Skip to content

Commit df0da68

Browse files
sec(api): renderAuthError Cache-Control: no-store + lang="en" (BUG-API-257/404)
The OAuth / magic-link callback HTML is per-request session-bound state (the underlying magic-link / OAuth code has been consumed or expired by the time the error page renders). Two compounding gaps shipped: 1. BUG-API-404 — no Cache-Control. A back-button, service-worker re-fetch, or intermediate proxy could replay the body, leaking the "you tried this link" UX state across sessions. Stamp Cache-Control: no-store (RFC 9111 §5.2.2.5) which is the strongest stop-cache directive and matches the contract every other auth-result surface in the api already follows. 2. BUG-API-257 — <html> shipped with no `lang` attribute. WCAG 3.1.1 ("Language of Page") requires a programmatically determinable primary language; assistive tech (VoiceOver, NVDA) falls back to the OS locale otherwise, mispronouncing English copy in non-English locales. Pin lang="en" to match the static English-only copy. Both fix at the single renderAuthError sink — fanning out across every OAuth / magic-link callback error path (~20 call sites in auth.go + magic_link.go) without scattering c.Set / template edits. Coverage block: Symptom: OAuth/magic-link error HTML missing Cache-Control: no-store (BUG-API-404) and lang attribute (BUG-API-257) Enumeration: rg -F 'renderAuthError' internal/handlers/ (~25 sites) All flow through a single emit point in auth.go:1113 Sites found: 1 emit function (~25 callers) Sites touched: 1 (single sink fix) Coverage test: TestAuth_RenderAuthError_StatusAndContentType now pins - Cache-Control: no-store - body contains `<html lang="en">` so a future revert of either fails before merge. Live verified: pending auto-deploy + curl -sI 'https://api.instanode.dev/auth/email/callback?token=invalid' | grep -i cache-control Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bc7cc64 commit df0da68

2 files changed

Lines changed: 31 additions & 1 deletion

File tree

internal/handlers/auth.go

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1112,8 +1112,26 @@ func (h *AuthHandler) consumeOAuthState(ctx context.Context, state string) bool
11121112
// remember to escape — defense in depth.
11131113
func renderAuthError(c *fiber.Ctx, status int, headline, detail string) error {
11141114
c.Set("Content-Type", "text/html; charset=utf-8")
1115+
// BUG-API-404 (QA 2026-05-29): the OAuth / magic-link callback HTML
1116+
// is a per-request rendering of session-bound state — a back-button,
1117+
// browser-history-restore, or service-worker re-fetch must NOT replay
1118+
// it (the underlying token has been consumed or expired). Without
1119+
// Cache-Control, the body could be re-served by the browser fetch
1120+
// cache or any intermediary, which both leaks the "you tried this
1121+
// link" UX state across sessions AND, in the success-redirect cousin
1122+
// of this surface, would re-set the exchange cookie. `no-store`
1123+
// (RFC 9111 §5.2.2.5) is the strongest stop-cache directive and
1124+
// matches the contract every other auth-result surface in the api
1125+
// already follows.
1126+
c.Set(fiber.HeaderCacheControl, "no-store")
1127+
// BUG-API-257 (QA 2026-05-29): the <html> element used to ship with
1128+
// no `lang` attribute. WCAG 3.1.1 "Language of Page" requires a
1129+
// programmatically determinable primary language; assistive tech
1130+
// (VoiceOver, NVDA) falls back to the OS locale otherwise, mis-
1131+
// pronouncing English copy in non-English locales. `lang="en"` is
1132+
// the correct value for the static English-only copy below.
11151133
body := fmt.Sprintf(`<!DOCTYPE html>
1116-
<html>
1134+
<html lang="en">
11171135
<head><meta charset="UTF-8"><title>Sign-in error</title></head>
11181136
<body style="font-family:sans-serif;max-width:480px;margin:48px auto;padding:24px;color:#111;">
11191137
<h2>%s</h2>

internal/handlers/auth_helpers_coverage_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,12 +263,24 @@ func TestAuth_RenderAuthError_StatusAndContentType(t *testing.T) {
263263
defer resp.Body.Close()
264264
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
265265
assert.Contains(t, resp.Header.Get("Content-Type"), "text/html")
266+
// BUG-API-404 (QA 2026-05-29): the OAuth / magic-link callback HTML
267+
// is per-request session-bound state — back-button or service-worker
268+
// re-fetch must NOT replay it. Pin Cache-Control: no-store so a
269+
// future regression that drops the c.Set call fails before merge.
270+
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"),
271+
"BUG-API-404: renderAuthError must stamp Cache-Control: no-store on every callback HTML response")
266272
buf := make([]byte, 1024)
267273
n, _ := resp.Body.Read(buf)
268274
body := string(buf[:n])
269275
assert.Contains(t, body, "<title>Sign-in error")
270276
assert.Contains(t, body, "Hello")
271277
assert.Contains(t, body, "Detail")
278+
// BUG-API-257 (QA 2026-05-29): WCAG 3.1.1 — the <html> element MUST
279+
// carry a lang attribute so assistive tech (VoiceOver, NVDA) doesn't
280+
// fall back to the OS locale and mispronounce English copy. Pin the
281+
// value here so a future revert to the bare <html> fails before merge.
282+
assert.Contains(t, body, `<html lang="en">`,
283+
"BUG-API-257: renderAuthError HTML must include lang=\"en\" on the <html> element (WCAG 3.1.1)")
272284
}
273285

274286
// SEC-API FINDING-23 regression: renderAuthError must HTML-escape both

0 commit comments

Comments
 (0)