Public: anyone can read this; the crew can change it.

The security floor is on every response, not only the HTML ones #165

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/136-the-security-floor-is-on-every-response → main 1 commit fork

  • Merged. mason/136-the-security-floor-is-on-every-response was merged into main as 7c21ca1.

Review

Sign in to review this change.
5 files changed +266 −78
CLAUDE.md +2 −1
@@ -99,7 +99,8 @@ src/
9999 updates reach its origin — and contents.ts is the whole edit
100100 (files, a new branch, a pull) as one call
101101 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
103104 (auth resolution, git smart HTTP, web routes, hook socket), sse
104105 design/ shared design tokens: themes (palettes both surfaces render)
105106 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:
206206 code. Six premade themes ship (three light, three dark); "system" follows
207207 the OS. The switcher is a plain form POST with a cookie — script only makes
208208 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.
210216 - **Mobile-first**: layouts compose from a single column up; touch targets
211217 ≥44px; the fast paths (open issue, comment, review a diff, merge) are
212218 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 {
9595 );
9696 }
9797
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
98141 /**
99142 * Map an action's error to a response: the three access errors have fixed
100143 * shapes; anything else is a validation code the caller chose a status for.
src/http/server.ts +103 −76
@@ -219,7 +219,14 @@ import {
219219 type Ctx,
220220 type Handler,
221221 } 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";
223230 import { RateLimiter, type Verdict } from "../core/ratelimit.ts";
224231 import {
225232 MAX_KEY_LENGTH,
@@ -1096,86 +1103,21 @@ export async function serve(app: App): Promise<Servers> {
10961103 const runtime = appsRuntime(app);
10971104 if (runtime !== undefined) {
10981105 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")
11021110 return proxyApp(runtime, request, target, server);
1111 if (target === "unknown")
1112 return securityFloor(new Response("not found", { status: 404 }));
11031113 }
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));
11751117 },
11761118 error(error) {
11771119 app.log.error("unhandled request error", { error: String(error) });
1178 return json({ error: "internal" }, 500);
1120 return securityFloor(json({ error: "internal" }, 500));
11791121 },
11801122 });
11811123
@@ -1202,3 +1144,88 @@ export async function serve(app: App): Promise<Servers> {
12021144 },
12031145 };
12041146 }
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 {
1313 type IconName,
1414 } from "../src/web/icons.ts";
1515 import { renderMarkdown } from "../src/web/markdown.ts";
16 import { highlightAsset } from "../src/web/asset.ts";
17 import { securityFloor } from "../src/http/respond.ts";
1618 import { THEME_NAMES } from "../src/design/themes.ts";
1719
1820 describe("html template", () => {
@@ -642,3 +644,112 @@ describe("readiness", () => {
642644 expect((await fetch(`${servers.url}/`)).status).toBe(200);
643645 });
644646 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs