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 (
-
-
-
-
+
+
+
+