package admin import ( "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "thehouseoficarus/internal/config" "thehouseoficarus/internal/player" ) // TestPlayerAPICannotEscapeData: /api/players/{name} joins the raw URL // remainder into players/characters/.yaml. ServeMux cleans the ESCAPED // path but handlers read the DECODED r.URL.Path, so a percent-encoded segment // (%2e%2e%2f) arrives as "../" and would otherwise read arbitrary *.yaml — // including account files, bypassing the password_hash redaction that // getAccount applies. The route must validate the name before the first join. func TestPlayerAPICannotEscapeData(t *testing.T) { root := t.TempDir() dir := filepath.Join(root, "data") for _, sub := range []string{"players/accounts", "players/characters"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o700); err != nil { t.Fatal(err) } } accounts := player.NewAccountStore(dir) if err := accounts.SaveAccount(&player.Account{Name: "auditor", Admin: true, PasswordHash: "argon2id$auditor-hash"}); err != nil { t.Fatal(err) } if err := accounts.SaveAccount(&player.Account{Name: "victim", PasswordHash: "argon2id$victim-hash"}); err != nil { t.Fatal(err) } victim := player.New("victim") victim.Account = "victim" if err := accounts.SaveCharacter(victim); err != nil { t.Fatal(err) } // A canary file outside data/ that traversal could otherwise reach. if err := os.WriteFile(filepath.Join(root, "outside.yaml"), []byte("secret: canary\n"), 0o600); err != nil { t.Fatal(err) } s, err := NewServer(ServerDeps{ Config: config.Default(), Accounts: accounts, DataDir: dir, ConfigPath: filepath.Join(dir, "config.yaml"), RunOnOwner: func(fn func()) bool { fn(); return true }, }) if err != nil { t.Fatal(err) } cookieRec := httptest.NewRecorder() s.setAuthCookie(cookieRec, "auditor") cookie := cookieRec.Result().Cookies()[0] get := func(target string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, target, nil) req.AddCookie(cookie) rec := httptest.NewRecorder() s.ServeHTTP(rec, req) return rec } // Encoded traversal to an account file must be rejected, and the raw // password hash must never appear in the response. rec := get("/api/players/%2e%2e%2faccounts%2fvictim") if rec.Code != http.StatusBadRequest { t.Fatalf("encoded traversal: status %d body %s", rec.Code, rec.Body.String()) } if strings.Contains(rec.Body.String(), "victim-hash") { t.Fatal("encoded traversal leaked a password hash") } // Traversal out of the data dir to a canary must be rejected. rec = get("/api/players/%2e%2e%2f%2e%2e%2foutside") if rec.Code != http.StatusBadRequest { t.Fatalf("outside traversal: status %d body %s", rec.Code, rec.Body.String()) } if strings.Contains(rec.Body.String(), "canary") { t.Fatal("encoded traversal read a file outside the data dir") } // A legitimate lookup still works. if rec = get("/api/players/victim"); rec.Code != http.StatusOK { t.Fatalf("legit player lookup: status %d body %s", rec.Code, rec.Body.String()) } }