aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_players_path_test.go
blob: dca156ee7c56c72e5371d0db28add8d347ec0d13 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
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/<name>.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(config.Default(), false, accounts, nil, nil, nil, nil, dir,
		nil, nil, nil, nil, filepath.Join(dir, "config.yaml"),
		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())
	}
}