Public: anyone can read this; the crew can change it.
The security floor is on every response, not only the HTML ones #165
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/136-the-security-floor-is-on-every-response → main 1 commit fork
5 files
changed +266
−78
CLAUDE.md +2 −1
| @@ -99,7 +99,8 @@ src/ | ||
| 99 | 99 | updates reach its origin — and contents.ts is the whole edit |
| 100 | 100 | (files, a new branch, a pull) as one call |
| 101 | 101 | http/ router, request context (Ctx/auth), respond (json/error shapes, |
| 102 | scope floors, pagination), api (every /api/v1 route), server | |
| 102 | scope floors, pagination, `securityFloor` — the headers every | |
| 103 | response leaves with), api (every /api/v1 route), server | |
| 103 | 104 | (auth resolution, git smart HTTP, web routes, hook socket), sse |
| 104 | 105 | design/ shared design tokens: themes (palettes both surfaces render) |
| 105 | 106 | origins/ other forges as one Provider interface: discover (version-endpoint |
docs/ARCHITECTURE.md +7 −1
| @@ -206,7 +206,13 @@ Two lessons Forgejo is still paying for, designed in up front: | ||
| 206 | 206 | code. Six premade themes ship (three light, three dark); "system" follows |
| 207 | 207 | the OS. The switcher is a plain form POST with a cookie — script only makes |
| 208 | 208 | it instant. shadcn's essence without Tailwind: tokens, radii, spacing scale, |
| 209 | semantic colors. Strict CSP on every response. | |
| 209 | semantic colors. | |
| 210 | - **A security floor at the boundary, not per responder**: one wrapper around | |
| 211 | everything the forge answers for itself (`securityFloor`, `http/respond.ts`) | |
| 212 | puts `nosniff` and `referrer-policy: same-origin` on every response, and an | |
| 213 | inert `default-src 'none'` CSP on every non-document one — JSON, CSS, | |
| 214 | redirects. A page keeps the nonce'd policy it writes for itself, and an | |
| 215 | app's proxied response is carried untouched: its headers are its own. | |
| 210 | 216 | - **Mobile-first**: layouts compose from a single column up; touch targets |
| 211 | 217 | ≥44px; the fast paths (open issue, comment, review a diff, merge) are |
| 212 | 218 | designed on a phone frame first and enhanced for desktop. |
src/http/respond.ts +43
| @@ -95,6 +95,49 @@ export function prefersHtml(request: Request, pathname: string): boolean { | ||
| 95 | 95 | ); |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | /** | |
| 99 | * The policy for a body that is not a document: nothing loads, nothing | |
| 100 | * frames it, no base tag redirects a relative URL inside it. JSON carrying | |
| 101 | * repository names and comment text a person typed is the case that wants | |
| 102 | * it — if a browser is ever talked into treating such a body as markup, | |
| 103 | * this is what refuses to fetch or run anything it names. | |
| 104 | */ | |
| 105 | const FLOOR_POLICY = | |
| 106 | "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"; | |
| 107 | ||
| 108 | /** | |
| 109 | * The security floor every response the forge writes leaves with: never | |
| 110 | * sniffed into another type, never a referrer sent off-site, and — unless | |
| 111 | * the body is a document that wrote its own policy — inert. | |
| 112 | * | |
| 113 | * A responder that already set one of these keeps its value: `page()` has a | |
| 114 | * nonce'd policy that must survive, and a fragment has a sandboxed one. The | |
| 115 | * headers are set on the response that exists rather than on a copy of it, | |
| 116 | * so a stream (the log follow, SSE) is never re-read — the same trade the | |
| 117 | * rate-limit headers make one seam up. | |
| 118 | */ | |
| 119 | export function securityFloor(response: Response): Response { | |
| 120 | try { | |
| 121 | const headers = response.headers; | |
| 122 | if (!headers.has("x-content-type-options")) | |
| 123 | headers.set("x-content-type-options", "nosniff"); | |
| 124 | if (!headers.has("referrer-policy")) | |
| 125 | headers.set("referrer-policy", "same-origin"); | |
| 126 | // What left decides, not what was asked for: an HTML body writes the | |
| 127 | // only policy that can name its own nonce, and `default-src 'none'` | |
| 128 | // over a page would strip it of its stylesheet. | |
| 129 | const html = (response.headers.get("content-type") ?? "").startsWith( | |
| 130 | "text/html", | |
| 131 | ); | |
| 132 | if (!html && !headers.has("content-security-policy")) | |
| 133 | headers.set("content-security-policy", FLOOR_POLICY); | |
| 134 | } catch { | |
| 135 | // An immutable header set — a response the forge is only carrying — | |
| 136 | // keeps the headers it came with, which is the right answer anyway. | |
| 137 | } | |
| 138 | return response; | |
| 139 | } | |
| 140 | ||
| 98 | 141 | /** |
| 99 | 142 | * Map an action's error to a response: the three access errors have fixed |
| 100 | 143 | * shapes; anything else is a validation code the caller chose a status for. |
src/http/server.ts +103 −76
| @@ -219,7 +219,14 @@ import { | ||
| 219 | 219 | type Ctx, |
| 220 | 220 | type Handler, |
| 221 | 221 | } from "./context.ts"; |
| 222 | import { apiError, json, noRoute, notFound, prefersHtml } from "./respond.ts"; | |
| 222 | import { | |
| 223 | apiError, | |
| 224 | json, | |
| 225 | noRoute, | |
| 226 | notFound, | |
| 227 | prefersHtml, | |
| 228 | securityFloor, | |
| 229 | } from "./respond.ts"; | |
| 223 | 230 | import { RateLimiter, type Verdict } from "../core/ratelimit.ts"; |
| 224 | 231 | import { |
| 225 | 232 | MAX_KEY_LENGTH, |
| @@ -1096,86 +1103,21 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1096 | 1103 | const runtime = appsRuntime(app); |
| 1097 | 1104 | if (runtime !== undefined) { |
| 1098 | 1105 | const target = resolveAppHost(app, runtime.options.domain, request); |
| 1099 | if (target === "unknown") | |
| 1100 | return new Response("not found", { status: 404 }); | |
| 1101 | if (target !== undefined) | |
| 1106 | // A preview's headers are the app's business: what `proxyApp` | |
| 1107 | // carries back leaves exactly as the app wrote it, which is why the | |
| 1108 | // floor below sits after this return and not around it. | |
| 1109 | if (target !== undefined && target !== "unknown") | |
| 1102 | 1110 | return proxyApp(runtime, request, target, server); |
| 1111 | if (target === "unknown") | |
| 1112 | return securityFloor(new Response("not found", { status: 404 })); | |
| 1103 | 1113 | } |
| 1104 | const url = publicUrl(request, app.config.url); | |
| 1105 | if (url.pathname === "/healthz") return healthResponse(); | |
| 1106 | const matched = router.match(request.method, url.pathname); | |
| 1107 | if (!matched) { | |
| 1108 | // A path under no route at all is what a mistyped or stale studio | |
| 1109 | // URL lands on. A browser gets the forge's own page for it, same as | |
| 1110 | // any other 404; an API client — anything outside `/api/` that never | |
| 1111 | // said it wanted HTML — keeps the `no_route` body, distinct from | |
| 1112 | // `not_found` so it can tell a typo from a resource that is really | |
| 1113 | // gone. The page is rendered from a viewer-less `Ctx`: resolving a | |
| 1114 | // session or token here would spend a database read on a path that, | |
| 1115 | // by construction, cannot belong to a signed-in-only route, at the | |
| 1116 | // cost of the topbar reading "Sign in" even for someone who is. | |
| 1117 | if (prefersHtml(request, url.pathname)) { | |
| 1118 | const themeCookie = cookieValue(request, "forge_theme") ?? ""; | |
| 1119 | const modeCookieValue = cookieValue(request, "forge_mode") ?? ""; | |
| 1120 | const anonCtx: Ctx = { | |
| 1121 | app, | |
| 1122 | request, | |
| 1123 | url, | |
| 1124 | params: {}, | |
| 1125 | auth: { user: undefined, scope: undefined, via: "none" }, | |
| 1126 | theme: isThemeName(themeCookie) ? themeCookie : undefined, | |
| 1127 | mode: isMode(modeCookieValue) ? modeCookieValue : DEFAULT_MODE, | |
| 1128 | }; | |
| 1129 | return notFoundPage(anonCtx); | |
| 1130 | } | |
| 1131 | return noRoute(request.method, url.pathname); | |
| 1132 | } | |
| 1133 | const auth = await resolveAuth(app, request); | |
| 1134 | ||
| 1135 | // The two things standing between one runaway agent and everybody | |
| 1136 | // else's latency, applied before a handler runs and after auth is | |
| 1137 | // known — a limit needs an identity, and a replay needs a credential. | |
| 1138 | const credential = tokenCredential(request, auth); | |
| 1139 | const verdict = | |
| 1140 | credential === undefined | |
| 1141 | ? undefined | |
| 1142 | : limiter.take(credential, auth.scope ?? "ro"); | |
| 1143 | if (verdict !== undefined && !verdict.allowed) { | |
| 1144 | return withRateHeaders(tooManyRequests(verdict), verdict); | |
| 1145 | } | |
| 1146 | ||
| 1147 | const themeCookie = cookieValue(request, "forge_theme") ?? ""; | |
| 1148 | const modeCookieValue = cookieValue(request, "forge_mode") ?? ""; | |
| 1149 | // `?mode=` on the URL is the escape hatch: a plain link a person is | |
| 1150 | // handed switches the browser by itself, the same way the toggle's | |
| 1151 | // POST does, so it must work in both directions with no form. | |
| 1152 | const modeParam = url.searchParams.get("mode"); | |
| 1153 | const modeOverride = isMode(modeParam) ? modeParam : undefined; | |
| 1154 | const mode = | |
| 1155 | modeOverride ?? (isMode(modeCookieValue) ? modeCookieValue : DEFAULT_MODE); | |
| 1156 | const invoke = (into: Request): Response | Promise<Response> => | |
| 1157 | matched.handler({ | |
| 1158 | app, | |
| 1159 | request: into, | |
| 1160 | url, | |
| 1161 | params: matched.params, | |
| 1162 | auth, | |
| 1163 | theme: isThemeName(themeCookie) ? themeCookie : undefined, | |
| 1164 | mode, | |
| 1165 | }); | |
| 1166 | ||
| 1167 | const response = | |
| 1168 | credential === undefined | |
| 1169 | ? await invoke(request) | |
| 1170 | : await withIdempotency(app, request, url, credential, invoke); | |
| 1171 | return withRateHeaders( | |
| 1172 | withModeCookie(response, modeOverride, url.protocol === "https:"), | |
| 1173 | verdict, | |
| 1174 | ); | |
| 1114 | // Everything the forge answers for itself leaves through this one | |
| 1115 | // door, so a responder added next year cannot forget the floor. | |
| 1116 | return securityFloor(await answer(app, router, limiter, request)); | |
| 1175 | 1117 | }, |
| 1176 | 1118 | error(error) { |
| 1177 | 1119 | app.log.error("unhandled request error", { error: String(error) }); |
| 1178 | return json({ error: "internal" }, 500); | |
| 1120 | return securityFloor(json({ error: "internal" }, 500)); | |
| 1179 | 1121 | }, |
| 1180 | 1122 | }); |
| 1181 | 1123 | |
| @@ -1202,3 +1144,88 @@ export async function serve(app: App): Promise<Servers> { | ||
| 1202 | 1144 | }, |
| 1203 | 1145 | }; |
| 1204 | 1146 | } |
| 1147 | ||
| 1148 | /** | |
| 1149 | * One request against the forge's own routes: health, the route that | |
| 1150 | * matched or the two shapes of "no route", the limiter, and the handler. | |
| 1151 | * The response it returns has not been given the security floor yet — that | |
| 1152 | * is the caller's single door, above. | |
| 1153 | */ | |
| 1154 | async function answer( | |
| 1155 | app: App, | |
| 1156 | router: Router<Handler>, | |
| 1157 | limiter: RateLimiter, | |
| 1158 | request: Request, | |
| 1159 | ): Promise<Response> { | |
| 1160 | const url = publicUrl(request, app.config.url); | |
| 1161 | if (url.pathname === "/healthz") return healthResponse(); | |
| 1162 | const matched = router.match(request.method, url.pathname); | |
| 1163 | if (!matched) { | |
| 1164 | // A path under no route at all is what a mistyped or stale studio | |
| 1165 | // URL lands on. A browser gets the forge's own page for it, same as | |
| 1166 | // any other 404; an API client — anything outside `/api/` that never | |
| 1167 | // said it wanted HTML — keeps the `no_route` body, distinct from | |
| 1168 | // `not_found` so it can tell a typo from a resource that is really | |
| 1169 | // gone. The page is rendered from a viewer-less `Ctx`: resolving a | |
| 1170 | // session or token here would spend a database read on a path that, | |
| 1171 | // by construction, cannot belong to a signed-in-only route, at the | |
| 1172 | // cost of the topbar reading "Sign in" even for someone who is. | |
| 1173 | if (prefersHtml(request, url.pathname)) { | |
| 1174 | const themeCookie = cookieValue(request, "forge_theme") ?? ""; | |
| 1175 | const modeCookieValue = cookieValue(request, "forge_mode") ?? ""; | |
| 1176 | const anonCtx: Ctx = { | |
| 1177 | app, | |
| 1178 | request, | |
| 1179 | url, | |
| 1180 | params: {}, | |
| 1181 | auth: { user: undefined, scope: undefined, via: "none" }, | |
| 1182 | theme: isThemeName(themeCookie) ? themeCookie : undefined, | |
| 1183 | mode: isMode(modeCookieValue) ? modeCookieValue : DEFAULT_MODE, | |
| 1184 | }; | |
| 1185 | return notFoundPage(anonCtx); | |
| 1186 | } | |
| 1187 | return noRoute(request.method, url.pathname); | |
| 1188 | } | |
| 1189 | const auth = await resolveAuth(app, request); | |
| 1190 | ||
| 1191 | // The two things standing between one runaway agent and everybody | |
| 1192 | // else's latency, applied before a handler runs and after auth is | |
| 1193 | // known — a limit needs an identity, and a replay needs a credential. | |
| 1194 | const credential = tokenCredential(request, auth); | |
| 1195 | const verdict = | |
| 1196 | credential === undefined | |
| 1197 | ? undefined | |
| 1198 | : limiter.take(credential, auth.scope ?? "ro"); | |
| 1199 | if (verdict !== undefined && !verdict.allowed) { | |
| 1200 | return withRateHeaders(tooManyRequests(verdict), verdict); | |
| 1201 | } | |
| 1202 | ||
| 1203 | const themeCookie = cookieValue(request, "forge_theme") ?? ""; | |
| 1204 | const modeCookieValue = cookieValue(request, "forge_mode") ?? ""; | |
| 1205 | // `?mode=` on the URL is the escape hatch: a plain link a person is | |
| 1206 | // handed switches the browser by itself, the same way the toggle's | |
| 1207 | // POST does, so it must work in both directions with no form. | |
| 1208 | const modeParam = url.searchParams.get("mode"); | |
| 1209 | const modeOverride = isMode(modeParam) ? modeParam : undefined; | |
| 1210 | const mode = | |
| 1211 | modeOverride ?? (isMode(modeCookieValue) ? modeCookieValue : DEFAULT_MODE); | |
| 1212 | const invoke = (into: Request): Response | Promise<Response> => | |
| 1213 | matched.handler({ | |
| 1214 | app, | |
| 1215 | request: into, | |
| 1216 | url, | |
| 1217 | params: matched.params, | |
| 1218 | auth, | |
| 1219 | theme: isThemeName(themeCookie) ? themeCookie : undefined, | |
| 1220 | mode, | |
| 1221 | }); | |
| 1222 | ||
| 1223 | const response = | |
| 1224 | credential === undefined | |
| 1225 | ? await invoke(request) | |
| 1226 | : await withIdempotency(app, request, url, credential, invoke); | |
| 1227 | return withRateHeaders( | |
| 1228 | withModeCookie(response, modeOverride, url.protocol === "https:"), | |
| 1229 | verdict, | |
| 1230 | ); | |
| 1231 | } |
test/web.test.ts +111
| @@ -13,6 +13,8 @@ import { | ||
| 13 | 13 | type IconName, |
| 14 | 14 | } from "../src/web/icons.ts"; |
| 15 | 15 | import { renderMarkdown } from "../src/web/markdown.ts"; |
| 16 | import { highlightAsset } from "../src/web/asset.ts"; | |
| 17 | import { securityFloor } from "../src/http/respond.ts"; | |
| 16 | 18 | import { THEME_NAMES } from "../src/design/themes.ts"; |
| 17 | 19 | |
| 18 | 20 | describe("html template", () => { |
| @@ -642,3 +644,112 @@ describe("readiness", () => { | ||
| 642 | 644 | expect((await fetch(`${servers.url}/`)).status).toBe(200); |
| 643 | 645 | }); |
| 644 | 646 | }); |
| 647 | ||
| 648 | const FLOOR_POLICY = | |
| 649 | "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"; | |
| 650 | ||
| 651 | /** | |
| 652 | * The floor, on one response: never sniffed, never a referrer off-site, and | |
| 653 | * — unless the body is a document with a policy of its own — inert. | |
| 654 | */ | |
| 655 | function expectFloor(what: string, response: Response): void { | |
| 656 | const label = `${what}: `; | |
| 657 | expect(label + response.headers.get("x-content-type-options")).toBe( | |
| 658 | label + "nosniff", | |
| 659 | ); | |
| 660 | expect(label + response.headers.get("referrer-policy")).toBe( | |
| 661 | label + "same-origin", | |
| 662 | ); | |
| 663 | const policy = response.headers.get("content-security-policy") ?? ""; | |
| 664 | expect(label + (policy === "" ? "(none)" : "(a policy)")).toBe( | |
| 665 | label + "(a policy)", | |
| 666 | ); | |
| 667 | const html = (response.headers.get("content-type") ?? "").startsWith( | |
| 668 | "text/html", | |
| 669 | ); | |
| 670 | if (!html) expect(label + policy).toBe(label + FLOOR_POLICY); | |
| 671 | } | |
| 672 | ||
| 673 | describe("the security floor is on every response", () => { | |
| 674 | let dataDir: string; | |
| 675 | let app: App; | |
| 676 | let servers: Servers; | |
| 677 | let base: string; | |
| 678 | ||
| 679 | beforeAll(async () => { | |
| 680 | dataDir = await mkdtemp(join(tmpdir(), "forge-floor-")); | |
| 681 | app = createApp({ dataDir, port: 0 }); | |
| 682 | servers = await serve(app); | |
| 683 | base = servers.url; | |
| 684 | }); | |
| 685 | ||
| 686 | afterAll(async () => { | |
| 687 | servers.stop(); | |
| 688 | app.close(); | |
| 689 | await rm(dataDir, { recursive: true, force: true }); | |
| 690 | }); | |
| 691 | ||
| 692 | test("one of each kind the forge serves carries it", async () => { | |
| 693 | const asset = await highlightAsset(); | |
| 694 | expect(asset).not.toBeNull(); | |
| 695 | ||
| 696 | const page = await fetch(`${base}/`); | |
| 697 | expect(page.status).toBe(200); | |
| 698 | expectFloor("html page", page); | |
| 699 | // An HTML body keeps the nonce'd policy it writes for itself: the floor | |
| 700 | // adds the other two headers and does not touch a policy already there. | |
| 701 | expect(page.headers.get("content-security-policy")).toContain("nonce-"); | |
| 702 | ||
| 703 | const list = await fetch(`${base}/api/v1/repos`); | |
| 704 | expect(list.status).toBe(200); | |
| 705 | expectFloor("json", list); | |
| 706 | ||
| 707 | const missing = await fetch(`${base}/api/v1/repos/nobody/nothing`); | |
| 708 | expect(missing.status).toBe(404); | |
| 709 | expectFloor("error json", missing); | |
| 710 | ||
| 711 | const typo = await fetch(`${base}/no/such/route`, { | |
| 712 | headers: { accept: "application/json" }, | |
| 713 | }); | |
| 714 | expect(typo.status).toBe(404); | |
| 715 | expect(((await typo.json()) as { error: string }).error).toBe("no_route"); | |
| 716 | expectFloor("no_route json", typo); | |
| 717 | ||
| 718 | const stylesheet = await fetch(`${base}/assets/forge.css`); | |
| 719 | expect(stylesheet.status).toBe(200); | |
| 720 | expectFloor("stylesheet", stylesheet); | |
| 721 | ||
| 722 | const built = await fetch(`${base}${asset!.path}`); | |
| 723 | expect(built.status).toBe(200); | |
| 724 | expectFloor("built asset", built); | |
| 725 | ||
| 726 | const redirect = await fetch(`${base}/theme`, { | |
| 727 | method: "POST", | |
| 728 | body: new URLSearchParams({ theme: "dark" }), | |
| 729 | redirect: "manual", | |
| 730 | }); | |
| 731 | expect(redirect.status).toBe(303); | |
| 732 | expectFloor("redirect", redirect); | |
| 733 | ||
| 734 | const health = await fetch(`${base}/healthz`); | |
| 735 | expect(health.status).toBe(200); | |
| 736 | expectFloor("healthz", health); | |
| 737 | }); | |
| 738 | ||
| 739 | test("a responder that set one of these keeps its own value", () => { | |
| 740 | const own = securityFloor( | |
| 741 | new Response("<p>fragment</p>", { | |
| 742 | headers: { | |
| 743 | "content-type": "text/html; charset=utf-8", | |
| 744 | "content-security-policy": "default-src 'none'; sandbox", | |
| 745 | "referrer-policy": "no-referrer", | |
| 746 | }, | |
| 747 | }), | |
| 748 | ); | |
| 749 | expect(own.headers.get("content-security-policy")).toBe( | |
| 750 | "default-src 'none'; sandbox", | |
| 751 | ); | |
| 752 | expect(own.headers.get("referrer-policy")).toBe("no-referrer"); | |
| 753 | expect(own.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 754 | }); | |
| 755 | }); |
new above
offline