diff --git a/AGENTS.md b/AGENTS.md index 52039c9f8f7..c9cd0de2de0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,11 +52,15 @@ with workspace members in `packages/*` and `www/`. ### Lockfile quirks -The lockfile contains remote specifiers pointing to `refs/heads/main` (e.g. -`raw.githubusercontent.com/.../refs/heads/main/...`). These hashes go stale when -upstream pushes. When that happens, manually update the hash in `deno.lock` -since `deno cache --reload` cannot fix it (see -https://github.com/denoland/deno/issues/32991). +The lockfile may contain unpinned remote specifiers whose content can change +(known limitation, see https://github.com/denoland/deno/issues/32991). If +`deno install` fails with an integrity check error, run: + + deno install --lock-write + +This tells Deno to accept the new content and update the lockfile. The +`--reload` flag alone is not sufficient here because it re-fetches content but +still validates against the existing lockfile integrity. ## Architecture diff --git a/packages/fresh/src/middlewares/csp.ts b/packages/fresh/src/middlewares/csp.ts index 0f0b6f558c4..2a37e244b53 100644 --- a/packages/fresh/src/middlewares/csp.ts +++ b/packages/fresh/src/middlewares/csp.ts @@ -19,6 +19,14 @@ export interface CSPOptions { * allow those Fresh-rendered inline elements. */ useNonce?: boolean; + + /** + * If true and `useNonce` is also true, keeps `'unsafe-inline'` + * alongside the nonce in the CSP header instead of removing it. + * This is useful when third-party inline scripts or styles (e.g. + * analytics, reporting widgets) also need to execute on the page. + */ + insecureUnsafeInline?: boolean; } /** @@ -64,6 +72,7 @@ export function csp(options: CSPOptions = {}): Middleware { reportTo, csp = [], useNonce = false, + insecureUnsafeInline = false, } = options; const defaultCsp = [ @@ -124,6 +133,12 @@ export function csp(options: CSPOptions = {}): Middleware { const spaceIdx = d.indexOf(" "); const name = spaceIdx === -1 ? d : d.slice(0, spaceIdx); if (INLINE_DIRECTIVES.has(name) && d.includes("'unsafe-inline'")) { + if (insecureUnsafeInline) { + return d.replaceAll( + "'unsafe-inline'", + `'unsafe-inline' 'nonce-${nonce}'`, + ); + } return d.replaceAll("'unsafe-inline'", `'nonce-${nonce}'`); } return d; diff --git a/packages/fresh/src/middlewares/csp_test.tsx b/packages/fresh/src/middlewares/csp_test.tsx index 3fc50f0fd4c..f9cbf7bc75c 100644 --- a/packages/fresh/src/middlewares/csp_test.tsx +++ b/packages/fresh/src/middlewares/csp_test.tsx @@ -260,3 +260,54 @@ Deno.test("CSP - useNonce replaces unsafe-inline in default-src", async () => { // default-src should have nonce, not unsafe-inline expect(cspHeader).toMatch(/default-src 'self' 'nonce-[a-f0-9]+'/); }); + +Deno.test("CSP - useNonce with insecureUnsafeInline keeps both", async () => { + const app = new App() + .use(csp({ useNonce: true, insecureUnsafeInline: true })) + .get("/", (ctx) => { + return ctx.render( + + + + + +

hello

+ + , + ); + }); + + const server = new FakeServer(app.handler()); + const res = await server.get("/"); + const html = await res.text(); + const cspHeader = res.headers.get("Content-Security-Policy")!; + + // Should contain both unsafe-inline and nonce + expect(cspHeader).toContain("'unsafe-inline'"); + expect(cspHeader).toMatch( + /script-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/, + ); + expect(cspHeader).toMatch( + /style-src 'self' 'unsafe-inline' 'nonce-[a-f0-9]+'/, + ); + + // HTML should still have nonce on the style tag + const nonceMatch = cspHeader.match(/nonce-([a-f0-9]+)/); + expect(nonceMatch).not.toBeNull(); + const nonce = nonceMatch![1]; + expect(html).toContain(`nonce="${nonce}"`); +}); + +Deno.test("CSP - insecureUnsafeInline without useNonce has no effect", async () => { + const handler = new App() + .use(csp({ insecureUnsafeInline: true })) + .get("/", () => new Response("ok")) + .handler(); + + const res = await handler(new Request("https://localhost/")); + const cspHeader = res.headers.get("Content-Security-Policy")!; + + // Without useNonce, insecureUnsafeInline should not change anything + expect(cspHeader).toContain("'unsafe-inline'"); + expect(cspHeader).not.toContain("'nonce-"); +}); diff --git a/packages/plugin-tailwindcss/src/mod.ts b/packages/plugin-tailwindcss/src/mod.ts index 6db172a80b5..54df0a82e5c 100644 --- a/packages/plugin-tailwindcss/src/mod.ts +++ b/packages/plugin-tailwindcss/src/mod.ts @@ -1,5 +1,5 @@ import type { Builder } from "fresh/dev"; -import twPostcss from "@tailwindcss/postcss"; +import * as twPostcss from "@tailwindcss/postcss"; import postcss from "postcss"; import type { TailwindPluginOptions } from "./types.ts"; @@ -11,7 +11,7 @@ export function tailwind( options: TailwindPluginOptions = {}, ): void { const { exclude, ...tailwindOptions } = options; - const instance = postcss(twPostcss({ + const instance = postcss(twPostcss.default({ optimize: builder.config.mode === "production", ...tailwindOptions, })); diff --git a/packages/plugin-vite/src/mod.ts b/packages/plugin-vite/src/mod.ts index c49c244526c..e859c016232 100644 --- a/packages/plugin-vite/src/mod.ts +++ b/packages/plugin-vite/src/mod.ts @@ -6,7 +6,7 @@ import { } from "./utils.ts"; import { deno } from "./plugins/deno.ts"; -import prefresh from "@prefresh/vite"; +import * as prefresh from "@prefresh/vite"; import { serverEntryPlugin } from "./plugins/server_entry.ts"; import { clientEntryPlugin } from "./plugins/client_entry.ts"; import { devServer } from "./plugins/dev_server.ts"; @@ -256,7 +256,7 @@ export function fresh(config?: FreshViteConfig): Plugin[] { ...clientSnapshot(fConfig), buildIdPlugin(), ...devServer(fConfig), - prefresh({ + prefresh.default({ include: [/\.[cm]?[tj]sx?$/], exclude: [/node_modules/, /[\\/]+deno[\\/]+npm[\\/]+/], parserPlugins: [