aboutsummaryrefslogtreecommitdiff
path: root/internal/admin/api_entity_path_test.go
blob: 27ecbe858525a4af34ce84d9f6ed6bb4f069ffa9 (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
88
89
90
91
92
93
94
95
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")
	}
}

// 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)
	}
}