From 89fd6579b102ea0c5ee0b55c993fb13b69385555 Mon Sep 17 00:00:00 2001 From: cletqui Date: Wed, 2 Sep 2026 13:11:48 +0200 Subject: [PATCH] feat: add a Content-Security-Policy to the page routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New per-route `pageCsp` middleware on `/` and `/template` only — the JSON API and the CDN-backed Swagger UI are left untouched. script-src is 'self' + a per-request nonce; the nonce is threaded into hono's streaming Suspense hydration scripts via StreamingContext so a strict policy doesn't freeze the page on the loader. style-src keeps 'unsafe-inline' for the dynamic language-dot colour (style attributes can't carry a nonce). --- src/index.tsx | 3 +++ src/routes/template.tsx | 2 ++ src/utils/headers.tsx | 29 +++++++++++++++++++++++++++++ src/utils/renderer.tsx | 13 +++++++++---- 4 files changed, 43 insertions(+), 4 deletions(-) create mode 100644 src/utils/headers.tsx diff --git a/src/index.tsx b/src/index.tsx index 058bc8b..386755d 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -2,6 +2,7 @@ import { Context, Hono } from "hono"; import { logger } from "hono/logger"; import { secureHeaders } from "hono/secure-headers"; +import { pageCsp } from "./utils/headers"; import { renderer } from "./utils/renderer"; import { handleTokens } from "./utils/tokens"; import { @@ -29,6 +30,7 @@ export type Variables = { refresh_token?: string; state: string; octokit: Octokit; + cspNonce?: string; }; /* APP */ @@ -43,6 +45,7 @@ app.use( }) ); app.use(renderer); +app.use("/", pageCsp); app.use("/", handleMaxId); app.use("/", handleTokens); diff --git a/src/routes/template.tsx b/src/routes/template.tsx index 22fd2ce..15f19ca 100644 --- a/src/routes/template.tsx +++ b/src/routes/template.tsx @@ -2,6 +2,7 @@ import { Context, Hono } from "hono"; import { Bindings, Variables } from ".."; import { Repository } from "../components/repository"; +import { pageCsp } from "../utils/headers"; import { handleTokens } from "../utils/tokens"; import { getRepository } from "../utils/octokit"; @@ -9,6 +10,7 @@ import { getRepository } from "../utils/octokit"; const app = new Hono<{ Bindings: Bindings; Variables: Variables }>(); /* MIDDLEWARES */ +app.use(pageCsp); app.use(handleTokens); /* ENDPOINTS */ diff --git a/src/utils/headers.tsx b/src/utils/headers.tsx new file mode 100644 index 0000000..d5fe86c --- /dev/null +++ b/src/utils/headers.tsx @@ -0,0 +1,29 @@ +import { createMiddleware } from "hono/factory"; + +const contentSecurityPolicy = (nonce: string): string => + [ + "default-src 'self'", + `script-src 'self' 'nonce-${nonce}'`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: https://avatars.githubusercontent.com https://*.githubusercontent.com", + "connect-src 'self'", + "manifest-src 'self'", + "base-uri 'none'", + "object-src 'none'", + "form-action 'self' https://github.com", + "frame-ancestors 'none'", + ].join("; "); + +/** + * Sets a Content-Security-Policy for the HTML page routes. Applied per-route so it + * does not touch the JSON API or the CDN-backed Swagger UI. A per-request nonce is + * stashed on the context for the streaming renderer's Suspense scripts. + * @function pageCsp + */ +export const pageCsp = createMiddleware(async (c, next) => { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + const nonce = btoa(String.fromCharCode(...bytes)); + c.set("cspNonce", nonce); + c.header("Content-Security-Policy", contentSecurityPolicy(nonce)); + await next(); +}); diff --git a/src/utils/renderer.tsx b/src/utils/renderer.tsx index 8b1a944..ad39142 100644 --- a/src/utils/renderer.tsx +++ b/src/utils/renderer.tsx @@ -2,6 +2,7 @@ import { jsxRenderer } from "hono/jsx-renderer"; import { PropsWithChildren, Suspense } from "hono/jsx"; import { JSX } from "hono/jsx/jsx-runtime"; import { useRequestContext } from "hono/jsx-renderer"; +import { StreamingContext } from "hono/jsx/streaming"; import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; import { fetchRepositoryData } from "./octokit"; @@ -103,11 +104,15 @@ export const renderer = jsxRenderer( children, repository, }: PropsWithChildren<{ repository?: Promise }>): JSX.Element => { + const c = useRequestContext(); + const scriptNonce = c.get("cspNonce"); return ( - - - - + + + + + + ); }, { docType: "", stream: true }