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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
|
package admin
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"thehouseoficarus/internal/behavior"
"thehouseoficarus/internal/config"
"thehouseoficarus/internal/player"
)
// TestEntityAPICannotEscapeData: entity create/rename IDs and directory
// targets join into file paths, so a traversal ID ("../../x") must be
// rejected instead of writing outside the data directory.
func TestEntityAPICannotEscapeData(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "data")
for _, sub := range []string{"objects", "players/accounts"} {
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}); 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]
post := func(path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
return rec
}
// Traversal entity ID via the structured create route.
if rec := post("/api/objects", `{"id":"../../outside","name":"Escaped object"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("traversal object id: status %d body %s", rec.Code, rec.Body.String())
}
// Traversal new ID via the entity rename route: the rename handler
// must reject the request, not rename anything.
rec := post("/api/objects/rename", `{"id":"test","new_id":"../../outside"}`)
if !strings.Contains(rec.Body.String(), "error") {
t.Fatalf("traversal rename id not rejected: status %d body %s", rec.Code, rec.Body.String())
}
// Nothing may exist outside the data directory.
if _, err := os.Stat(filepath.Join(root, "outside.yaml")); !os.IsNotExist(err) {
t.Fatal("entity API escaped the data directory")
}
}
// TestTechModuleCreateRejectsTraversalID: POST /api/techs and /api/modules
// once joined an unvalidated id into a filesystem path, allowing an
// arbitrary write outside the data directory (and a destructive undo).
func TestTechModuleCreateRejectsTraversalID(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "data")
for _, sub := range []string{"techs", "modules", "players/accounts"} {
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}); 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]
post := func(path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
req.AddCookie(cookie)
rec := httptest.NewRecorder()
s.ServeHTTP(rec, req)
return rec
}
for _, tc := range []struct{ path, body string }{
{"/api/techs", `{"id":"../../outside_tech","name":"x"}`},
{"/api/modules", `{"id":"../../outside_mod","name":"x"}`},
} {
if rec := post(tc.path, tc.body); !strings.Contains(rec.Body.String(), "invalid id") {
t.Fatalf("%s traversal id not rejected: status %d body %s", tc.path, rec.Code, rec.Body.String())
}
}
if _, err := os.Stat(filepath.Join(root, "outside_tech.yaml")); !os.IsNotExist(err) {
t.Fatal("tech create escaped the data directory")
}
if _, err := os.Stat(filepath.Join(root, "outside_mod.yaml")); !os.IsNotExist(err) {
t.Fatal("module create escaped the data directory")
}
}
// TestEntityRenameRejectsDuplicateIDInOtherDirectory: entity IDs are global
// across the type's subdirectory tree (the runtime path indexes resolve by
// filename anywhere under data/<subdir>), so renaming into an ID that
// exists in a DIFFERENT directory must be rejected instead of creating a
// duplicate ID shadowed by whichever file the index finds first.
func TestEntityRenameRejectsDuplicateIDInOtherDirectory(t *testing.T) {
dir := t.TempDir()
for _, sub := range []string{"items/a", "items/b"} {
if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(dir, "items/a/old.yaml"), []byte("name: Original\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "items/b/taken.yaml"), []byte("name: Existing\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, _, _, err := renameEntityFile(dir, "items", "old", "taken"); err == nil {
t.Fatal("rename accepted an ID that already exists in another directory")
}
if dups := behavior.CheckDuplicateIDs(filepath.Join(dir, "items")); len(dups) != 0 {
t.Fatalf("rename produced duplicate IDs: %+v", dups)
}
// Renaming within the same directory to a free name still works.
if _, _, _, err := renameEntityFile(dir, "items", "old", "moved"); err != nil {
t.Fatalf("ordinary rename rejected: %v", err)
}
}
|