Skip to content

Commit

The site's pages run only their own scripts: a nonce policy, nosniff, a referrer policy and no framing

g1t.sh sent no Content-Security-Policy, X-Content-Type-Options, X-Frame-Options or Referrer-Policy. - Pages: Content-Security-Policy with a fresh nonce per render. Scripts only from the site and the inline ones React and React Router write (ServerRouter, renderToReadableStream, Links, ScrollRestoration and Scripts carry the nonce; app/lib/nonce.ts hands it to the root layout), plus Cloudflare's analytics beacon. Inline styles allowed; images from any HTTPS address, data: and blob:; requests to any HTTPS address (the status summary); no plugins, no frames, base-uri 'self', frame-ancestors 'none'. Off in development, where Vite injects its own. A kept public page keeps the policy it was rendered with. - Every Worker answer: X-Content-Type-Options: nosniff and Referrer-Policy: strict-origin-when-cross-origin unless set; HTML answers X-Frame-Options: DENY (app/lib/page-headers.ts). Static files get the same two from _headers. - Downloads: the ZIP archive, workflow artifacts and log downloads name their file with an ASCII fallback and RFC 6266 filename*, so a ref or name holding quotes or control characters cannot break the header. - Production screenshots: default-src 'none'; sandbox. Checked on a production build (vite preview) in headless Chrome: the landing page, sign-in and sign-up hydrate, every inline script carries the nonce, and no policy violation is reported.

syntaqxcommitted Parentcddd2faBrowse files
13 files+197−590/13 viewed
+22−10
33 import { isbot } from "isbot";
44 import { renderToReadableStream } from "react-dom/server";
55
6+import { NonceContext } from "./lib/nonce";
7+import { makeNonce, pagePolicy } from "./lib/page-headers";
68 import { recordHandler } from "./lib/perf.server";
79
810 // React Router's own server entry, plus the timing of every loader and
4749 let shellRendered = false;
4850 const userAgent = request.headers.get("user-agent");
4951
50− const body = await renderToReadableStream(<ServerRouter context={routerContext} url={request.url} />, {
51− signal: AbortSignal.timeout(streamTimeout + 1000),
52− onError(error: unknown) {
53− responseStatusCode = 500;
54− // Errors while streaming after the shell; those in the shell reject
55− // and are logged by React Router.
56− if (shellRendered) {
57− console.error(error);
58− }
52+ // The page's inline scripts carry this nonce, and its policy allows only
53+ // them (lib/page-headers.ts). Not in development, where Vite adds its own.
54+ const nonce = import.meta.env.DEV ? undefined : makeNonce();
55+ const body = await renderToReadableStream(
56+ <NonceContext value={nonce}>
57+ <ServerRouter context={routerContext} url={request.url} nonce={nonce} />
58+ </NonceContext>,
59+ {
60+ nonce,
61+ signal: AbortSignal.timeout(streamTimeout + 1000),
62+ onError(error: unknown) {
63+ responseStatusCode = 500;
64+ // Errors while streaming after the shell; those in the shell reject
65+ // and are logged by React Router.
66+ if (shellRendered) {
67+ console.error(error);
68+ }
69+ },
5970 },
60− });
71+ );
6172 shellRendered = true;
6273
6374 // Crawlers get the whole page at once, deferred panels included.
6677 }
6778
6879 responseHeaders.set("Content-Type", "text/html");
80+ if (nonce) responseHeaders.set("Content-Security-Policy", pagePolicy(nonce));
6981 return new Response(body, {
7082 headers: responseHeaders,
7183 status: responseStatusCode,
+4−4
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import { attachment, hardenRegistryHeaders, NOTHING_RUNS, opensAsDocument } from "./content-safety.ts";
4+import { contentDisposition, hardenRegistryHeaders, NOTHING_RUNS, opensAsDocument } from "./content-safety.ts";
55
66 test("publisher documents a browser would open are downloads", () => {
77 for (const type of [
5252 });
5353
5454 test("download names keep quotes and control characters out of the header", () => {
55− assert.equal(attachment("web-main.zip"), `attachment; filename="web-main.zip"; filename*=UTF-8''web-main.zip`);
56− const sly = attachment('web-a"b\r\nSet-Cookie: x.zip');
55+ assert.equal(contentDisposition("web-main.zip"), `attachment; filename="web-main.zip"; filename*=UTF-8''web-main.zip`);
56+ const sly = contentDisposition('web-a"b\r\nSet-Cookie: x.zip');
5757 assert.ok(!/[\r\n]/.test(sly));
5858 assert.match(sly, /^attachment; filename="web-a_b__Set-Cookie: x.zip"; filename\*=UTF-8''web-a%22b__Set-Cookie%3A%20x.zip$/);
59− assert.match(attachment("café.zip"), /filename="caf_.zip"; filename\*=UTF-8''caf%C3%A9.zip$/);
59+ assert.match(contentDisposition("café.zip"), /filename="caf_.zip"; filename\*=UTF-8''caf%C3%A9.zip$/);
6060 });
+1−1
5555 * fallback with anything unsafe replaced, and the exact name as
5656 * RFC 6266's `filename*`.
5757 */
58−export function attachment(filename: string): string {
58+export function contentDisposition(filename: string): string {
5959 const fallback = filename.replace(/[^\x20-\x7e]|["\\%;]/g, "_") || "download";
6060 const exact = encodeURIComponent(filename.replace(/[\x00-\x1f\x7f]/g, "_")).replace(/['()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
6161 return `attachment; filename="${fallback}"; filename*=UTF-8''${exact}`;
+3−1
11 /** Answers for the log downloads: a file to save, or why there is none. */
22
3+import { contentDisposition } from "./content-safety";
4+
35 export function refused(status: number, message: string): Response {
46 return new Response(`${message}\n`, { status, headers: { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" } });
57 }
810 return new Response(body, {
911 headers: {
1012 "content-type": type,
11− "content-disposition": `attachment; filename="${file.replace(/["\\\r\n]/g, "")}"`,
13+ "content-disposition": contentDisposition(file),
1214 "cache-control": "no-store",
1315 },
1416 });
+12−0
1+import { createContext, useContext } from "react";
2+
3+/**
4+ * The nonce the page's inline scripts carry, which its Content-Security-Policy
5+ * names (lib/page-headers.ts). Given by entry.server.tsx; in the browser
6+ * there is none, and none is needed: the scripts have already run.
7+ */
8+export const NonceContext = createContext<string | undefined>(undefined);
9+
10+export function useNonce(): string | undefined {
11+ return useContext(NonceContext);
12+}
+38−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { makeNonce, pagePolicy, withSiteHeaders } from "./page-headers.ts";
5+
6+test("a page runs only its own scripts and is never framed", () => {
7+ const nonce = makeNonce();
8+ assert.match(nonce, /^[A-Za-z0-9+/]{22}==$/);
9+ assert.notEqual(nonce, makeNonce());
10+ const policy = pagePolicy(nonce);
11+ assert.ok(policy.includes(`script-src 'self' 'nonce-${nonce}'`));
12+ assert.doesNotMatch(policy, /script-src[^;]*'unsafe-inline'/);
13+ assert.match(policy, /frame-ancestors 'none'/);
14+ assert.match(policy, /object-src 'none'/);
15+ assert.match(policy, /base-uri 'self'/);
16+ // Pictures in a README come from anywhere on HTTPS.
17+ assert.match(policy, /img-src 'self' https: data: blob:/);
18+});
19+
20+test("every answer gains nosniff and a referrer policy; pages are not framed", () => {
21+ const page = withSiteHeaders(new Response("<p>hi</p>", { headers: { "content-type": "text/html" } }));
22+ assert.equal(page.headers.get("x-content-type-options"), "nosniff");
23+ assert.equal(page.headers.get("referrer-policy"), "strict-origin-when-cross-origin");
24+ assert.equal(page.headers.get("x-frame-options"), "DENY");
25+
26+ const data = withSiteHeaders(new Response("{}", { headers: { "content-type": "application/json" } }));
27+ assert.equal(data.headers.get("x-content-type-options"), "nosniff");
28+ assert.equal(data.headers.get("x-frame-options"), null);
29+
30+ // A redirect's headers are fixed; the answer is copied, status and all.
31+ const moved = withSiteHeaders(Response.redirect("https://docs.g1t.sh/", 301));
32+ assert.equal(moved.status, 301);
33+ assert.equal(moved.headers.get("location"), "https://docs.g1t.sh/");
34+ assert.equal(moved.headers.get("referrer-policy"), "strict-origin-when-cross-origin");
35+
36+ const own = withSiteHeaders(new Response("", { headers: { "referrer-policy": "no-referrer" } }));
37+ assert.equal(own.headers.get("referrer-policy"), "no-referrer");
38+});
+54−0
1+/**
2+ * The headers every answer from the site carries, and the policy its pages
3+ * run under. No Workers imports, so it can be tested under Node.
4+ *
5+ * - Nothing is sniffed: a download or a data request is only the type it says.
6+ * - A link to another site sends the origin, never the path.
7+ * - No other site may put g1t's pages in a frame.
8+ * - A page runs only the scripts the site served it: its own files, and the
9+ * inline scripts React and React Router write, each carrying the page's
10+ * nonce. Styles may be inline (highlighting and layout set them); images
11+ * may come from any HTTPS address (pictures in a README); requests may go
12+ * to any HTTPS address (the status page's summary).
13+ */
14+
15+/** A fresh nonce for one page: 128 random bits, base64. */
16+export function makeNonce(): string {
17+ const bytes = crypto.getRandomValues(new Uint8Array(16));
18+ return btoa(String.fromCharCode(...bytes));
19+}
20+
21+/** The Content-Security-Policy of a page rendered with `nonce`. */
22+export function pagePolicy(nonce: string): string {
23+ return [
24+ "default-src 'self'",
25+ // Cloudflare's Web Analytics beacon, when the zone turns it on.
26+ `script-src 'self' 'nonce-${nonce}' https://static.cloudflareinsights.com`,
27+ "style-src 'self' 'unsafe-inline'",
28+ "img-src 'self' https: data: blob:",
29+ "media-src 'self' https:",
30+ "font-src 'self' data:",
31+ "connect-src 'self' https:",
32+ "frame-src 'none'",
33+ "object-src 'none'",
34+ "base-uri 'self'",
35+ "frame-ancestors 'none'",
36+ ].join("; ");
37+}
38+
39+/**
40+ * The answer with the site's headers added. A header the answer already
41+ * has is kept. An upgrade to a WebSocket is passed on as it is.
42+ */
43+export function withSiteHeaders(response: Response): Response {
44+ if (response.status === 101 || (response as Response & { webSocket?: unknown }).webSocket) return response;
45+ // A redirect's headers cannot be changed, so the answer is copied.
46+ const answer = new Response(response.body, response);
47+ const headers = answer.headers;
48+ if (!headers.has("x-content-type-options")) headers.set("x-content-type-options", "nosniff");
49+ if (!headers.has("referrer-policy")) headers.set("referrer-policy", "strict-origin-when-cross-origin");
50+ if (/^text\/html\b/i.test(headers.get("content-type") ?? "") && !headers.has("x-frame-options")) {
51+ headers.set("x-frame-options", "DENY");
52+ }
53+ return answer;
54+}
+5−3
6565 import { addresses } from "./lib/addresses.server";
6666 import { useSignUpCopy } from "./lib/registration";
6767 import { RELOADED_KEY, reloadFixes } from "./lib/stale-build";
68+import { useNonce } from "./lib/nonce";
6869
6970
7071 export const links: Route.LinksFunction = () => [
462463 let lastRoot: Awaited<ReturnType<typeof loader>> | undefined;
463464
464465 export function Layout({ children }: { children: React.ReactNode }) {
466+ const nonce = useNonce();
465467 // Undefined when the root loader itself failed.
466468 const loaded = useRouteLoaderData<typeof loader>("root");
467469 const inBrowser = typeof document !== "undefined";
517519 connection paints sooner (docs/research/css-shipping.md). */}
518520 <link rel="stylesheet" href={appCss} precedence="default" />
519521 <Meta />
520− <Links />
522+ <Links nonce={nonce} />
521523 </head>
522524 <body className="flex min-h-screen flex-col">
523525 {/* The first stop for the keyboard: past the menus, to the page. */}
542544 <SiteFooter user={user} />
543545 </>
544546 )}
545− <ScrollRestoration />
546− <Scripts />
547+ <ScrollRestoration nonce={nonce} />
548+ <Scripts nonce={nonce} />
547549 </body>
548550 </html>
549551 );
+2−1
22
33 import type { Route } from "./+types/actions-artifact";
44 import { addresses } from "../../lib/addresses.server";
5+import { contentDisposition } from "../../lib/content-safety";
56 import { readArtifact } from "../../lib/artifacts.server";
67 import { actions } from "../../lib/services.server";
78 import { getViewer } from "../../lib/session.server";
2526 return new Response(bytes.buffer as ArrayBuffer, {
2627 headers: {
2728 "content-type": "application/gzip",
28− "content-disposition": `attachment; filename="${params.name.replace(/"/g, "")}.tar.gz"`,
29+ "content-disposition": contentDisposition(`${params.name}.tar.gz`),
2930 },
3031 });
3132 }
+3−1
77 import type { Route } from "./+types/archive";
88 import { cloneUrl } from "../../lib/addresses";
99 import { addresses } from "../../lib/addresses.server";
10+import { contentDisposition } from "../../lib/content-safety";
1011 import { repos } from "../../lib/services.server";
1112 import { getViewer } from "../../lib/session.server";
1213 import { zip } from "../../lib/zip";
6162 return new Response(archive, {
6263 headers: {
6364 "content-type": "application/zip",
64− "content-disposition": `attachment; filename="${label}.zip"`,
65+ // A ref may hold quotes; the header never does.
66+ "content-disposition": contentDisposition(`${label}.zip`),
6567 // A commit's files never change; a branch's do.
6668 "cache-control": /^[0-9a-f]{40}$/.test(ref) ? "private, max-age=31536000, immutable" : "private, no-cache",
6769 },
+2−0
4545 "content-type": shot.contentType,
4646 "cache-control": current ? LONG : BRIEF,
4747 "x-content-type-options": "nosniff",
48+ // A picture of someone's app: shown, never run.
49+ "content-security-policy": "default-src 'none'; sandbox",
4850 "last-modified": new Date(shot.capturedAt).toUTCString(),
4951 },
5052 });
+6−0
44 # kept for good. That includes the typefaces.
55 /assets/*
66 Cache-Control: public, max-age=31536000, immutable
7+
8+# Every static file: only the type it says, and a referrer of the origin
9+# alone, as the Worker's own answers (app/lib/page-headers.ts).
10+/*
11+ X-Content-Type-Options: nosniff
12+ Referrer-Policy: strict-origin-when-cross-origin
+45−38
33 import { identityClient, isNamespaceShaped } from "@g1t/contracts";
44
55 import { hardenRegistryHeaders } from "../app/lib/content-safety";
6+import { withSiteHeaders } from "../app/lib/page-headers";
67 import { finishResponse, withRequestPerf } from "../app/lib/perf.server";
78 import { goImport } from "../app/lib/go-get";
89 import { repositoryOfPage, stillPublic } from "../app/lib/public-cache";
3334
3435 export default {
3536 async fetch(request, env, ctx) {
36− const { pathname } = new URL(request.url);
37− // Git over HTTPS shares this hostname but belongs to the repos service.
38− // Its answer goes back to the git client as it is: a repository under a
39− // renamed workspace's old name answers with a 301, which git follows and
40− // must see, so the redirect is never followed here.
41− // The container registry (`docker login g1t.sh`) and the npm registry
42− // (`g1t.sh/-/npm/`) are the packages
43− // service's, handed over the same way.
44− // `go get g1t.sh/<workspace>/<repo>`: where its code is, from the
45− // address alone, so it costs nothing and caches.
46− const go = request.method === "GET" ? goImport(new URL(request.url)) : null;
47− if (go) {
48− return new Response(go, {
49− headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" },
50− });
51− }
52− const service = servicePath(pathname);
53− if (service === "git") {
54− return proxyGit(env, request);
55− }
56− if (service === "packages") {
57− return proxyPackages(env, request);
58− }
59− const avatar = AVATAR_PATH.exec(pathname);
60− if (avatar) {
61− return serveAvatar(env, ctx, request, avatar[1]);
62− }
63− // The documentation is its own site.
64− if (pathname === "/docs" || pathname.startsWith("/docs/")) {
65− const page = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
66− const target = MOVED_DOCS[page] ?? "/";
67− return Response.redirect(DOCS + target, 301);
68− }
69− // Every page and data request says where its time went (Server-Timing)
70− // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts).
71− const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request)));
72− if (anonymousPage(request, pathname)) return servePublic(env, request, ctx, render);
73− return render();
37+ // Every answer: no sniffing, a referrer of the origin alone, and no
38+ // framing of pages (app/lib/page-headers.ts).
39+ return withSiteHeaders(await site(request, env, ctx));
7440 },
7541 } satisfies ExportedHandler<Env>;
7642
43+async function site(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
44+ const { pathname } = new URL(request.url);
45+ // Git over HTTPS shares this hostname but belongs to the repos service.
46+ // Its answer goes back to the git client as it is: a repository under a
47+ // renamed workspace's old name answers with a 301, which git follows and
48+ // must see, so the redirect is never followed here.
49+ // The container registry (`docker login g1t.sh`) and the npm registry
50+ // (`g1t.sh/-/npm/`) are the packages
51+ // service's, handed over the same way.
52+ // `go get g1t.sh/<workspace>/<repo>`: where its code is, from the
53+ // address alone, so it costs nothing and caches.
54+ const go = request.method === "GET" ? goImport(new URL(request.url)) : null;
55+ if (go) {
56+ return new Response(go, {
57+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "public, max-age=3600" },
58+ });
59+ }
60+ const service = servicePath(pathname);
61+ if (service === "git") {
62+ return proxyGit(env, request);
63+ }
64+ if (service === "packages") {
65+ return proxyPackages(env, request);
66+ }
67+ const avatar = AVATAR_PATH.exec(pathname);
68+ if (avatar) {
69+ return serveAvatar(env, ctx, request, avatar[1]);
70+ }
71+ // The documentation is its own site.
72+ if (pathname === "/docs" || pathname.startsWith("/docs/")) {
73+ const page = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
74+ const target = MOVED_DOCS[page] ?? "/";
75+ return Response.redirect(DOCS + target, 301);
76+ }
77+ // Every page and data request says where its time went (Server-Timing)
78+ // and keeps the reader's D1 bookmarks (app/lib/perf.server.ts).
79+ const render = () => withRequestPerf(request, async () => finishResponse(request, await requestHandler(request)));
80+ if (anonymousPage(request, pathname)) return servePublic(env, request, ctx, render);
81+ return render();
82+}
83+
7784 /**
7885 * Public pages as someone signed out sees them: the same for every such
7986 * visitor, so kept in this data centre's cache. Reserved first segments