Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions packages/fresh/src/middlewares/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -64,6 +72,7 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
reportTo,
csp = [],
useNonce = false,
insecureUnsafeInline = false,
} = options;

const defaultCsp = [
Expand Down Expand Up @@ -124,6 +133,12 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
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;
Expand Down
51 changes: 51 additions & 0 deletions packages/fresh/src/middlewares/csp_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<html>
<head>
<style>{"body { color: red; }"}</style>
</head>
<body>
<h1>hello</h1>
</body>
</html>,
);
});

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-");
});
4 changes: 2 additions & 2 deletions packages/plugin-tailwindcss/src/mod.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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,
}));
Expand Down
4 changes: 2 additions & 2 deletions packages/plugin-vite/src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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: [
Expand Down
Loading