File size: 5,530 Bytes
feccc69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
145
146
147
148
149
package webui

import (
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// TestServeFromDiskPinsContentType ensures static admin assets are returned
// with an explicit, RFC-compliant Content-Type that does not depend on
// mime.TypeByExtension. On Windows mime.TypeByExtension consults the registry
// (HKEY_CLASSES_ROOT) which third-party software can corrupt — for example
// installing certain editors rewrites .css to application/xml — and Chrome
// then refuses to apply a stylesheet whose Content-Type is not text/css,
// breaking the /admin page entirely. Pinning the type by file extension makes
// the response deterministic across operating systems and machine state.
func TestServeFromDiskPinsContentType(t *testing.T) {
	staticDir := t.TempDir()
	assetsDir := filepath.Join(staticDir, "assets")
	if err := os.MkdirAll(assetsDir, 0o755); err != nil {
		t.Fatalf("mkdir assets: %v", err)
	}

	files := map[string]string{
		"index.html":           "<!doctype html><html></html>",
		"assets/index.css":     "body{}",
		"assets/index.js":      "console.log(1)",
		"assets/icon.svg":      `<svg xmlns="http://www.w3.org/2000/svg"></svg>`,
		"assets/source.js.map": `{"version":3}`,
	}
	for rel, body := range files {
		full := filepath.Join(staticDir, filepath.FromSlash(rel))
		if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
			t.Fatalf("mkdir %s: %v", rel, err)
		}
		if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
			t.Fatalf("write %s: %v", rel, err)
		}
	}

	h := &Handler{StaticDir: staticDir}

	cases := []struct {
		urlPath      string
		wantPrefix   string
		wantCacheCtl string
	}{
		{"/admin/assets/index.css", "text/css", "public, max-age=31536000, immutable"},
		{"/admin/assets/index.js", "text/javascript", "public, max-age=31536000, immutable"},
		{"/admin/assets/icon.svg", "image/svg+xml", "public, max-age=31536000, immutable"},
		{"/admin/assets/source.js.map", "application/json", "public, max-age=31536000, immutable"},
		// "/admin/index.html" is intentionally omitted: http.ServeFile redirects
		// requests for index.html to "./", matching Go's net/http behavior. The
		// route the SPA actually lands on is "/admin/" below.
		{"/admin/", "text/html", "no-store, must-revalidate"},
	}

	for _, tc := range cases {
		t.Run(tc.urlPath, func(t *testing.T) {
			req := httptest.NewRequest(http.MethodGet, tc.urlPath, nil)
			rec := httptest.NewRecorder()
			h.serveFromDisk(rec, req, staticDir)

			if rec.Code != http.StatusOK {
				t.Fatalf("status = %d, want 200", rec.Code)
			}
			ct := rec.Header().Get("Content-Type")
			if !strings.HasPrefix(ct, tc.wantPrefix) {
				t.Fatalf("Content-Type = %q, want prefix %q", ct, tc.wantPrefix)
			}
			if got := rec.Header().Get("Cache-Control"); got != tc.wantCacheCtl {
				t.Fatalf("Cache-Control = %q, want %q", got, tc.wantCacheCtl)
			}
		})
	}
}

func TestServeFromDiskRejectsSiblingDirectoryWithSharedPrefix(t *testing.T) {
	parent := t.TempDir()
	staticDir := filepath.Join(parent, "admin")
	siblingDir := filepath.Join(parent, "admin-leak")
	if err := os.MkdirAll(staticDir, 0o755); err != nil {
		t.Fatalf("mkdir static dir: %v", err)
	}
	if err := os.MkdirAll(siblingDir, 0o755); err != nil {
		t.Fatalf("mkdir sibling dir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(siblingDir, "secret.txt"), []byte("secret"), 0o644); err != nil {
		t.Fatalf("write sibling secret: %v", err)
	}

	h := &Handler{StaticDir: staticDir}
	req := httptest.NewRequest(http.MethodGet, "/admin/../admin-leak/secret.txt", nil)
	rec := httptest.NewRecorder()
	h.serveFromDisk(rec, req, staticDir)

	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rec.Code)
	}
	if body := rec.Body.String(); strings.Contains(body, "secret") {
		t.Fatal("served content from sibling directory")
	}
}

func TestIsPathInsideRootAllowsFilesystemRootChildren(t *testing.T) {
	root := filepath.VolumeName(os.TempDir()) + string(os.PathSeparator)
	child := filepath.Join(root, "assets", "index.css")

	if !isPathInsideRoot(child, root) {
		t.Fatalf("expected filesystem-root child %q inside %q", child, root)
	}
}

func TestIsPathInsideRootRejectsSharedPrefixSibling(t *testing.T) {
	parent := t.TempDir()
	root := filepath.Join(parent, "admin")
	sibling := filepath.Join(parent, "admin-leak", "secret.txt")

	if isPathInsideRoot(sibling, root) {
		t.Fatalf("expected shared-prefix sibling %q outside %q", sibling, root)
	}
}

// TestSetStaticContentTypeUnknownExtensionFallsThrough verifies that unknown
// extensions leave the Content-Type header unset, so http.ServeFile can apply
// its own detection (sniffing or mime.TypeByExtension) for cases the pinned
// table does not cover.
func TestSetStaticContentTypeUnknownExtensionFallsThrough(t *testing.T) {
	rec := httptest.NewRecorder()
	setStaticContentType(rec, "/tmp/data.unknownext")
	if got := rec.Header().Get("Content-Type"); got != "" {
		t.Fatalf("Content-Type = %q, want empty for unknown extension", got)
	}
}

// TestSetStaticContentTypeIsCaseInsensitive guards against a regression where
// uppercase extensions (e.g. STYLE.CSS shipped from some build pipelines)
// would bypass the pinned table and fall back to the registry on Windows.
func TestSetStaticContentTypeIsCaseInsensitive(t *testing.T) {
	rec := httptest.NewRecorder()
	setStaticContentType(rec, "/tmp/STYLE.CSS")
	if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/css") {
		t.Fatalf("Content-Type = %q, want text/css prefix", got)
	}
}