Skip to content

Commit 7166e7a

Browse files
committed
feat: themed static 404 page for unmatched routes
Wires chi's NotFound to a self-contained handler so a typo'd URL (or a bookmark to the now-renamed /history) lands on a small Gearbox-themed page instead of the browser's default 404. Particularly relevant after the /history → /metrics rename in this PR — anyone with a bookmark to the old URL will hit this. Security posture: the handler is deliberately static end-to-end. - No templ rendering, no auth middleware, no DB / agent lookups. - HTML is a Go const so there's no filesystem or template lookup at request time. - Inline CSS only (no <link> tags) so the response works even if the static-asset bundle didn't load. - No request data echoed into the response (test enforces this) — guards against the page becoming a reflection vector. - `Cache-Control: no-store` so a 404 doesn't outlive a deploy that later adds the missing route. Tests: - TestNotFoundHandlerStatusAndBody: 404 status, HTML body, Cache- Control, expected copy. - TestNotFoundHandlerDoesNotEchoRequestData: URL/header/cookie probes don't appear in the response body — handler is fully static. - TestNotFoundHandlerStableAcrossMethods: GET/POST/PUT/DELETE/PATCH all return the same 404 page. Visual: matches the dashboard's blue accent (#2563eb), uses system fonts (no external font loading), `prefers-color-scheme`-aware so it renders in dark mode without JS.
1 parent b7f161a commit 7166e7a

3 files changed

Lines changed: 181 additions & 0 deletions

File tree

gearbox/cmd/server/main.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,12 @@ func main() {
452452
r.Use(gbmiddleware.SecurityHeaders)
453453
// Inject asset configuration for templates (CDN vs local assets)
454454
r.Use(gbmiddleware.InjectAssetConfig(cfg.UseLocalAssets))
455+
456+
// Themed 404 for unmatched routes. The handler is intentionally
457+
// static (inline HTML, no auth/DB/templ) so a typo'd URL can't be a
458+
// side channel for fingerprinting session state. See the handler's
459+
// own godoc for the rationale.
460+
r.NotFound(handler.NotFoundHandler)
455461
// Note: Timeout middleware is applied per-route group below
456462
// SSE endpoints need to bypass the timeout middleware
457463

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package handler
2+
3+
import "net/http"
4+
5+
// NotFoundHandler serves a static, self-contained 404 page for any URL
6+
// that doesn't match a registered route. Wired in main.go via
7+
// chi's r.NotFound().
8+
//
9+
// Deliberately bypasses every dashboard concern beyond the HTTP
10+
// envelope: no templ rendering, no auth middleware, no DB / agent
11+
// lookups, no user-context injection. A page-not-found response for a
12+
// randomly-typed URL must not be a side channel for fingerprinting
13+
// session state or for accidentally exposing data that a logged-out
14+
// caller shouldn't see. The HTML is a const so there's no filesystem
15+
// or template lookup at request time either.
16+
//
17+
// Visually the page matches the dashboard's palette (blue accent,
18+
// system fonts, prefers-color-scheme-aware) and includes a single
19+
// link back to "/". All styles are inline so the response works even
20+
// if the static-asset bundle didn't load.
21+
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
22+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
23+
// 404s shouldn't be cached — the next deploy might add the route.
24+
w.Header().Set("Cache-Control", "no-store")
25+
w.WriteHeader(http.StatusNotFound)
26+
_, _ = w.Write([]byte(notFoundHTML))
27+
}
28+
29+
// notFoundHTML is the entire 404 response body. Kept as a const so the
30+
// handler has no runtime template or filesystem dependency — security
31+
// posture comment on NotFoundHandler explains why this matters.
32+
const notFoundHTML = `<!doctype html>
33+
<html lang="en">
34+
<head>
35+
<meta charset="utf-8">
36+
<title>404 — Page not found · Gearbox</title>
37+
<meta name="viewport" content="width=device-width,initial-scale=1">
38+
<meta name="robots" content="noindex">
39+
<style>
40+
:root { color-scheme: light dark; }
41+
* { box-sizing: border-box; }
42+
html, body { height: 100%; margin: 0; }
43+
body {
44+
display: flex;
45+
align-items: center;
46+
justify-content: center;
47+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
48+
background: #f8fafc;
49+
color: #0f172a;
50+
padding: 1.5rem;
51+
}
52+
@media (prefers-color-scheme: dark) {
53+
body { background: #0f172a; color: #e2e8f0; }
54+
.footer { color: #64748b; }
55+
}
56+
.card {
57+
text-align: center;
58+
max-width: 32rem;
59+
padding: 2rem;
60+
}
61+
h1 {
62+
font-size: 5rem;
63+
font-weight: 700;
64+
margin: 0 0 0.25rem;
65+
letter-spacing: -0.025em;
66+
color: #2563eb;
67+
line-height: 1;
68+
}
69+
.lead {
70+
font-size: 1.25rem;
71+
font-weight: 600;
72+
margin: 0 0 1rem;
73+
}
74+
p {
75+
margin: 0.5rem 0;
76+
line-height: 1.6;
77+
opacity: 0.85;
78+
}
79+
a.home {
80+
display: inline-block;
81+
margin-top: 1.5rem;
82+
padding: 0.625rem 1.25rem;
83+
background: #2563eb;
84+
color: #ffffff;
85+
text-decoration: none;
86+
border-radius: 0.5rem;
87+
font-weight: 500;
88+
font-size: 0.9375rem;
89+
transition: background-color 120ms ease;
90+
}
91+
a.home:hover, a.home:focus { background: #1d4ed8; }
92+
.footer {
93+
margin-top: 2rem;
94+
font-size: 0.75rem;
95+
color: #94a3b8;
96+
letter-spacing: 0.04em;
97+
text-transform: uppercase;
98+
}
99+
</style>
100+
</head>
101+
<body>
102+
<main class="card">
103+
<h1>404</h1>
104+
<p class="lead">Page not found</p>
105+
<p>The URL you requested isn't registered on this dashboard.</p>
106+
<a class="home" href="/">Back to dashboard</a>
107+
<p class="footer">Gearbox</p>
108+
</main>
109+
</body>
110+
</html>`
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package handler
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestNotFoundHandlerStatusAndBody(t *testing.T) {
11+
req := httptest.NewRequest(http.MethodGet, "/anything-not-registered", nil)
12+
w := httptest.NewRecorder()
13+
14+
NotFoundHandler(w, req)
15+
16+
if w.Code != http.StatusNotFound {
17+
t.Errorf("status = %d, want 404", w.Code)
18+
}
19+
ct := w.Header().Get("Content-Type")
20+
if !strings.HasPrefix(ct, "text/html") {
21+
t.Errorf("Content-Type = %q, want HTML", ct)
22+
}
23+
if cc := w.Header().Get("Cache-Control"); cc != "no-store" {
24+
t.Errorf("Cache-Control = %q, want no-store", cc)
25+
}
26+
body := w.Body.String()
27+
for _, want := range []string{"<!doctype html>", "404", "Page not found", "Back to dashboard"} {
28+
if !strings.Contains(body, want) {
29+
t.Errorf("body should contain %q", want)
30+
}
31+
}
32+
}
33+
34+
// TestNotFoundHandlerDoesNotEchoRequestData guards against accidentally
35+
// turning the 404 page into a reflection vector. The handler should
36+
// never embed any part of the request URL, headers, cookies, or body
37+
// into the response — the point of the static page is that a typo'd
38+
// URL produces a fully deterministic response.
39+
func TestNotFoundHandlerDoesNotEchoRequestData(t *testing.T) {
40+
const probe = "GEARBOX-PROBE-MARKER-39df09a1"
41+
req := httptest.NewRequest(http.MethodGet, "/"+probe, nil)
42+
req.Header.Set("X-Probe", probe)
43+
req.Header.Set("Cookie", "session="+probe)
44+
req.Header.Set("Referer", "https://example.com/"+probe)
45+
w := httptest.NewRecorder()
46+
47+
NotFoundHandler(w, req)
48+
49+
if strings.Contains(w.Body.String(), probe) {
50+
t.Errorf("response body contains request-supplied marker %q — handler is reflecting request data", probe)
51+
}
52+
}
53+
54+
func TestNotFoundHandlerStableAcrossMethods(t *testing.T) {
55+
for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
56+
t.Run(method, func(t *testing.T) {
57+
req := httptest.NewRequest(method, "/missing", nil)
58+
w := httptest.NewRecorder()
59+
NotFoundHandler(w, req)
60+
if w.Code != http.StatusNotFound {
61+
t.Errorf("%s: status = %d, want 404", method, w.Code)
62+
}
63+
})
64+
}
65+
}

0 commit comments

Comments
 (0)