From 50b6cfddb39c8851b0b764f99329e7ad62df8243 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:05:28 +0800 Subject: [PATCH 01/51] Answer prerender path discovery only inside the build-time runner Deployed Cloudflare Workers and Node, Bun and Deno servers no longer run getPrerenderPaths() for outside requests to POST /__void/prerender-paths. The prerender runner marks its own request with a __VOID_PRERENDER binding (Miniflare) or Hono env argument (Node); unmarked requests are ordinary app requests and go through env validation. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/AGENTS.md | 2 +- packages/void/src/prerender.ts | 22 +++++-------- packages/void/src/router/compile.ts | 12 +++---- packages/void/src/runtime/routing-worker.ts | 14 ++++----- .../integration/typed-routing-runtime.test.ts | 15 ++++++--- packages/void/test/unit/compile.test.ts | 6 +++- packages/void/test/unit/prerender.test.ts | 5 +-- .../void/test/unit/routing-runtime.test.ts | 31 +++++++++++-------- 8 files changed, 58 insertions(+), 49 deletions(-) diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index bef5ae964..0f7f903c9 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -98,7 +98,7 @@ generated entries must preserve the module-boundary rules below. - **Installed runtime module identity**: Keep `void` server runtime subpaths out of Vite dependency optimization. Generated entries import runtime modules by resolved file path; prebundling a bare `void/kv` creates a second `envContext` and loses request bindings. Workspace symlinks can hide this defect. The shared runtime-optimizer plugin excludes the package in server environments on every target while preserving client optimization; packed-consumer checks exercise actual npm/pnpm installations. - **pnpm scaffold build policy**: Keep `create-void` and `void init` aligned with the repository: `better-sqlite3: false` uses version 13's bundled Node-API binaries; `esbuild`, `sharp`, and `workerd` have approved builds. Do not require an unnecessary node-gyp rebuild in fresh applications. -- **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Preserve its existing token check, and keep validation on readiness, application requests, page rendering, and event handlers. +- **Prerender discovery and environment validation**: Only `POST /__void/prerender-paths` bypasses whole-schema boot validation, because path discovery runs without remote production secrets. Discovery answers only when the build-time runner marks the request (the `__VOID_PRERENDER` Miniflare binding on Cloudflare, the Hono env argument on Node/Bun/Deno), never on a deployed app or dev server. Keep validation on readiness, application requests, page rendering, and event handlers. - **Pre-resolved paths in codegen**: Generated virtual module code uses absolute paths for `hono` and `void/response` (resolved via `import.meta.resolve()`). Virtual modules have no filesystem location, so bare specifiers can't resolve from them. - **CF plugin API**: Worker config (`main`, `d1_databases`, `kv_namespaces`, `r2_buckets`) goes inside the `config` option of `cloudflare()`, NOT as top-level properties. `persistState` takes `{ path: string }`, not a raw string. The `config` option uses a **function form** `(resolved: WorkerConfig) => Partial` so that bindings from the user's `wrangler.json` (if present) take precedence over inferred placeholder bindings. - **Direct deploy package boundary**: target-neutral intents, artifacts, resources, migrations, and Durable Object plans live in public `@void/deploy-core`; root Wrangler config parsing and verified Cloudflare upload primitives live in public `@void/deploy-cloudflare`. The CLI consumes both packages directly. Hosted platform packages consume `@void/deploy-core` and must not be imported by public SDK packages. diff --git a/packages/void/src/prerender.ts b/packages/void/src/prerender.ts index 54e861932..fd8f1a22e 100644 --- a/packages/void/src/prerender.ts +++ b/packages/void/src/prerender.ts @@ -270,7 +270,9 @@ async function createCloudflarePrerenderRunner(options: { modulesRoot: workerDir, compatibilityDate: wranglerCompat.compatibilityDate, compatibilityFlags: wranglerCompat.compatibilityFlags ?? [], - bindings, + // Marks this Worker as the build-time prerender runner, the only place the + // built app answers path discovery. Deployed Workers never carry it. + bindings: { ...bindings, __VOID_PRERENDER: '1' }, }; if (clientDir) { mfOptions.serviceBindings = { @@ -328,20 +330,11 @@ async function createCloudflarePrerenderRunner(options: { } const mf = new Miniflare(convertV4MiniflareOptions(mfOptions)); - const allBindings = mfOptions.bindings as Record | undefined; - const proxyToken = allBindings?.__VOID_PROXY_TOKEN as string | undefined; return { async collectPaths(): Promise> { - const headers: Record = {}; - if (proxyToken) { - headers['x-void-internal'] = proxyToken; - } return readPrerenderPathsResponse( - await mf.dispatchFetch(`http://localhost${PRERENDER_PATHS_ENDPOINT}`, { - method: 'POST', - headers, - }), + await mf.dispatchFetch(`http://localhost${PRERENDER_PATHS_ENDPOINT}`, { method: 'POST' }), ); }, async fetchPath(path: string): Promise { @@ -440,11 +433,12 @@ if (!fetch) { process.exit(1); } +// The second argument is the Hono env. Its marker is what lets the built app +// answer path discovery; HTTP servers never pass it. async function collectPaths() { - const headers = {}; - if (process.env.__VOID_PROXY_TOKEN) headers["x-void-internal"] = process.env.__VOID_PROXY_TOKEN; const response = await fetch( - new Request("http://localhost${PRERENDER_PATHS_ENDPOINT}", { method: "POST", headers }), + new Request("http://localhost${PRERENDER_PATHS_ENDPOINT}", { method: "POST" }), + { __VOID_PRERENDER: "1" }, ); if (!response.ok) { throw new Error("prerender: Failed to collect prerender paths. HTTP " + response.status + "."); diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index 26e7f384e..d4cd999ef 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -1521,8 +1521,12 @@ export function compileNodeApp( ? `import ${JSON.stringify(envSchemaModule.importPath)};\nimport { _validateEnvOnce } from ${JSON.stringify(envSchemaModule.helperPath)};\n` : ''; const envSource = target === 'deno' ? 'Deno.env.toObject()' : 'process.env'; + // Path discovery answers only the build-time prerender runner, which passes + // `{ __VOID_PRERENDER: "1" }` as the Hono env. HTTP servers never do: Node + // passes `{ incoming, outgoing }`, Bun its Server and Deno its handler info. + const discoveryRequest = `c.req.method === "POST" && c.req.path === "/__void/prerender-paths" && c.env?.__VOID_PRERENDER === "1"`; const envMiddleware = envSchemaModule - ? `app.use("*", async (c, next) => { if (c.req.method !== "POST" || c.req.path !== "/__void/prerender-paths") await _validateEnvOnce(${envSource}); return next(); });\n` + ? `app.use("*", async (c, next) => { if (!(${discoveryRequest})) await _validateEnvOnce(${envSource}); return next(); });\n` : ''; return `${staticImport} @@ -1537,11 +1541,7 @@ assetApp.get("*", serveStatic({ root: clientRoot })); const assets = { fetch: (request) => assetApp.fetch(request) }; const app = new Hono(); ${envMiddleware}app.post("/__void/prerender-paths", async (c) => { - const __expected = c.env?.__VOID_PROXY_TOKEN; - if (__expected) { - const __token = c.req.header("x-void-internal"); - if (!__token || __token !== __expected) return c.json({ error: "unauthorized" }, 401); - } + if (c.env?.__VOID_PRERENDER !== "1") return c.notFound(); const paths = await __voidCollectPrerenderPaths(c.req.raw); return c.json({ paths }); }); diff --git a/packages/void/src/runtime/routing-worker.ts b/packages/void/src/runtime/routing-worker.ts index ec5abb030..ad8f7975f 100644 --- a/packages/void/src/runtime/routing-worker.ts +++ b/packages/void/src/runtime/routing-worker.ts @@ -205,7 +205,12 @@ export function createWorkerFetch( ): (request: Request, env: RuntimeEnvironment, ctx: ExecutionContext) => Promise { return async (request, originalEnv, ctx) => { const url = new URL(request.url); - const discovery = request.method === 'POST' && url.pathname === '/__void/prerender-paths'; + // Only the build-time prerender runner sets this binding. Without it the + // request is an ordinary app request, so env validation runs as usual. + const discovery = + request.method === 'POST' && + url.pathname === '/__void/prerender-paths' && + originalEnv.__VOID_PRERENDER === '1'; if (options.validateEnv && !discovery) { await options.validateEnv(originalEnv); } @@ -238,13 +243,6 @@ export function createWorkerFetch( return internal; } if (discovery) { - const expected = env.__VOID_PROXY_TOKEN; - if (expected) { - const token = request.headers.get('x-void-internal'); - if (!token || token !== expected) { - return Response.json({ error: 'unauthorized' }, { status: 401 }); - } - } return Response.json({ paths: await options.collectPrerenderPaths(request, env, ctx) }); } const userEnv = options.filterBindings(env); diff --git a/packages/void/test/integration/typed-routing-runtime.test.ts b/packages/void/test/integration/typed-routing-runtime.test.ts index 4bb3d1b3d..7df10e30b 100644 --- a/packages/void/test/integration/typed-routing-runtime.test.ts +++ b/packages/void/test/integration/typed-routing-runtime.test.ts @@ -244,10 +244,17 @@ renderPage.preambleScript = ${JSON.stringify(preambleScript)};`; expect(await (await runtime.dispatchFetch('https://app.test/__void/ready')).json()).toEqual({ ready: true, }); - const unauthorized = await runtime.dispatchFetch('https://app.test/__void/prerender-paths', { - method: 'POST', - }); - expect(unauthorized.status).toBe(401); + // Path discovery answers only the build-time prerender runner, so the + // deployed Worker treats it as an ordinary request, even with the proxy + // token. This fixture has no binding handler, so the pages fallback answers. + for (const headers of [{}, { 'x-void-internal': 'secret' }]) { + const discovery = await runtime.dispatchFetch('https://app.test/__void/prerender-paths', { + method: 'POST', + headers, + }); + expect(discovery.status).toBe(404); + expect(await discovery.text()).not.toContain('"paths"'); + } }, ); }); diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index baf360da7..80a17825a 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -1979,6 +1979,10 @@ describe('compileNodeApp', () => { 'import innerApp, { __voidCollectPrerenderPaths } from "virtual:void-routes"', ); expect(code).toContain('app.post("/__void/prerender-paths"'); + // Only the prerender runner passes the marker as the Hono env. + expect(code).toContain('if (c.env?.__VOID_PRERENDER !== "1") return c.notFound();'); + expect(code).not.toContain('__VOID_PROXY_TOKEN'); + expect(code).not.toContain('x-void-internal'); }); it('generates bun app with hono/bun serveStatic', () => { @@ -2014,7 +2018,7 @@ describe('compileNodeApp', () => { expect(code).toContain('app.use("*", async (c, next)'); expect(code).toContain('await _validateEnvOnce(process.env)'); expect(code).toContain( - 'if (c.req.method !== "POST" || c.req.path !== "/__void/prerender-paths")', + 'if (!(c.req.method === "POST" && c.req.path === "/__void/prerender-paths" && c.env?.__VOID_PRERENDER === "1")) await _validateEnvOnce(process.env)', ); }); diff --git a/packages/void/test/unit/prerender.test.ts b/packages/void/test/unit/prerender.test.ts index 382fdb4e6..de8554a05 100644 --- a/packages/void/test/unit/prerender.test.ts +++ b/packages/void/test/unit/prerender.test.ts @@ -130,7 +130,8 @@ const sensitive = [ 'ACTIONS_ID_TOKEN_REQUEST_TOKEN', ]; export default { - fetch() { + fetch(request, env) { + if (env?.__VOID_PRERENDER !== '1') return new Response('Not Found', { status: 404 }); const paths = process.env.PUBLIC_VALUE === 'visible' ? ['/safe'] : ['/safe-missing']; if (sensitive.some((name) => process.env[name])) paths.push('/credential-leaked'); return Response.json({ paths }); @@ -209,7 +210,7 @@ describe('collectPrerenderPaths (Cloudflare target)', () => { export default { async fetch(request, env) { const url = new URL(request.url); - if (url.pathname !== '/__void/prerender-paths') { + if (url.pathname !== '/__void/prerender-paths' || env.__VOID_PRERENDER !== '1') { return new Response('Not Found', { status: 404 }); } const { results } = await env.DB.prepare('SELECT path FROM pages ORDER BY path').all(); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index a7364cf5a..20dd53110 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -171,9 +171,10 @@ describe('typed Worker fetch execution', () => { expect(await missing.json()).toEqual({ ready: true }); expect(missing.headers.get('cache-control')).toBe('no-store'); }); - it('validates readiness and app requests while discovery skips validation and checks its token', async () => { + it('validates readiness and app requests and answers discovery only for the prerender runner', async () => { const validateEnv = vi.fn(async () => {}); - const fetch = worker({ validateEnv }); + const collectPrerenderPaths = vi.fn(async () => ['/page']); + const fetch = worker({ validateEnv, collectPrerenderPaths }); const env = { PUBLIC: 'yes', __VOID_PROXY_TOKEN: 'secret' }; expect( await (await fetch(new Request('https://app.test/__void/ready'), env, context)).json(), @@ -182,18 +183,22 @@ describe('typed Worker fetch execution', () => { public: 'yes', keys: ['PUBLIC'], }); - const url = 'https://app.test/__void/prerender-paths'; - expect((await fetch(new Request(url, { method: 'POST' }), env, context)).status).toBe(401); - expect( - await ( - await fetch( - new Request(url, { method: 'POST', headers: { 'x-void-internal': 'secret' } }), - env, - context, - ) - ).json(), - ).toEqual({ paths: ['/page'] }); expect(validateEnv).toHaveBeenCalledTimes(2); + const url = 'https://app.test/__void/prerender-paths'; + for (const headers of [{}, { 'x-void-internal': 'secret' }] as Array>) { + const response = await fetch(new Request(url, { method: 'POST', headers }), env, context); + expect(response.status).toBe(404); + expect(await response.text()).not.toContain('/page'); + } + expect(collectPrerenderPaths).not.toHaveBeenCalled(); + expect(validateEnv).toHaveBeenCalledTimes(4); + const runner = await fetch( + new Request(url, { method: 'POST' }), + { PUBLIC: 'yes', __VOID_PRERENDER: '1' }, + context, + ); + expect(await runner.json()).toEqual({ paths: ['/page'] }); + expect(validateEnv).toHaveBeenCalledTimes(4); }); it('resolves remote env after validating original bindings and enters scopes before internal routes', async () => { const original = { __VOID_REMOTE: '1' }; From 73b0dc12ae92fa4a582ffdc47747ef668aed047f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:05:51 +0800 Subject: [PATCH 02/51] Resolve named page actions from own function properties only A query such as ?constructor, ?valueOf or ?__proto__ no longer picks an Object.prototype member as the page action; it gets the usual "Unknown action" 404. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/runtime/routing-pages.ts | 11 ++++++++++- packages/void/test/unit/routing-runtime.test.ts | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/void/src/runtime/routing-pages.ts b/packages/void/src/runtime/routing-pages.ts index 020686854..99833f841 100644 --- a/packages/void/src/runtime/routing-pages.ts +++ b/packages/void/src/runtime/routing-pages.ts @@ -45,7 +45,16 @@ export function createPageMutationHandler( const actionName = options.namedActions ? new URL(context.req.url).searchParams.keys().next().value : undefined; - const handler = options.namedActions ? mod.actions?.[actionName || 'default'] : mod.action; + // The name comes from the query string, so only own function properties + // count; `?constructor` or `?valueOf` must not reach Object.prototype. + const name = actionName || 'default'; + const named = mod.actions; + const candidate = named && Object.hasOwn(named, name) ? named[name] : undefined; + const handler = options.namedActions + ? typeof candidate === 'function' + ? candidate + : undefined + : mod.action; if (options.namedActions && !handler) { return new Response('Unknown action' + (actionName ? ': ' + actionName : ''), { status: 404, diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 20dd53110..b4161a452 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -436,6 +436,10 @@ describe('typed Pages and SSR execution', () => { ['/named', 'default', 200], ['/named?save', 'save', 200], ['/named?missing', 'Unknown action: missing', 404], + ['/named?constructor', 'Unknown action: constructor', 404], + ['/named?__proto__', 'Unknown action: __proto__', 404], + ['/named?valueOf', 'Unknown action: valueOf', 404], + ['/named?toString', 'Unknown action: toString', 404], ['/single', 'single', 200], ] as const) { const response = await app.fetch(new Request('https://app.test' + path, { method: 'POST' })); From d2278f446c8fa586cd8d3a80262c901931b95e86 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:08:12 +0800 Subject: [PATCH 03/51] Keep catch-all redirect destinations on the same site A rule such as '/en/*' -> '/:splat' no longer turns /en//evil.com into a protocol-relative Location header. When a path destination's capture starts with a slash or backslash, the result is collapsed to a single leading slash. Destinations written as '//host/...' or 'https://...' are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/edge/src/pattern.ts | 7 ++++++- packages/edge/test/unit/runtime.test.ts | 11 +++++++++++ packages/void/test/unit/routing-runtime.test.ts | 9 +++++++++ .../packages/dispatch/test/unit/pattern.test.ts | 15 +++++++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/edge/src/pattern.ts b/packages/edge/src/pattern.ts index a560230f6..1060fd48e 100644 --- a/packages/edge/src/pattern.ts +++ b/packages/edge/src/pattern.ts @@ -237,7 +237,12 @@ export function matchSegmentPattern(pattern: string, pathname: string): boolean * because `compilePattern` captures anonymous `*` as `splat`. */ export function substituteParams(destination: string, params: Record): string { - return destination.replace(/:([A-Za-z0-9_]+)\*?/g, (whole, name: string) => { + const result = destination.replace(/:([A-Za-z0-9_]+)\*?/g, (whole, name: string) => { return params[name] ?? whole; }); + // A capture can start with '/', so a path destination must stay on this site. + if (/^[/\\]{2}/.test(result) && !/^[/\\]{2}/.test(destination)) { + return '/' + result.replace(/^[/\\]+/, ''); + } + return result; } diff --git a/packages/edge/test/unit/runtime.test.ts b/packages/edge/test/unit/runtime.test.ts index 886ab3fec..b3f167ef0 100644 --- a/packages/edge/test/unit/runtime.test.ts +++ b/packages/edge/test/unit/runtime.test.ts @@ -54,6 +54,17 @@ describe('createStaticEdgeHandler', () => { expect(binding.fetch).not.toHaveBeenCalled(); }); + it('keeps catch-all redirects on the same site when the capture starts with a slash', async () => { + const binding = assets({}); + const response = await createStaticEdgeHandler({ + redirectRules: [{ source: '/en/*', destination: '/:splat', status: 301 }], + }).fetch(new Request('https://example.com/en//evil.com'), { ASSETS: binding }); + + expect(response.status).toBe(301); + expect(response.headers.get('location')).toBe('/evil.com'); + expect(binding.fetch).not.toHaveBeenCalled(); + }); + it('rewrites before assets and exposes the original URL', async () => { const binding = assets({ '/new': new Response('rewritten') }); const response = await createStaticEdgeHandler({ diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index b4161a452..018f0c224 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -94,6 +94,15 @@ describe('typed HTTP execution', () => { 'redirect[/old/:id] -> /new/42?set=1 301 (config)', ); }); + it('keeps catch-all redirects on the same site when the capture starts with a slash', async () => { + const app = new Hono(); + const runtime = createRoutingRuntime(app, { nodeTarget: true }); + app.use(runtime.contextMiddleware); + app.use(runtime.routingMiddleware([{ source: '/en/*', destination: '/:splat', status: 301 }])); + const response = await app.fetch(new Request('https://app.test/en//evil.com')); + expect(response.status).toBe(301); + expect(response.headers.get('location')).toBe('/evil.com'); + }); it.each([true, false])('gates dynamic rewrite tracing on dev=%s', async (dev) => { const app = new Hono(); const runtime = createRoutingRuntime(app, { nodeTarget: true, dev }); diff --git a/platform/packages/dispatch/test/unit/pattern.test.ts b/platform/packages/dispatch/test/unit/pattern.test.ts index 89f162e59..9bd7533a8 100644 --- a/platform/packages/dispatch/test/unit/pattern.test.ts +++ b/platform/packages/dispatch/test/unit/pattern.test.ts @@ -371,4 +371,19 @@ describe('substituteParams', () => { it('ignores extra params not referenced by destination', () => { expect(substituteParams('/articles/:slug', { slug: 'hi', extra: 'x' })).toBe('/articles/hi'); }); + + it('keeps a path destination on the same site when a capture starts with a slash', () => { + expect(substituteParams('/:splat', { splat: '/evil.com' })).toBe('/evil.com'); + expect(substituteParams('/:splat', { splat: '\\evil.com' })).toBe('/evil.com'); + expect(substituteParams(':splat', { splat: '//evil.com' })).toBe('/evil.com'); + expect(substituteParams('/:path*', { path: '//evil.com/x' })).toBe('/evil.com/x'); + }); + + it('leaves protocol-relative, absolute and prefixed destinations unchanged', () => { + expect(substituteParams('//cdn.example.com/:splat', { splat: 'a' })).toBe( + '//cdn.example.com/a', + ); + expect(substituteParams('https://x.com/:splat', { splat: '/a' })).toBe('https://x.com//a'); + expect(substituteParams('/posts/:splat', { splat: '/x' })).toBe('/posts//x'); + }); }); From 5970251718c9127b27a8c950c2699980efecfaab Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:10:11 +0800 Subject: [PATCH 04/51] Send a generic message for unexpected deferred prop errors in production A rejected deferred prop no longer streams its raw error message (SQL, hostnames, URLs) to the browser in production builds. The error is logged on the server and the page receives "Internal Server Error", matching the page error policy. HTTPException messages still pass through, and the dev server keeps showing the raw message. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/pages-routing/loaders.md | 4 + packages/void/src/pages/protocol.ts | 29 ++++- packages/void/src/router/compile.ts | 2 +- packages/void/test/unit/pages-compile.test.ts | 6 + .../void/test/unit/pages-protocol.test.ts | 122 +++++++++++++++--- 5 files changed, 144 insertions(+), 19 deletions(-) diff --git a/docs/guide/pages-routing/loaders.md b/docs/guide/pages-routing/loaders.md index b9699af81..0509c2524 100644 --- a/docs/guide/pages-routing/loaders.md +++ b/docs/guide/pages-routing/loaders.md @@ -157,6 +157,10 @@ around the Suspense boundary when the page should render a custom failure state. For explicit React prop annotations, import `Deferred` from `@void/react`; the adapter export is typed as `Promise`. +In production, an unexpected error in a deferred prop reaches the page as +`Internal Server Error`; throw an +[`HTTPException`](../../integrations/hono.md#utilities) to show your own message. + In Vue, Svelte, and Solid, `Deferred` behaves as a [discriminated union](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#discriminated-unions) with three states: ```ts diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 69622f13f..ef3583090 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -63,6 +63,8 @@ type PageHeadHandler = | undefined; type PageRenderOptions = { renderMode?: PageRenderMode; + /** Dev server build: rejected deferred props keep their raw error message. */ + dev?: boolean; }; type AssetTags = { css: string; @@ -341,12 +343,33 @@ function renderedResponse( }); } +/** + * Message sent to the browser for a rejected deferred prop. HTTPException + * messages are deliberate and pass through, as in the page error handler. + * Anything else is logged and, outside dev, replaced with a generic message so + * internal details (SQL, hostnames, URLs) do not reach the page. + */ +function deferredErrorMessage(err: unknown, dev: boolean): string { + const message = err instanceof Error ? err.message : String(err); + if ( + err && + typeof err === 'object' && + 'getResponse' in err && + typeof err.getResponse === 'function' + ) { + return message; + } + console.error(err); + return dev ? message : 'Internal Server Error'; +} + /** Build a streaming response that sends an initial chunk then streams deferred results. */ function streamDeferredResponse( initialChunk: PageRenderResult, deferredMap: Record, c: CloudContext, contentType: string, + dev: boolean, formatResolved: (group: string, data: unknown) => string, formatRejected: (group: string, error: string) => string, assetTags?: AssetTags, @@ -359,9 +382,7 @@ function streamDeferredResponse( const pendingChunks = entries.map(([group, { resolver }]) => resolver(c) .then((data) => formatResolved(group, data)) - .catch((err: unknown) => - formatRejected(group, err instanceof Error ? err.message : String(err)), - ), + .catch((err: unknown) => formatRejected(group, deferredErrorMessage(err, dev))), ); (async () => { @@ -486,6 +507,7 @@ export async function handlePageGet( deferredMap, c, 'application/x-ndjson', + options?.dev === true, (group, data) => JSON.stringify({ __deferred: group, data }) + '\n', (group, error) => JSON.stringify({ __deferred: group, error }) + '\n', ); @@ -510,6 +532,7 @@ export async function handlePageGet( deferredMap, c, 'text/html; charset=utf-8', + options?.dev === true, (group, data) => `\n`, (group, error) => diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index d4cd999ef..68e7af8d0 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -1122,7 +1122,7 @@ function compilePageRoutes( page.renderMode === 'island' && pages.islandClientEntryId ? 'getIslandsAssetTags' : 'getPagesAssetTags'; - const routeOptions = `{ ${serverImportPath ? 'load: _pload_' + safeId + ', ' : ''}${mdHeadRef ? 'head: ' + mdHeadRef + ', ' : ''}componentId: ${JSON.stringify(page.componentId)}, render: ${renderer}, assets: ${assetTags}, headConfig: __voidHeadConfig, renderOptions: ${JSON.stringify({ renderMode: page.renderMode })} }`; + const routeOptions = `{ ${serverImportPath ? 'load: _pload_' + safeId + ', ' : ''}${mdHeadRef ? 'head: ' + mdHeadRef + ', ' : ''}componentId: ${JSON.stringify(page.componentId)}, render: ${renderer}, assets: ${assetTags}, headConfig: __voidHeadConfig, renderOptions: ${JSON.stringify({ renderMode: page.renderMode, ...(options.dev === true ? { dev: true } : {}) })} }`; lines.push( `app.get(${JSON.stringify(page.pattern)}, __createPageGetHandler(${routeOptions}));`, ); diff --git a/packages/void/test/unit/pages-compile.test.ts b/packages/void/test/unit/pages-compile.test.ts index afc1e76af..0ffc6091d 100644 --- a/packages/void/test/unit/pages-compile.test.ts +++ b/packages/void/test/unit/pages-compile.test.ts @@ -86,6 +86,12 @@ describe('Pages metadata wiring', () => { expect(code).toContain('namedActions: true'); expect(code).not.toContain('handlePageMutation(handler'); }); + it('marks page render options as dev only in dev builds', () => { + expect(compile()).toContain('renderOptions: {"renderMode":"server","dev":true} }'); + const prod = compile({ ...testOptions, dev: false }); + expect(prod).toContain('renderOptions: {"renderMode":"server"} }'); + expect(prod).not.toContain('"renderMode":"server","dev"'); + }); it('passes dev CSS metadata and a shared manifest loader to the runtime', () => { const code = compile(); expect(code).toContain('getVoidPagesDevCss'); diff --git a/packages/void/test/unit/pages-protocol.test.ts b/packages/void/test/unit/pages-protocol.test.ts index 4706c4849..2129e378c 100644 --- a/packages/void/test/unit/pages-protocol.test.ts +++ b/packages/void/test/unit/pages-protocol.test.ts @@ -1,4 +1,5 @@ import { Hono, type Context } from 'hono'; +import { HTTPException } from 'hono/http-exception'; import { describe, expect, it, vi } from 'vite-plus/test'; import { defer } from '../../src/pages/defer.ts'; import { @@ -932,7 +933,8 @@ describe('handlePageGet with deferred props', () => { expect(deferredLine.data).toEqual({ requests: 100 }); }); - it('SPA — streams error for failed deferred resolver', async () => { + it('SPA — dev streams the raw error for a failed deferred resolver', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); const c = await createTestContext('GET', '/dashboard', { headers: { 'x-voidpages': 'true' }, }); @@ -942,22 +944,112 @@ describe('handlePageGet with deferred props', () => { usage: defer(() => Promise.reject(new Error('timeout'))), })); - const response = await handlePageGet( - handler, - undefined, - c, - 'dashboard', - mockRenderFn, - mockGetAssetTags, - ); + try { + const response = await handlePageGet( + handler, + undefined, + c, + 'dashboard', + mockRenderFn, + mockGetAssetTags, + undefined, + { dev: true }, + ); - const text = await response.text(); - const lines = text.trim().split('\n'); - expect(lines.length).toBe(2); + const text = await response.text(); + const lines = text.trim().split('\n'); + expect(lines.length).toBe(2); - const deferredLine = JSON.parse(lines[1]); - expect(deferredLine.__deferred).toBe('usage'); - expect(deferredLine.error).toBe('timeout'); + const deferredLine = JSON.parse(lines[1]); + expect(deferredLine.__deferred).toBe('usage'); + expect(deferredLine.error).toBe('timeout'); + } finally { + consoleError.mockRestore(); + } + }); + + it('SPA — production streams a generic error and logs the original', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const c = await createTestContext('GET', '/dashboard', { + headers: { 'x-voidpages': 'true' }, + }); + const error = new Error('db 10.0.0.5 refused'); + + try { + const response = await handlePageGet( + () => ({ usage: defer(() => Promise.reject(error)) }), + undefined, + c, + 'dashboard', + mockRenderFn, + mockGetAssetTags, + ); + + const text = await response.text(); + expect(text).not.toContain('10.0.0.5'); + expect(JSON.parse(text.trim().split('\n')[1])).toEqual({ + __deferred: 'usage', + error: 'Internal Server Error', + }); + expect(consoleError).toHaveBeenCalledWith(error); + } finally { + consoleError.mockRestore(); + } + }); + + it('SSR — production rejects deferred props with a generic error', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const c = await createTestContext('GET', '/dashboard'); + const error = new Error('db 10.0.0.5 refused'); + + try { + const response = await handlePageGet( + () => ({ usage: defer(() => Promise.reject(error)) }), + undefined, + c, + 'dashboard', + mockRenderFn, + mockGetAssetTags, + ); + + const text = await response.text(); + expect(text).not.toContain('10.0.0.5'); + expect(text).toContain('window.__rejectDeferred("usage","Internal Server Error")'); + expect(consoleError).toHaveBeenCalledWith(error); + } finally { + consoleError.mockRestore(); + } + }); + + it('SPA — production keeps HTTPException messages for deferred props', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const c = await createTestContext('GET', '/dashboard', { + headers: { 'x-voidpages': 'true' }, + }); + + try { + const response = await handlePageGet( + () => ({ + usage: defer(() => + Promise.reject(new HTTPException(403, { message: 'Upgrade required' })), + ), + }), + undefined, + c, + 'dashboard', + mockRenderFn, + mockGetAssetTags, + ); + + const text = await response.text(); + expect(JSON.parse(text.trim().split('\n')[1])).toEqual({ + __deferred: 'usage', + error: 'Upgrade required', + }); + expect(consoleError).not.toHaveBeenCalled(); + } finally { + consoleError.mockRestore(); + } }); it('SPA — no deferred props returns normal JSON (not streaming)', async () => { From 99db21251de80489eb7f5be707b1710ad0ddcd76 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:15:17 +0800 Subject: [PATCH 05/51] Ignore the session cookie on cross-origin unsafe requests and WebSocket upgrades Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/auth.md | 2 + docs/guide/websockets.md | 2 + .../void/src/runtime/better-auth-shared.ts | 90 ++++++- .../void/test/unit/auth-middleware.test.ts | 234 +++++++++++++++++- 4 files changed, 325 insertions(+), 3 deletions(-) diff --git a/docs/guide/auth.md b/docs/guide/auth.md index 77ada7a1c..ecd8fb71e 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -127,6 +127,8 @@ Auth sessions use the same database as your app. Void creates `BETTER_AUTH_SECRE Local development needs no secret setup. To preview a production build locally, provide `BETTER_AUTH_SECRET`. +`POST`, `PUT`, `PATCH` and `DELETE` requests and WebSocket connections from another origin don't use the signed-in session unless that origin is in `trustedOrigins`. If a separate frontend calls your app, add its origin there (see [Customization](#customization)). If your routes use their own cookies, check the request `Origin` yourself, for example with Hono's [`csrf()`](https://hono.dev/docs/middleware/builtin/csrf) middleware. + ## Customization For advanced configuration, create `auth.ts` at the project root and export `defineAuth(...)`: diff --git a/docs/guide/websockets.md b/docs/guide/websockets.md index 4f4cfd294..8c1c19713 100644 --- a/docs/guide/websockets.md +++ b/docs/guide/websockets.md @@ -151,6 +151,8 @@ Messages are JSON events. Raw string and binary messages are not supported. When Void auth is enabled, every WebSocket hook receives the current user as `ctx.user`, or `null` for an anonymous connection. Use `onBeforeConnect` to reject unauthenticated clients. +Connections from another origin are anonymous unless that origin is in your auth `trustedOrigins`. If you read your own cookies from `ctx.request`, check its `Origin` header yourself. + ## Hooks Both `defineRoom()` and `defineWebSocket()` support: diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index 973ecae8d..8c10a7408 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -276,6 +276,92 @@ function resolveUserConfig( return mergeEmailAndPasswordDefaults({ ...defaults, ...userConfig }); } +const SESSION_SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); + +// An http page never counts for an https request. An https page counts for an +// http request on the same host: Vite HTTPS dev and preview hand WebSocket +// upgrades to the Worker with an http:// URL. +function isRequestOrigin(origin: string, requestUrl: string): boolean { + try { + const from = new URL(origin); + const to = new URL(requestUrl); + return from.host === to.host && (from.protocol === to.protocol || from.protocol === 'https:'); + } catch { + return false; + } +} + +/** + * Headers used to look up the signed-in session for app routes and WebSockets. + * + * The session cookie is SameSite=Lax, so a page on another origin of the same + * site (for example a sibling subdomain) can still send it with a WebSocket + * handshake or a POST that needs no CORS preflight. For those requests the + * cookie only counts when the Origin is the request origin or one Better Auth + * trusts, following Better Auth's own origin check for its endpoints. The + * request is not rejected; it is resolved without a user. + */ +async function sessionHeaders( + request: Request, + auth: ReturnType, +): Promise { + const headers = request.headers; + if (!headers.has('cookie')) { + return headers; + } + if ( + SESSION_SAFE_METHODS.has(request.method) && + headers.get('upgrade')?.toLowerCase() !== 'websocket' + ) { + return headers; + } + const origin = headers.get('origin'); + if (!origin || isRequestOrigin(origin, request.url)) { + return headers; + } + if (origin === 'null' && headers.get('sec-fetch-site') === 'same-origin') { + return headers; + } + + // Better Auth skips its origin check when CSRF checks are off, and treats + // `disableOriginCheck: true` the same way while `disableCSRFCheck` is unset. + const advanced = auth.options.advanced; + if ( + advanced?.disableCSRFCheck === true || + (advanced?.disableOriginCheck === true && advanced.disableCSRFCheck === undefined) + ) { + return headers; + } + + const context = await auth.$context; + if (context.isTrustedOrigin(origin, { allowRelativePaths: false })) { + return headers; + } + // The function form is evaluated per request by Better Auth, while the + // context list was built once without a request. `isTrustedOrigin` matches + // against `this.trustedOrigins`, so reuse it to keep wildcard patterns. + const trustedOrigins = auth.options.trustedOrigins; + if (typeof trustedOrigins === 'function') { + const requestOrigins = (await trustedOrigins(request)).filter((value): value is string => + Boolean(value), + ); + if ( + context.isTrustedOrigin.call({ trustedOrigins: requestOrigins }, origin, { + allowRelativePaths: false, + }) + ) { + return headers; + } + } + + const withoutCookie = new Headers(headers); + withoutCookie.delete('cookie'); + console.warn( + `auth: Ignored the session cookie on a ${request.method} request from ${origin}. Add this origin to auth trustedOrigins if it should use the signed-in session.`, + ); + return withoutCookie; +} + export function assertRuntimeDialect( runtime: VoidAuthRuntimeOptions, dialect: VoidAuthDialect, @@ -362,7 +448,7 @@ export function createVoidAuthRuntime( try { const result = await auth.api.getSession({ - headers: request.headers, + headers: await sessionHeaders(request, auth), asResponse: false, returnHeaders: true, }); @@ -400,7 +486,7 @@ export function createVoidAuthRuntime( try { const result = await auth.api.getSession({ - headers: c.req.raw.headers, + headers: await sessionHeaders(c.req.raw, auth), asResponse: false, returnHeaders: true, }); diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 7ab39b301..942cb973e 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -10,7 +10,7 @@ vi.mock('better-auth', () => ({ betterAuth: mocks.betterAuth, })); -import type { AuthState } from '../../src/runtime/auth.ts'; +import { requireAuth, type AuthState, type AuthUser } from '../../src/runtime/auth.ts'; import { createVoidAuthRuntime, type VoidAuthRuntimeOptions, @@ -179,3 +179,235 @@ describe('runVoidAuthMiddleware response headers', () => { expect(response.headers.getSetCookie()).toEqual(['session=rotated; Path=/; HttpOnly']); }); }); + +describe('cross-origin cookie sessions', () => { + const env = { BETTER_AUTH_SECRET: 'test-secret' }; + const now = new Date(); + const signedIn: AuthState = { + user: { + id: 'victim', + name: 'Victim', + email: 'victim@example.test', + emailVerified: true, + createdAt: now, + updatedAt: now, + }, + session: { + id: 'victim-session', + userId: 'victim', + token: 'synthetic-token', + expiresAt: now, + createdAt: now, + updatedAt: now, + }, + }; + + // Better Auth's context matches origins against `this.trustedOrigins`. + function mockAuth( + options: Record = {}, + trustedOrigins: Array = ['https://app.void.app'], + ) { + mocks.getSession.mockImplementation(async ({ headers }: { headers: Headers }) => ({ + response: headers.has('cookie') ? signedIn : null, + headers: new Headers(), + })); + mocks.betterAuth.mockReturnValue({ + api: { getSession: mocks.getSession }, + options, + $context: Promise.resolve({ + trustedOrigins, + isTrustedOrigin(this: { trustedOrigins: Array }, url: string) { + return this.trustedOrigins.includes(url); + }, + }), + }); + } + + function upgradeRequest(headers: Record): Request { + return new Request('https://app.void.app/chat/general', { + headers: { upgrade: 'websocket', cookie: 'session=victim', ...headers }, + }); + } + + async function sessionCookieFor(request: Request): Promise { + await runtime.resolveVoidAuthState(request, env, authRuntimeOptions, {} as never); + expect(mocks.getSession).toHaveBeenCalledOnce(); + return (mocks.getSession.mock.calls[0]![0] as { headers: Headers }).headers.get('cookie'); + } + + function routeApp() { + const app = new Hono<{ Variables: { user: AuthUser | null } }>(); + app.use(async (c, next) => { + await runtime.runVoidAuthMiddleware(c, next, authRuntimeOptions, {} as never); + }); + return app; + } + + it('ignores the cookie on a WebSocket upgrade from another origin', async () => { + mockAuth(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await expect( + sessionCookieFor(upgradeRequest({ origin: 'https://evil.void.app' })), + ).resolves.toBeNull(); + } finally { + warn.mockRestore(); + } + }); + + it('ignores the cookie on a WebSocket upgrade from a plain http page on the same host', async () => { + mockAuth(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await expect( + sessionCookieFor(upgradeRequest({ origin: 'http://app.void.app' })), + ).resolves.toBeNull(); + } finally { + warn.mockRestore(); + } + }); + + it('keeps the cookie when Vite HTTPS hands the upgrade over as http', async () => { + // The default base URL follows the request, so Better Auth trusts only http. + mockAuth({}, ['http://app.void.app']); + const request = new Request('http://app.void.app/chat/general', { + headers: { upgrade: 'websocket', cookie: 'session=victim', origin: 'https://app.void.app' }, + }); + await expect(sessionCookieFor(request)).resolves.toBe('session=victim'); + }); + + it.each([ + ['the same origin', { origin: 'https://app.void.app' }], + ['no origin', {}], + [ + 'an opaque origin the browser marks same-origin', + { origin: 'null', 'sec-fetch-site': 'same-origin' }, + ], + ])('keeps the cookie on a WebSocket upgrade from %s', async (_label, headers) => { + mockAuth(); + await expect(sessionCookieFor(upgradeRequest(headers))).resolves.toBe('session=victim'); + }); + + it('keeps the cookie for an origin Better Auth trusts', async () => { + mockAuth({}, ['https://app.void.app', 'https://admin.example.com']); + await expect( + sessionCookieFor(upgradeRequest({ origin: 'https://admin.example.com' })), + ).resolves.toBe('session=victim'); + }); + + it('evaluates function-form trustedOrigins with the request', async () => { + const trustedOrigins = vi.fn(async (request?: Request) => + request?.headers.get('origin') === 'https://admin.example.com' + ? ['https://admin.example.com', null] + : [], + ); + mockAuth({ trustedOrigins }); + const request = upgradeRequest({ origin: 'https://admin.example.com' }); + await expect(sessionCookieFor(request)).resolves.toBe('session=victim'); + expect(trustedOrigins).toHaveBeenCalledWith(request); + }); + + it('follows the Better Auth CSRF opt-out options', async () => { + mockAuth({ advanced: { disableCSRFCheck: true } }); + await expect( + sessionCookieFor(upgradeRequest({ origin: 'https://evil.void.app' })), + ).resolves.toBe('session=victim'); + + mocks.getSession.mockClear(); + mockAuth({ advanced: { disableOriginCheck: true } }); + await expect( + sessionCookieFor(upgradeRequest({ origin: 'https://evil.void.app' })), + ).resolves.toBe('session=victim'); + + mocks.getSession.mockClear(); + mockAuth({ advanced: { disableOriginCheck: true, disableCSRFCheck: false } }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await expect( + sessionCookieFor(upgradeRequest({ origin: 'https://evil.void.app' })), + ).resolves.toBeNull(); + } finally { + warn.mockRestore(); + } + }); + + it('ignores the cookie for an opaque origin that is not same-origin', async () => { + mockAuth(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await expect( + sessionCookieFor(upgradeRequest({ origin: 'null', 'sec-fetch-site': 'same-site' })), + ).resolves.toBeNull(); + } finally { + warn.mockRestore(); + } + }); + + it('leaves a cross-origin WebSocket upgrade without a user in the middleware', async () => { + mockAuth(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = routeApp(); + app.get('/chat/general', (c) => c.json({ user: c.get('user') })); + try { + const response = await app.request( + upgradeRequest({ origin: 'https://evil.void.app' }), + undefined, + env, + ); + await expect(response.json()).resolves.toEqual({ user: null }); + } finally { + warn.mockRestore(); + } + }); + + it('does not sign in a cross-origin POST but keeps its raw cookies', async () => { + mockAuth(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const app = routeApp(); + app.post('/api/profile', (c) => { + const cookie = c.req.header('cookie'); + return c.json({ id: requireAuth(c).id, cookie }); + }); + app.post('/api/whoami', (c) => c.json({ user: c.get('user'), cookie: c.req.header('cookie') })); + try { + const init = { + method: 'POST', + headers: { cookie: 'session=victim', origin: 'https://evil.void.app' }, + body: '{}', + }; + const denied = await app.request('https://app.void.app/api/profile', init, env); + expect(denied.status).toBe(401); + + const whoami = await app.request('https://app.void.app/api/whoami', init, env); + await expect(whoami.json()).resolves.toEqual({ user: null, cookie: 'session=victim' }); + expect(warn).toHaveBeenCalledTimes(2); + expect(String(warn.mock.calls[0]![0])).toContain('https://evil.void.app'); + expect(String(warn.mock.calls[0]![0])).toContain('trustedOrigins'); + } finally { + warn.mockRestore(); + } + }); + + it.each([ + ['a same-origin POST', 'POST', { origin: 'https://app.void.app' }], + ['a POST without an Origin header', 'POST', {}], + ['a cross-origin GET', 'GET', { origin: 'https://evil.void.app' }], + ])('keeps the session for %s', async (_label, method, headers) => { + mockAuth(); + const warn = vi.spyOn(console, 'warn'); + const app = routeApp(); + app.on(method, '/api/profile', (c) => c.json({ id: requireAuth(c).id })); + try { + const response = await app.request( + 'https://app.void.app/api/profile', + { method, headers: { cookie: 'session=victim', ...headers } }, + env, + ); + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ id: 'victim' }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); From 9229a39875da3d221951f98844de7bc4b7edf7de Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:15:37 +0800 Subject: [PATCH 06/51] Rate-limit auth endpoints on deployed Cloudflare apps Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/auth.md | 2 + .../void/src/runtime/better-auth-shared.ts | 44 +++++++++- .../test/integration/auth-rate-limit.test.ts | 76 +++++++++++++++++ packages/void/test/unit/better-auth.test.ts | 84 +++++++++++++++++++ 4 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 packages/void/test/integration/auth-rate-limit.test.ts diff --git a/docs/guide/auth.md b/docs/guide/auth.md index ecd8fb71e..947496a63 100644 --- a/docs/guide/auth.md +++ b/docs/guide/auth.md @@ -129,6 +129,8 @@ Local development needs no secret setup. To preview a production build locally, `POST`, `PUT`, `PATCH` and `DELETE` requests and WebSocket connections from another origin don't use the signed-in session unless that origin is in `trustedOrigins`. If a separate frontend calls your app, add its origin there (see [Customization](#customization)). If your routes use their own cookies, check the request `Origin` yourself, for example with Hono's [`csrf()`](https://hono.dev/docs/middleware/builtin/csrf) middleware. +Deployed apps on Cloudflare limit auth requests per client IP with Better Auth's defaults, for example 3 sign-in attempts per 10 seconds. Each Worker instance keeps its own count. Change or turn off the limits with `rateLimit` in `auth.ts`. To share one count across instances, set `rateLimit: { storage: 'database' }`, then run `void db generate` for a direct Cloudflare deploy. + ## Customization For advanced configuration, create `auth.ts` at the project root and export `defineAuth(...)`: diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index 8c10a7408..20007bb63 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -362,6 +362,44 @@ async function sessionHeaders( return withoutCookie; } +const LOOPBACK_IP = /^(127\.|::1$|::ffff:127\.)/; + +/** + * The client IP set by Cloudflare's edge, or null outside a deployed Worker. + * `request.cf` keeps a client-supplied header from being trusted on Node, Bun + * or Deno, and local previews report a loopback address. + */ +function cloudflareClientIp(request: Request): string | null { + const cf = (request as { cf?: unknown }).cf; + if (typeof cf !== 'object' || cf === null) { + return null; + } + const ip = request.headers.get('cf-connecting-ip'); + if (!ip || LOOPBACK_IP.test(ip)) { + return null; + } + return ip; +} + +/** + * Better Auth enables rate limiting only when NODE_ENV is "production", which + * Workers never set. Turn it on for deployed Cloudflare requests and key it by + * Cloudflare's client IP header. Explicit user settings still win. + */ +function applyCloudflareRateLimitDefaults(options: BetterAuthOptions): BetterAuthOptions { + return { + ...options, + rateLimit: { + ...options.rateLimit, + enabled: options.rateLimit?.enabled ?? true, + }, + advanced: { + ...options.advanced, + ipAddress: options.advanced?.ipAddress ?? { ipAddressHeaders: ['cf-connecting-ip'] }, + }, + }; +} + export function assertRuntimeDialect( runtime: VoidAuthRuntimeOptions, dialect: VoidAuthDialect, @@ -405,8 +443,12 @@ export function createVoidAuthRuntime( trustedOrigins: [origin], }; + const resolved = resolveUserConfig(runtime.userConfig, defaults, request, env, dialect); return { - options: resolveUserConfig(runtime.userConfig, defaults, request, env, dialect), + options: + runtime.dev !== true && cloudflareClientIp(request) + ? applyCloudflareRateLimitDefaults(resolved) + : resolved, dispose, }; } diff --git a/packages/void/test/integration/auth-rate-limit.test.ts b/packages/void/test/integration/auth-rate-limit.test.ts new file mode 100644 index 000000000..395331017 --- /dev/null +++ b/packages/void/test/integration/auth-rate-limit.test.ts @@ -0,0 +1,76 @@ +import { memoryAdapter } from 'better-auth/adapters/memory'; +import { Hono } from 'hono'; +import { describe, expect, it } from 'vite-plus/test'; +import { createVoidAuthRuntime } from '../../src/runtime/better-auth-shared.ts'; + +const env = { BETTER_AUTH_SECRET: 'local-test-secret-with-at-least-32-characters' }; +const options = { dialect: 'sqlite' as const, providers: null }; + +function fixture() { + const database = memoryAdapter({ user: [], session: [], account: [], verification: [] }); + const runtime = createVoidAuthRuntime( + 'sqlite', + async () => ({ database, dispose: async () => {} }), + async () => {}, + ); + const app = new Hono(); + app.use(async (c, next) => runtime.runVoidAuthMiddleware(c, next, options)); + app.on(['GET', 'POST'], '/api/auth/*', (c) => runtime.handleVoidAuthRequest(c, options)); + return app; +} + +// Better Auth keeps its in-memory limiter for the whole module, so every test +// signs in from its own client address. +function signIn(clientIp: string | null, cf: boolean): Request { + const request = new Request('https://app.test/api/auth/sign-in/email', { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(clientIp ? { 'cf-connecting-ip': clientIp } : {}), + }, + body: JSON.stringify({ email: 'nobody@example.test', password: 'wrong-password' }), + }); + if (cf) { + Object.defineProperty(request, 'cf', { value: { colo: 'SJC' } }); + } + return request; +} + +async function statuses(app: Hono, requests: () => Request, count: number) { + const result: Array = []; + for (let attempt = 0; attempt < count; attempt += 1) { + const response = await app.fetch(requests(), env); + result.push(response.status); + } + return result; +} + +describe('auth rate limits on deployed Cloudflare requests', () => { + it('limits repeated sign-in attempts from one client IP', async () => { + const app = fixture(); + const results = await statuses(app, () => signIn('203.0.113.10', true), 4); + expect(results.slice(0, 3)).not.toContain(429); + expect(results[3]).toBe(429); + + const other = await app.fetch(signIn('203.0.113.11', true), env); + expect(other.status).not.toBe(429); + }); + + it('does not limit requests outside a deployed Worker', async () => { + const app = fixture(); + const results = await statuses(app, () => signIn('203.0.113.12', false), 5); + expect(results).not.toContain(429); + }); + + it.each(['127.0.0.1', '::1'])('does not limit a local preview from %s', async (ip) => { + const app = fixture(); + const results = await statuses(app, () => signIn(ip, true), 5); + expect(results).not.toContain(429); + }); + + it('does not limit Worker requests without a client IP', async () => { + const app = fixture(); + const results = await statuses(app, () => signIn(null, true), 5); + expect(results).not.toContain(429); + }); +}); diff --git a/packages/void/test/unit/better-auth.test.ts b/packages/void/test/unit/better-auth.test.ts index 571e68761..3b505c8d8 100644 --- a/packages/void/test/unit/better-auth.test.ts +++ b/packages/void/test/unit/better-auth.test.ts @@ -271,3 +271,87 @@ describe('resolveVoidAuthOptions', () => { ).resolves.toBe(false); }); }); + +describe('resolveVoidAuthOptions auth rate limits', () => { + function plainRequest(headers: Record): Request { + return new Request('https://example.com/api/auth/sign-in/email', { headers }); + } + + function edgeRequest(headers: Record): Request { + const request = plainRequest(headers); + Object.defineProperty(request, 'cf', { value: { colo: 'SJC' } }); + return request; + } + + function resolve( + request: Request, + runtime: Partial[2]> = {}, + ) { + return resolveVoidAuthOptions( + request, + { BETTER_AUTH_SECRET: 'secret' }, + { dialect: 'sqlite', providers: null, ...runtime }, + {} as never, + ); + } + + it('limits auth requests per Cloudflare client IP on a deployed Worker', async () => { + const { options } = await resolve(edgeRequest({ 'cf-connecting-ip': '203.0.113.7' })); + expect(options.rateLimit?.enabled).toBe(true); + expect(options.advanced?.ipAddress?.ipAddressHeaders).toEqual(['cf-connecting-ip']); + }); + + it('leaves the dev server unlimited', async () => { + const { options } = await resolve(edgeRequest({ 'cf-connecting-ip': '203.0.113.7' }), { + dev: true, + }); + expect(options.rateLimit).toBeUndefined(); + }); + + it('does not trust the Cloudflare IP header outside a Worker', async () => { + const { options } = await resolve(plainRequest({ 'cf-connecting-ip': '203.0.113.7' })); + expect(options.rateLimit).toBeUndefined(); + expect(options.advanced).toBeUndefined(); + }); + + it.each(['127.0.0.1', '::1', '::ffff:127.0.0.1'])( + 'leaves a local preview from %s unlimited', + async (ip) => { + const { options } = await resolve(edgeRequest({ 'cf-connecting-ip': ip })); + expect(options.rateLimit).toBeUndefined(); + expect(options.advanced).toBeUndefined(); + }, + ); + + it('does not share one bucket for requests without a client IP', async () => { + const { options } = await resolve(edgeRequest({})); + expect(options.rateLimit).toBeUndefined(); + expect(options.advanced).toBeUndefined(); + }); + + it('keeps an explicit rateLimit setting', async () => { + const { options } = await resolve(edgeRequest({ 'cf-connecting-ip': '203.0.113.7' }), { + userConfig: { rateLimit: { enabled: false, window: 60 } }, + }); + expect(options.rateLimit).toEqual({ enabled: false, window: 60 }); + }); + + it('adds the client IP header next to other advanced settings', async () => { + const { options } = await resolve(edgeRequest({ 'cf-connecting-ip': '203.0.113.7' }), { + userConfig: { advanced: { cookiePrefix: 'x' } }, + }); + expect(options.advanced).toEqual({ + cookiePrefix: 'x', + ipAddress: { ipAddressHeaders: ['cf-connecting-ip'] }, + }); + }); + + it('keeps an explicit ipAddress setting', async () => { + const ipAddress = { ipAddressHeaders: ['x-real-ip'], ipv6Subnet: 48 }; + const { options } = await resolve(edgeRequest({ 'cf-connecting-ip': '203.0.113.7' }), { + userConfig: { advanced: { ipAddress } }, + }); + expect(options.advanced?.ipAddress).toBe(ipAddress); + expect(options.rateLimit?.enabled).toBe(true); + }); +}); From 4ddf5d08c3268050928142a6bc36d7573ccbf784 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:15:44 +0800 Subject: [PATCH 07/51] Document that a WebSocket's user is fixed for the connection Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/websockets.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/guide/websockets.md b/docs/guide/websockets.md index 8c1c19713..c33249960 100644 --- a/docs/guide/websockets.md +++ b/docs/guide/websockets.md @@ -153,6 +153,8 @@ When Void auth is enabled, every WebSocket hook receives the current user as `ct Connections from another origin are anonymous unless that origin is in your auth `trustedOrigins`. If you read your own cookies from `ctx.request`, check its `Origin` header yourself. +`ctx.user` is resolved when the socket connects and stays the same for that connection, even if the user later signs out or the session is revoked. For actions that must respect a ban or role change, re-check your own records in `onMessage` and close the connection with `ctx.connection.close()` in rooms or `ctx.socket.close()` in `defineWebSocket()`. + ## Hooks Both `defineRoom()` and `defineWebSocket()` support: From f471cafd64acc803f71dbc1778517dcabfc4a191 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:02:33 +0800 Subject: [PATCH 08/51] Keep router.visit() page loads on the current origin Other-origin http(s) GET visits now go straight to a full page load without fetching page data from that origin, other schemes such as javascript: and data: are rejected, and non-GET visits to another origin are rejected before anything is sent. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/pages-routing/overview.md | 2 + packages/void/src/pages/navigation-engine.ts | 15 +++ packages/void/test/unit/pages-client.test.ts | 118 +++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/docs/guide/pages-routing/overview.md b/docs/guide/pages-routing/overview.md index 19815346d..6155bd9ec 100644 --- a/docs/guide/pages-routing/overview.md +++ b/docs/guide/pages-routing/overview.md @@ -212,6 +212,8 @@ router.refresh(); // re-fetch current page props router.visit('/logout', { method: 'POST' }); // non-GET navigation ``` +`router.visit()` updates the page in place for same-origin URLs. `http(s)` URLs on another origin get a full page load, and other schemes such as `javascript:` are rejected. + For dynamic route params, use `useParams()` from the same adapter package: ```tsx diff --git a/packages/void/src/pages/navigation-engine.ts b/packages/void/src/pages/navigation-engine.ts index c3319f13f..9275378b8 100644 --- a/packages/void/src/pages/navigation-engine.ts +++ b/packages/void/src/pages/navigation-engine.ts @@ -594,6 +594,21 @@ export async function createPageNavigationEngine( commitTarget: { fetchUrl: string; hash: string }, deferredRegistry: DeferredRegistry, ): Promise { + // Page data only comes from this origin. Other http(s) URLs get a full page + // load; other schemes (javascript:, data:, ...) must never reach location.href. + // Check the scheme first: a blob: URL reports the origin that created it. + const target = new URL(url, window.location.origin); + if (target.protocol !== 'http:' && target.protocol !== 'https:') { + throw new Error(`Void Router: refusing to navigate to a ${target.protocol} URL.`); + } + if (target.origin !== window.location.origin) { + if (method !== 'GET') { + throw new Error('Void Router: non-GET visits must target the current origin.'); + } + window.location.href = url; + return undefined; + } + if (method !== 'GET') { _prefetchCache.flushAll(); } diff --git a/packages/void/test/unit/pages-client.test.ts b/packages/void/test/unit/pages-client.test.ts index 22a21068a..ef735fa9f 100644 --- a/packages/void/test/unit/pages-client.test.ts +++ b/packages/void/test/unit/pages-client.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { createVoidRouter, ssrProxy } from '../../src/pages/client.ts'; +import { createPageNavigationEngine } from '../../src/pages/navigation-engine.ts'; import { parseCacheFor } from '../../src/pages/prefetch.ts'; type DeferredTestWindow = Window & { @@ -1258,3 +1259,120 @@ describe('createVoidRouter prefetch', () => { expect(resolveDeferred).not.toHaveBeenCalled(); }); }); + +describe('createVoidRouter other-origin visits', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function createRouter() { + stubClientGlobals(); + const fetchMock = vi.fn(async () => + Response.json( + { component: 'users', props: { users: [] } }, + { headers: { 'X-VoidPages': 'true' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + const applyUpdate = vi.fn(); + const { router } = await createVoidRouter({ + adapter: { + applyUpdate, + createDeferred: vi.fn(() => ({})), + isLoading: vi.fn(() => false), + rejectDeferred: vi.fn((_ref, error) => error), + resolveDeferred: vi.fn((_ref, value) => value), + }, + components: { + home: vi.fn(async () => ({ default: {} })), + users: vi.fn(async () => ({ default: {} })), + }, + layoutTree: {}, + matchRoute: vi.fn((url) => (url === '/users' ? 'users' : 'home')), + prefetchDefaultCacheFor: parseCacheFor('30s'), + prefetchHoverDelay: 75, + routeMeta: {}, + viewTransitions: false, + }); + return { router, fetchMock, applyUpdate }; + } + + it.each(['javascript:alert(1)', ' JaVaScRiPt:alert(1)', 'data:text/html,x'])( + 'rejects %j without fetching or assigning location', + async (url) => { + const { router, fetchMock, applyUpdate } = await createRouter(); + + await expect(router.visit(url)).rejects.toThrow(/refusing to navigate/); + expect(fetchMock).not.toHaveBeenCalled(); + expect(applyUpdate).not.toHaveBeenCalled(); + expect(window.location.href).toBeUndefined(); + }, + ); + + it('rejects a same-origin blob URL passed straight to the navigation engine', async () => { + stubClientGlobals(); + const fetchMock = vi.fn( + async () => new Response('', { headers: { 'Content-Type': 'text/html' } }), + ); + vi.stubGlobal('fetch', fetchMock); + const engine = await createPageNavigationEngine({ + adapter: { + createDeferred: () => ({}), + resolveDeferred: (_ref, value) => value, + rejectDeferred: (_ref, error) => error, + isLoading: () => false, + }, + components: { home: async () => ({ default: {} }) }, + layoutTree: {}, + routeMeta: {}, + matchRoute: () => 'home', + viewTransitions: false, + prefetchHoverDelay: 75, + prefetchDefaultCacheFor: parseCacheFor('30s'), + }); + + const resource = engine.prepareVisit( + 'blob:http://localhost/0b5e4c1a-7f0d-4f9e-9b52-1c3d2e4f5a6b', + ); + + await expect(resource.shell).rejects.toThrow('refusing to navigate to a blob: URL'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(window.location.href).toBeUndefined(); + }); + + it('sends other-origin http(s) GET visits to a full page load without fetching', async () => { + const { router, fetchMock, applyUpdate } = await createRouter(); + + await expect(router.visit('https://other.example/x?y=1#z')).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(applyUpdate).not.toHaveBeenCalled(); + expect(window.location.href).toBe('https://other.example/x?y=1#z'); + }); + + it('rejects non-GET visits to another origin without sending them', async () => { + const { router, fetchMock } = await createRouter(); + + await expect( + router.visit('https://other.example/x', { method: 'POST', data: { a: 1 } }), + ).rejects.toThrow('Void Router: non-GET visits must target the current origin.'); + expect(fetchMock).not.toHaveBeenCalled(); + expect(window.location.href).toBeUndefined(); + }); + + it('still loads absolute same-origin URLs in place', async () => { + const { router, fetchMock, applyUpdate } = await createRouter(); + + await router.visit('http://localhost/users'); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + '/users', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ 'X-VoidPages': 'true' }), + }), + ); + expect(applyUpdate).toHaveBeenCalledTimes(1); + expect(applyUpdate.mock.calls[0][0]).toMatchObject({ component: 'users' }); + expect(window.location.href).toBeUndefined(); + }); +}); From ac5a9df725acd7a7240db6e79328472f5e5bccd4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:03:24 +0800 Subject: [PATCH 09/51] Reject route params that would change the request path Typed fetch, fetchStream, connect, action and the generated server clients now throw a TypeError when a param is "." or "..", when a catch-all param contains such a segment, or when a leading param would make the path start with "//". Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/runtime/route-url.ts | 58 ++++++++++++++++------- packages/void/test/unit/route-url.test.ts | 31 ++++++++++++ 2 files changed, 73 insertions(+), 16 deletions(-) diff --git a/packages/void/src/runtime/route-url.ts b/packages/void/src/runtime/route-url.ts index 0603e1cf7..0c66a60b0 100644 --- a/packages/void/src/runtime/route-url.ts +++ b/packages/void/src/runtime/route-url.ts @@ -1,3 +1,17 @@ +/** + * Encode one path segment of a route param. `encodeURIComponent` keeps dots, and + * URL parsing resolves `.`/`..` (even as `%2e`), so such a segment would move the + * request to another path instead of reaching this route. + */ +function encodeSegment(name: string, segment: string): string { + if (segment === '.' || segment === '..') { + throw new TypeError( + `Route param "${name}" cannot be "${segment}"; it would change the request path.`, + ); + } + return encodeURIComponent(segment); +} + /** Expand complete route tokens without changing query strings or fragments. */ export function interpolateRouteParams(path: string, params?: Record): string { if (!params) { @@ -6,21 +20,33 @@ export function interpolateRouteParams(path: string, params?: Record { - if (!Object.hasOwn(params, name)) { - return token; - } - const value = params[name]; - return ( - prefix + - (catchAll - ? value.split('/').map(encodeURIComponent).join('/') - : encodeURIComponent(value)) - ); - }, - ) + suffix + let leadingName: string | undefined; + const replaced = pathname.replace( + /(^|\/):([^/{}]+)(\{\.\+\})?(?=\/|$)/g, + (token: string, prefix: string, name: string, catchAll: string | undefined, offset: number) => { + if (!Object.hasOwn(params, name)) { + return token; + } + if (offset === 0) { + leadingName = name; + } + const value = params[name]; + return ( + prefix + + (catchAll + ? value + .split('/') + .map((segment) => encodeSegment(name, segment)) + .join('/') + : encodeSegment(name, value)) + ); + }, ); + // A path that starts with `//` is read as another host by fetch() and URL parsing. + if (replaced.startsWith('//') && !pathname.startsWith('//')) { + throw new TypeError( + `Route param "${leadingName ?? ''}" would make the request path start with "//", which changes the request host.`, + ); + } + return replaced + suffix; } diff --git a/packages/void/test/unit/route-url.test.ts b/packages/void/test/unit/route-url.test.ts index 860f29463..7a828ecbd 100644 --- a/packages/void/test/unit/route-url.test.ts +++ b/packages/void/test/unit/route-url.test.ts @@ -21,4 +21,35 @@ describe('route URL interpolation', () => { expect(interpolateRouteParams('/:id', undefined)).toBe('/:id'); expect(interpolateRouteParams('/:toString', {})).toBe('/:toString'); }); + + it.each([ + ['/api/posts/:id', { id: '..' }], + ['/api/posts/:id', { id: '.' }], + ['/files/:path{.+}', { path: 'a/../../admin' }], + ['/files/:path{.+}', { path: './x' }], + ])('rejects dot segments that would change the path of %s (%j)', (path, params) => { + expect(() => interpolateRouteParams(path, params)).toThrow(TypeError); + expect(() => interpolateRouteParams(path, params)).toThrow(/it would change the request path/); + }); + + it('rejects a leading param that would make the path start with //', () => { + expect(() => interpolateRouteParams('/:path{.+}', { path: '/evil.com/x' })).toThrow( + new TypeError( + 'Route param "path" would make the request path start with "//", which changes the request host.', + ), + ); + }); + + it('keeps values that only contain dots or encoded dots', () => { + expect(interpolateRouteParams('/api/posts/:id', { id: '...' })).toBe('/api/posts/...'); + expect(interpolateRouteParams('/api/posts/:id', { id: '.hidden' })).toBe('/api/posts/.hidden'); + expect(interpolateRouteParams('/api/posts/:id', { id: 'a..b' })).toBe('/api/posts/a..b'); + expect(interpolateRouteParams('/api/posts/:id', { id: '%2e%2e' })).toBe( + '/api/posts/%252e%252e', + ); + expect(interpolateRouteParams('/api/posts/:id', { id: '../admin' })).toBe( + '/api/posts/..%2Fadmin', + ); + expect(interpolateRouteParams('/files/:path{.+}', { path: 'a//b' })).toBe('/files/a//b'); + }); }); From 6a78178ed63d59b72d24b184de5fa8b3838314c2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:04:26 +0800 Subject: [PATCH 10/51] Send a fixed message for internal void/live errors When a live hook, binding or the Durable Object fails, connect and control now log the error on the server and answer 500 with "live: internal error." instead of the raw error text. Client input errors keep their specific messages. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/runtime/live.ts | 8 +- packages/void/test/unit/live-server.test.ts | 85 +++++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/void/src/runtime/live.ts b/packages/void/src/runtime/live.ts index a5040f0f2..f33257e2a 100644 --- a/packages/void/src/runtime/live.ts +++ b/packages/void/src/runtime/live.ts @@ -629,13 +629,13 @@ function errorToResponse(error: unknown): Response { { status: error.status }, ); } + // Hooks, bindings and the Durable Object can fail with internal details; log them + // on the server and send clients a fixed message. + console.error(error); return Response.json( { accepted: false, - error: { - code: 'INTERNAL_ERROR', - message: error instanceof Error ? error.message : String(error), - }, + error: { code: 'INTERNAL_ERROR', message: 'live: internal error.' }, } satisfies LiveControlResponse, { status: 500 }, ); diff --git a/packages/void/test/unit/live-server.test.ts b/packages/void/test/unit/live-server.test.ts index ad6a1789d..a79442f31 100644 --- a/packages/void/test/unit/live-server.test.ts +++ b/packages/void/test/unit/live-server.test.ts @@ -656,6 +656,91 @@ describe('live Durable Object runtime', () => { const topicName = `live:app:topic:${await topicDigest('app', 'post:12')}`; expect(await harness.subscriberCount(topicName)).toBe(0); }); + + it('hides internal error text when a subscribe hook throws', async () => { + const harness = createHarness(); + const error = new Error('secret detail'); + const stream = defineLiveStream({ + id: `subscribe-error-${crypto.randomUUID()}`, + allowAnonymousControl: true, + onSubscribe() { + throw error; + }, + }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const response = await stream.control( + liveContext(harness.env, 'https://void.live/live', { + connectionId: 'connection-subscribe-error', + operations: [{ kind: 'subscribe', id: 'card', topic: 'post:12' }], + }), + ); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(JSON.parse(text)).toEqual({ + accepted: false, + error: { code: 'INTERNAL_ERROR', message: 'live: internal error.' }, + }); + expect(text).not.toContain('secret detail'); + expect(consoleError).toHaveBeenCalledWith(error); + } finally { + consoleError.mockRestore(); + } + }); + + it('hides internal error text when a connect hook throws', async () => { + const harness = createHarness(); + const stream = defineLiveStream({ + id: `connect-error-${crypto.randomUUID()}`, + allowAnonymousControl: true, + onConnect() { + throw new Error('secret detail'); + }, + }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + try { + const response = await stream.connect( + liveContext(harness.env, 'https://void.live/live?connectionId=connection-connect-error'), + ); + const text = await response.text(); + + expect(response.status).toBe(500); + expect(JSON.parse(text)).toEqual({ + accepted: false, + error: { code: 'INTERNAL_ERROR', message: 'live: internal error.' }, + }); + expect(text).not.toContain('secret detail'); + expect(consoleError).toHaveBeenCalledTimes(1); + } finally { + consoleError.mockRestore(); + } + }); + + it('keeps specific messages for invalid control requests', async () => { + const harness = createHarness(); + const stream = defineLiveStream({ + id: `control-json-${crypto.randomUUID()}`, + allowAnonymousControl: true, + }); + const ctx = { + env: harness.env, + req: { + raw: new Request('https://void.live/live', { method: 'POST', body: 'not json' }), + }, + get() { + return undefined; + }, + } as unknown as CloudContext; + + const response = await stream.control(ctx); + + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ + accepted: false, + error: { code: 'BAD_REQUEST', message: 'live: control request body must be JSON.' }, + }); + }); }); function createHarness(options?: { From 8c7ae4cb4b8b5cb7c269d28af91d7f2f997cc3b9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:04:46 +0800 Subject: [PATCH 11/51] Explain that void/live topics are open until onSubscribe checks them The live guide and the onSubscribe type docs now say that, without onSubscribe, any connection that passes the owner check can subscribe to any topic, and the example rejects topics it does not know. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/live.md | 15 ++++++++++----- packages/void/src/runtime/live.ts | 1 + 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/guide/live.md b/docs/guide/live.md index f89301623..f862face6 100644 --- a/docs/guide/live.md +++ b/docs/guide/live.md @@ -22,6 +22,8 @@ export const live = defineLiveStream({ }); ``` +This example accepts visitors who are not signed in. Without `onSubscribe`, any connection can subscribe to any topic, so read [Authorization](#authorization) before you publish private data. + Void enables live streams when a [scanned file](../reference/resource-inference.md#scanned-directories) imports `void/live`, so keep this module in `src/` or another scanned directory. To keep it in an unscanned directory such as `lib/`, add that directory to [`inference.scanDirs`](../reference/resource-inference.md#custom-scan-directories) and list the default directories you still use, because `scanDirs` replaces them. Expose it from a normal route: @@ -101,6 +103,8 @@ await live.withEnv(env).publish('post:12', { title: 'Updated title' }); Only a connection's owner can change its subscriptions. The example above allows anonymous connections; authenticated streams use the current Void user by default. +The owner decides who can change a connection's subscriptions, not which topics the connection can read. + For authenticated streams, pass the same owner key to `connect()` and `control()`: @@ -127,16 +131,17 @@ current authenticated user and uses `user:${user.id}` when the user has a string `id`. If neither path produces an owner and `allowAnonymousControl` is not set, the request is rejected with `403`. -For stream-local subscription rules, use `onSubscribe`: +Without `onSubscribe`, any connection that passes the owner check (any signed-in user, or any visitor when `allowAnonymousControl` is set) can subscribe to any topic and receives everything published to it. Topic names are easy to guess. Use `onSubscribe` to decide who can read each topic: return a `Response` to reject the subscription, or return nothing to allow it. ```ts export const live = defineLiveStream({ id: 'app', async onSubscribe(ctx) { - const match = /^post:(.+)$/.exec(ctx.topic); - if (match && !(await canReadPost(ctx.env, ctx.user, match[1]))) { - return new Response('Forbidden', { status: 403 }); - } + const post = /^post:(.+)$/.exec(ctx.topic); + if (post && (await canReadPost(ctx.env, ctx.user, post[1]))) return; + const inbox = /^user:(.+):inbox$/.exec(ctx.topic); + if (inbox && ctx.owner === `user:${inbox[1]}`) return; + return new Response('Forbidden', { status: 403 }); }, }); ``` diff --git a/packages/void/src/runtime/live.ts b/packages/void/src/runtime/live.ts index f33257e2a..7667bcb47 100644 --- a/packages/void/src/runtime/live.ts +++ b/packages/void/src/runtime/live.ts @@ -124,6 +124,7 @@ export type LiveStreamOptions = { allowAnonymousControl?: boolean; identifyConnection?: (ctx: LiveHookContext) => MaybePromise; onConnect?: (ctx: LiveConnectHookContext) => MaybePromise; + /** Decides who can read each topic. Without it, any connection that passes the owner check can subscribe to any topic. Return a Response to reject. */ onSubscribe?: (ctx: LiveSubscribeHookContext) => MaybePromise; limits?: Partial; }; From 43ac447016868c17bfa8f9294975e9811e66eb7e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:05:24 +0800 Subject: [PATCH 12/51] Fail basicAuth closed when the password resolves to an empty value An unset or empty password used to accept an empty password from any client. The middleware now throws, so the app answers with a server error instead of serving the gated page. An empty username still works. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/reference/api/handlers.md | 2 + packages/void/src/runtime/handler.ts | 6 +++ packages/void/test/unit/basic-auth.test.ts | 62 ++++++++++++++++++++-- 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/docs/reference/api/handlers.md b/docs/reference/api/handlers.md index 33e4279fb..d42fe7ca6 100644 --- a/docs/reference/api/handlers.md +++ b/docs/reference/api/handlers.md @@ -103,6 +103,8 @@ Void’s reserved `/__void` endpoints use their own authentication and are exclu When credentials come from `void/env`, wrap the reads in functions so they are resolved per request after Void has bound the runtime env. +If `password` resolves to an empty value, requests fail with a server error instead of passing the gate. + For app-specific bypasses such as health checks or public webhooks, compose that logic in your own middleware before calling `basicAuth()`. ```ts diff --git a/packages/void/src/runtime/handler.ts b/packages/void/src/runtime/handler.ts index 069fdc67e..192cebea7 100644 --- a/packages/void/src/runtime/handler.ts +++ b/packages/void/src/runtime/handler.ts @@ -378,6 +378,12 @@ export function basicAuth(options: BasicAuthOptions): MiddlewareHandler(); app.use('*', basicAuth(options)); - app.all('*', (c) => c.text('ok')); + app.all('*', (c) => { + downstream(); + return c.text('ok'); + }); return app; } describe('basicAuth', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('challenges requests without credentials', async () => { const app = createApp({ username: 'guest', password: 'wedding', realm: 'Preview' }); @@ -129,6 +136,55 @@ describe('basicAuth', () => { expect(await res.text()).toBe('ok'); }); + it('skips Void internal endpoints when the password is empty', async () => { + const app = createApp({ username: 'guest', password: '' }); + + const res = await app.request('http://localhost/__void/migrate', { method: 'POST' }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe('ok'); + }); + + it.each([ + ['an empty password', { username: '', password: '' }, ':'], + [ + 'a password callback that returns undefined', + { username: 'admin', password: () => undefined as unknown as string }, + 'admin:', + ], + ])('fails closed for %s', async (_label, options, credentials) => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const downstream = vi.fn(); + const app = createApp(options, downstream); + + const res = await app.request('http://localhost/', { + headers: { Authorization: authorization(credentials) }, + }); + + expect(res.status).toBe(500); + expect(downstream).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + expect.objectContaining({ + message: + 'basicAuth: the password resolved to an empty value. Set the credential before serving requests.', + }), + ); + }); + + it.each([ + ['an empty username', ''], + ['a username callback that returns undefined', () => undefined as unknown as string], + ])('allows password-only gates with %s', async (_label, username) => { + const app = createApp({ username, password: 'secret' }); + + const res = await app.request('http://localhost/', { + headers: { Authorization: authorization(':secret') }, + }); + + expect(res.status).toBe(200); + expect(await res.text()).toBe('ok'); + }); + it('does not trust x-void-internal on public paths', async () => { const app = createApp({ username: 'guest', password: 'wedding' }); From 8e300c4f8846f77bdb1b8b85e323a475847efc23 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:04:37 +0800 Subject: [PATCH 13/51] Keep database URLs out of the project directory for db studio and db push Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/cli/db.ts | 63 ++++++++------ packages/void/src/drizzle/config.ts | 54 ++++++++++-- .../void/test/unit/db-studio-remote.test.ts | 83 +++++++++++++++++-- .../void/test/unit/drizzle-config.test.ts | 80 +++++++++++++++++- 4 files changed, 239 insertions(+), 41 deletions(-) diff --git a/packages/void/src/cli/db.ts b/packages/void/src/cli/db.ts index 72452b59e..3cee30a1a 100644 --- a/packages/void/src/cli/db.ts +++ b/packages/void/src/cli/db.ts @@ -78,6 +78,32 @@ function runDrizzleKit(root: string, args: Array): void { }); } +/** + * Run a drizzle-kit command against a real database URL. The config holding + * the URL lives in a private temp directory outside the project and is + * removed afterwards. Exits with code 1 when drizzle-kit fails; it has + * already printed its own error. + */ +async function runDrizzleKitWithDatabaseUrl( + root: string, + url: string, + dialect: DatabaseDialect, + command: 'push' | 'studio', +): Promise { + const { withPrivateDrizzleConfig } = await import('../drizzle/config.ts'); + const succeeded = withPrivateDrizzleConfig(root, url, dialect, (configPath) => { + try { + runDrizzleKit(root, [command, '--config', configPath]); + return true; + } catch { + return false; + } + }); + if (!succeeded) { + process.exit(1); + } +} + type SeedTarget = { displayPath: string; kind: 'module' | 'sql'; @@ -358,20 +384,20 @@ async function runConnect( } async function runPush(root: string, dialect: DatabaseDialect): Promise { - const { writeDrizzleConfig } = await import('../drizzle/config.ts'); - - let configPath: string; if (dialect !== 'sqlite') { const url = dialect === 'mysql' ? getMySqlUrl(root) : getPgUrl(root); - configPath = writeDrizzleConfig(root, url, dialect); - } else { - const { mkdirSync } = await import('node:fs'); - const { dirname } = await import('pathslash'); - const { databasePath: dbPath } = getLocalD1Target(root); - mkdirSync(dirname(dbPath), { recursive: true }); - configPath = writeDrizzleConfig(root, dbPath); + await runDrizzleKitWithDatabaseUrl(root, url, dialect, 'push'); + printSuccess('Database schema pushed.'); + return; } + const { writeDrizzleConfig } = await import('../drizzle/config.ts'); + const { mkdirSync } = await import('node:fs'); + const { dirname } = await import('pathslash'); + const { databasePath: dbPath } = getLocalD1Target(root); + mkdirSync(dirname(dbPath), { recursive: true }); + const configPath = writeDrizzleConfig(root, dbPath); + try { runDrizzleKit(root, ['push', '--config', configPath]); } catch { @@ -1370,29 +1396,16 @@ async function runStudio(root: string, dialect: DatabaseDialect, remote?: boolea dbUrl = await fetchRemoteDatabaseUrl(client, projectConfig.projectId); } - const { writeDrizzleConfig } = await import('../drizzle/config.ts'); - const configPath = writeDrizzleConfig(root, dbUrl, dialect); - log.info('Opening Drizzle Studio (remote)...'); - try { - runDrizzleKit(root, ['studio', '--config', configPath]); - } catch { - process.exit(1); - } + await runDrizzleKitWithDatabaseUrl(root, dbUrl, dialect, 'studio'); return; } if (dialect !== 'sqlite') { - const { writeDrizzleConfig } = await import('../drizzle/config.ts'); const url = dialect === 'mysql' ? getMySqlUrl(root) : getPgUrl(root); - const configPath = writeDrizzleConfig(root, url, dialect); log.info('Opening Drizzle Studio...'); - try { - runDrizzleKit(root, ['studio', '--config', configPath]); - } catch { - process.exit(1); - } + await runDrizzleKitWithDatabaseUrl(root, url, dialect, 'studio'); return; } diff --git a/packages/void/src/drizzle/config.ts b/packages/void/src/drizzle/config.ts index d8043f8a6..acad7d003 100644 --- a/packages/void/src/drizzle/config.ts +++ b/packages/void/src/drizzle/config.ts @@ -1,20 +1,27 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join, relative } from 'pathslash'; import { readProjectPaths } from '../project-paths.ts'; import { discoverSchema, schemaSpecifier } from './discover.ts'; +type DrizzleDialect = 'sqlite' | 'postgresql' | 'mysql'; + /** - * Write a temporary drizzle-kit config to `.void/drizzle.config.json`. - * All paths are relative to the project root (drizzle-kit resolves - * relative to `--config` file location, so we use project-root-relative). + * Write a temporary drizzle-kit config, by default to `.void/drizzle.config.json`. + * Schema and migration paths are relative to the project root: drizzle-kit + * resolves them against its working directory, and `void db` always runs it + * with `cwd` set to the project root, so the config file itself may live + * anywhere (see `outDir`). * + * @param options.outDir Directory for the config file. When set, the file is + * written readable by the owner only. * @returns Absolute path to the written config file. */ export function writeDrizzleConfig( root: string, dbPath: string, - dialect: 'sqlite' | 'postgresql' | 'mysql' = 'sqlite', - options?: { additionalSchemas?: Array }, + dialect: DrizzleDialect = 'sqlite', + options?: { additionalSchemas?: Array; outDir?: string }, ): string { const paths = readProjectPaths(root); const schema = discoverSchema(root, paths); @@ -28,7 +35,7 @@ export function writeDrizzleConfig( ); } - const outDir = join(root, '.void'); + const outDir = options?.outDir ?? join(root, '.void'); mkdirSync(outDir, { recursive: true }); const config = { @@ -40,6 +47,37 @@ export function writeDrizzleConfig( }; const configPath = join(outDir, 'drizzle.config.json'); - writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n'); + writeFileSync( + configPath, + JSON.stringify(config, null, 2) + '\n', + options?.outDir ? { mode: 0o600 } : undefined, + ); return configPath; } + +/** + * Run `run` with a drizzle-kit config that holds a real database URL, kept + * out of the project directory (the dev server can serve files under it). + * The config lives in a private temp directory that is removed afterwards. + * + * A no-op SIGINT listener stays installed while `run` executes: Drizzle + * Studio is stopped with Ctrl-C, and without a listener Node would exit on + * the signal before `finally` removes the directory. + */ +export function withPrivateDrizzleConfig( + root: string, + dbUrl: string, + dialect: DrizzleDialect, + run: (configPath: string) => T, +): T { + const dir = mkdtempSync(join(tmpdir(), 'void-drizzle-')); + const noop = () => {}; + process.on('SIGINT', noop); + try { + const configPath = writeDrizzleConfig(root, dbUrl, dialect, { outDir: dir }); + return run(configPath); + } finally { + process.off('SIGINT', noop); + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/packages/void/test/unit/db-studio-remote.test.ts b/packages/void/test/unit/db-studio-remote.test.ts index fafd502b7..35659b0e4 100644 --- a/packages/void/test/unit/db-studio-remote.test.ts +++ b/packages/void/test/unit/db-studio-remote.test.ts @@ -1,6 +1,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { join, relative } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'; // ---- hoisted mocks --------------------------------------------------------- @@ -41,14 +41,20 @@ vi.mock('../../src/cli/client', () => ({ }, })); -// Mock writeDrizzleConfig to avoid needing a real schema on disk. +// Mock the drizzle config writers to avoid needing a real schema on disk. const mockWriteDrizzleConfig = vi.fn<(root: string, dbPath: string, dialect?: string) => string>( (root, _dbPath, _dialect) => join(root, '.void', 'drizzle.config.json'), ); +const mockWithPrivateDrizzleConfig = vi.fn( + (_root: string, _dbUrl: string, _dialect: string, run: (configPath: string) => unknown) => + run(join(tmpdir(), 'void-drizzle-test', 'drizzle.config.json')), +); vi.mock('../../src/drizzle/config.ts', () => ({ writeDrizzleConfig: (...args: Array) => mockWriteDrizzleConfig(...(args as Parameters)), + withPrivateDrizzleConfig: (...args: Array) => + mockWithPrivateDrizzleConfig(...(args as Parameters)), })); // Mock execFileSync (used by runDrizzleKit) so no child process is spawned. @@ -79,6 +85,14 @@ vi.mock('@voidzero-dev/vite-plus-prompts', () => ({ const roots: Array = []; +function expectConfigOutsideRoot(root: string): void { + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + const cliArgs = (mockExecFileSync.mock.calls[0] as unknown as [string, Array])[1]; + const configPath = cliArgs[cliArgs.indexOf('--config') + 1]; + expect(configPath).toBeDefined(); + expect(relative(root, configPath!).startsWith('..')).toBe(true); +} + function createProjectRoot(options: { database?: 'pg' } = {}): string { const root = mkdtempSync(join(tmpdir(), 'void-db-studio-remote-')); roots.push(root); @@ -127,7 +141,7 @@ describe('void db studio --remote (PostgreSQL)', () => { } }); - it('fetches the database URL via getDatabaseUrl and calls writeDrizzleConfig with it', async () => { + it('fetches the database URL via getDatabaseUrl and keeps it out of the project directory', async () => { const root = createProjectRoot({ database: 'pg' }); const dbUrl = 'postgres://user:pass@host:5432/mydb'; mockGetDatabaseUrl.mockResolvedValue({ url: dbUrl }); @@ -135,7 +149,27 @@ describe('void db studio --remote (PostgreSQL)', () => { await runDbCommand(root, { action: 'studio', remote: true }); expect(mockGetDatabaseUrl).toHaveBeenCalledTimes(1); - expect(mockWriteDrizzleConfig).toHaveBeenCalledWith(root, dbUrl, 'postgresql'); + expect(mockWithPrivateDrizzleConfig).toHaveBeenCalledWith( + root, + dbUrl, + 'postgresql', + expect.any(Function), + ); + expect(mockWriteDrizzleConfig).not.toHaveBeenCalled(); + expectConfigOutsideRoot(root); + }); + + it('exits with code 1 when drizzle-kit studio fails', async () => { + const root = createProjectRoot({ database: 'pg' }); + mockGetDatabaseUrl.mockResolvedValue({ url: 'postgres://user:pass@host:5432/mydb' }); + mockExecFileSync.mockImplementationOnce(() => { + throw new Error('Command failed'); + }); + + await expect(runDbCommand(root, { action: 'studio', remote: true })).rejects.toThrow( + 'process.exit:1', + ); + expect(mockWithPrivateDrizzleConfig).toHaveBeenCalledTimes(1); }); it('spawns drizzle-kit studio --config ', async () => { @@ -173,8 +207,9 @@ describe('void db studio --remote (PostgreSQL)', () => { .map((c: Array) => String(c[0])) .join('\n'); expect(errorCalls).toContain('void db set-url'); - // writeDrizzleConfig and execFileSync must NOT have been called + // No config may be written and drizzle-kit must NOT have been spawned expect(mockWriteDrizzleConfig).not.toHaveBeenCalled(); + expect(mockWithPrivateDrizzleConfig).not.toHaveBeenCalled(); expect(mockExecFileSync).not.toHaveBeenCalled(); }); @@ -255,6 +290,7 @@ describe('void db studio --remote (D1)', () => { expect(errorCalls).toContain('void db execute --remote'); expect(mockWriteDrizzleConfig).not.toHaveBeenCalled(); + expect(mockWithPrivateDrizzleConfig).not.toHaveBeenCalled(); expect(mockExecFileSync).not.toHaveBeenCalled(); expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); }); @@ -294,6 +330,43 @@ describe('void db studio (local, regression)', () => { expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); }); + it('local PG studio keeps the .env database URL out of the project directory', async () => { + const root = createProjectRoot({ database: 'pg' }); + const dbUrl = 'postgres://local:pass@localhost:5432/dev'; + writeFileSync(join(root, '.env'), `DATABASE_URL=${dbUrl}\n`); + + await runDbCommand(root, { action: 'studio' }); + + expect(mockGetDatabaseUrl).not.toHaveBeenCalled(); + expect(mockWithPrivateDrizzleConfig).toHaveBeenCalledWith( + root, + dbUrl, + 'postgresql', + expect.any(Function), + ); + expect(mockWriteDrizzleConfig).not.toHaveBeenCalled(); + expectConfigOutsideRoot(root); + }); + + it('PG push keeps the .env database URL out of the project directory', async () => { + const root = createProjectRoot({ database: 'pg' }); + const dbUrl = 'postgres://local:pass@localhost:5432/dev'; + writeFileSync(join(root, '.env'), `DATABASE_URL=${dbUrl}\n`); + + await runDbCommand(root, { action: 'push' }); + + expect(mockWithPrivateDrizzleConfig).toHaveBeenCalledWith( + root, + dbUrl, + 'postgresql', + expect.any(Function), + ); + expect(mockWriteDrizzleConfig).not.toHaveBeenCalled(); + const cliArgs = (mockExecFileSync.mock.calls[0] as unknown as [string, Array])[1]; + expect(cliArgs).toContain('push'); + expectConfigOutsideRoot(root); + }); + it('local PG studio does not call getDatabaseUrl (uses local URL path)', async () => { const root = createProjectRoot({ database: 'pg' }); diff --git a/packages/void/test/unit/drizzle-config.test.ts b/packages/void/test/unit/drizzle-config.test.ts index 17cddc3c4..6a3aa61d8 100644 --- a/packages/void/test/unit/drizzle-config.test.ts +++ b/packages/void/test/unit/drizzle-config.test.ts @@ -1,9 +1,9 @@ -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, relative } from 'node:path'; import { toSlash } from 'pathslash'; import { describe, it, expect, beforeEach, afterEach } from 'vite-plus/test'; -import { writeDrizzleConfig } from '../../src/drizzle/config.ts'; +import { withPrivateDrizzleConfig, writeDrizzleConfig } from '../../src/drizzle/config.ts'; describe('writeDrizzleConfig', () => { let root: string; @@ -96,3 +96,77 @@ describe('writeDrizzleConfig', () => { expect(config.schema).toBe('./.void/better-auth-schema.ts'); }); }); + +describe('withPrivateDrizzleConfig', () => { + let root: string; + const dbUrl = 'postgres://user:secret@db.example.com:5432/app'; + + beforeEach(() => { + root = join(tmpdir(), `void-drizzle-private-${Date.now()}`); + mkdirSync(join(root, 'db'), { recursive: true }); + writeFileSync(join(root, 'db/schema.ts'), 'export const users = {};'); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('passes a config outside the project that holds the database URL', () => { + const result = withPrivateDrizzleConfig(root, dbUrl, 'postgresql', (configPath) => { + expect(relative(root, configPath).startsWith('..')).toBe(true); + const config = JSON.parse(readFileSync(configPath, 'utf-8')); + expect(config.dbCredentials.url).toBe(dbUrl); + expect(config.dialect).toBe('postgresql'); + expect(config.schema).toBe('./db/schema.ts'); + expect(config.out).toBe('./db/migrations'); + return 'ran'; + }); + expect(result).toBe('ran'); + expect(existsSync(join(root, '.void', 'drizzle.config.json'))).toBe(false); + }); + + it.skipIf(process.platform === 'win32')('writes the config readable by the owner only', () => { + withPrivateDrizzleConfig(root, dbUrl, 'postgresql', (configPath) => { + expect(statSync(configPath).mode & 0o777).toBe(0o600); + expect(statSync(dirname(configPath)).mode & 0o777).toBe(0o700); + }); + }); + + it('removes the config after run returns', () => { + let seen = ''; + withPrivateDrizzleConfig(root, dbUrl, 'postgresql', (configPath) => { + seen = configPath; + }); + expect(seen).not.toBe(''); + expect(existsSync(dirname(seen))).toBe(false); + }); + + it('removes the config after run throws', () => { + let seen = ''; + expect(() => + withPrivateDrizzleConfig(root, dbUrl, 'mysql', (configPath) => { + seen = configPath; + throw new Error('drizzle-kit failed'); + }), + ).toThrow('drizzle-kit failed'); + expect(seen).not.toBe(''); + expect(existsSync(dirname(seen))).toBe(false); + }); + + it('keeps a SIGINT listener only while run executes', () => { + const before = process.listenerCount('SIGINT'); + withPrivateDrizzleConfig(root, dbUrl, 'postgresql', () => { + expect(process.listenerCount('SIGINT')).toBe(before + 1); + }); + expect(process.listenerCount('SIGINT')).toBe(before); + }); + + it('still reports a missing schema', () => { + rmSync(join(root, 'db', 'schema.ts')); + const before = process.listenerCount('SIGINT'); + expect(() => withPrivateDrizzleConfig(root, dbUrl, 'postgresql', () => undefined)).toThrow( + 'No Drizzle schema found', + ); + expect(process.listenerCount('SIGINT')).toBe(before); + }); +}); From 705c2d706cd4c6dc93ffa06d80cd1f94a84f1789 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:14:47 +0800 Subject: [PATCH 14/51] Drop terminal control sequences from app logs printed by void logs Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/cli/cloudflare-operation-process.ts | 10 ++- packages/void/src/cli/logs.ts | 12 ++-- packages/void/src/cli/terminal-log-text.ts | 19 +++++ .../unit/cloudflare-operation-process.test.ts | 4 +- .../void/test/unit/logs-rendering.test.ts | 71 ++++++++++++++++++- 5 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 packages/void/src/cli/terminal-log-text.ts diff --git a/packages/void/src/cli/cloudflare-operation-process.ts b/packages/void/src/cli/cloudflare-operation-process.ts index 5b6905611..f45e63ddd 100644 --- a/packages/void/src/cli/cloudflare-operation-process.ts +++ b/packages/void/src/cli/cloudflare-operation-process.ts @@ -18,6 +18,7 @@ import { printCloudflareLoginLink, printCloudflareLoginPrompt, } from './cloudflare-login-output.ts'; +import { sanitizeTerminalLogText } from './terminal-log-text.ts'; type Result = { status: number; @@ -1180,20 +1181,23 @@ async function tail(): Promise { : typeof detail.mailFrom === 'string' ? `Email from ${detail.mailFrom}` : 'Worker event'; + // Request URLs, log lines and exceptions carry data from app visitors, + // so terminal control sequences are dropped before printing. process.stdout.write( - `${prefix} - ${log.outcome ?? 'unknown'} @ ${new Date(Number(log.eventTimestamp)).toLocaleString()}\n`, + `${sanitizeTerminalLogText(prefix)} - ${log.outcome ?? 'unknown'} @ ${new Date(Number(log.eventTimestamp)).toLocaleString()}\n`, ); if (Array.isArray(log.logs)) { for (const line of log.logs.map(object)) { + const message = Array.isArray(line.message) ? line.message.join(' ') : ''; process.stdout.write( - ` (${line.level ?? 'log'}) ${Array.isArray(line.message) ? line.message.join(' ') : ''}\n`, + ` (${line.level ?? 'log'}) ${sanitizeTerminalLogText(message)}\n`, ); } } if (Array.isArray(log.exceptions)) { for (const exception of log.exceptions.map(object)) { process.stderr.write( - ` ${exception.name ?? 'Error'}: ${exception.message ?? ''}\n`, + ` ${sanitizeTerminalLogText(`${exception.name ?? 'Error'}: ${exception.message ?? ''}`)}\n`, ); } } diff --git a/packages/void/src/cli/logs.ts b/packages/void/src/cli/logs.ts index c1f437a00..0dc9f0379 100644 --- a/packages/void/src/cli/logs.ts +++ b/packages/void/src/cli/logs.ts @@ -3,6 +3,7 @@ import { getToken } from './auth.ts'; import { PlatformClient, type LogEntry } from './client.ts'; import { dim, formatCount, pc } from './output.ts'; import { readDeploymentPlatform, readProjectConfig } from './project.ts'; +import { sanitizeTerminalLogText } from './terminal-log-text.ts'; export async function runLogsCommand( root: string, @@ -83,11 +84,12 @@ export async function runLogsCommand( outro(dim(formatCount(entries.length, 'log entry', 'log entries'))); } +// Logs carry data from app visitors, so terminal control sequences are dropped before printing. function printEntry(entry: LogEntry): void { const time = formatTime(entry.timestamp); - const method = entry.method || '-'; - const url = entry.url || '-'; - const status = entry.status || '-'; + const method = sanitizeTerminalLogText(entry.method || '-'); + const url = sanitizeTerminalLogText(entry.url || '-'); + const status = sanitizeTerminalLogText(entry.status || '-'); const statusNum = parseInt(status, 10); const coloredStatus = @@ -107,7 +109,7 @@ function printEntry(entry: LogEntry): void { ); for (const l of entry.logs) { - const text = l.message.map(String).join(' '); + const text = sanitizeTerminalLogText(l.message.map(String).join(' ')); const level = l.level ?? 'log'; const marker = level === 'error' @@ -124,7 +126,7 @@ function printEntry(entry: LogEntry): void { console.log( wrapTextWithPrefix( process.stdout, - `${ex.name}: ${ex.message}`, + sanitizeTerminalLogText(`${ex.name}: ${ex.message}`), ' '.repeat(14), ` ${pc.red('[exception]')} `, ), diff --git a/packages/void/src/cli/terminal-log-text.ts b/packages/void/src/cli/terminal-log-text.ts new file mode 100644 index 000000000..c4865a498 --- /dev/null +++ b/packages/void/src/cli/terminal-log-text.ts @@ -0,0 +1,19 @@ +/** + * Make app log text safe to print to a terminal. App logs often echo request + * data from anonymous visitors, so escape sequences in them must not reach the + * terminal as commands (clipboard writes, hyperlinks, screen clears, cursor + * moves or carriage-return overwrites). + * + * SGR color codes, newlines and tabs are kept so ordinary colored or + * multi-line logs read the same. Every other C0/C1 control character is + * dropped, which turns any other escape sequence into plain text. + * + * Keep this module free of imports: the Cloudflare operation process is a + * separate bundle entry that must stay small. + */ +export function sanitizeTerminalLogText(value: string): string { + // eslint-disable-next-line no-control-regex -- matching control characters is the point + return value.replace(/\u001b\[[0-9;]*m|[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (match) => + match.length > 1 ? match : '', + ); +} diff --git a/packages/void/test/unit/cloudflare-operation-process.test.ts b/packages/void/test/unit/cloudflare-operation-process.test.ts index 24355e4b8..8f88995af 100644 --- a/packages/void/test/unit/cloudflare-operation-process.test.ts +++ b/packages/void/test/unit/cloudflare-operation-process.test.ts @@ -1604,7 +1604,7 @@ it('renders binary and text frames from a real Node WebSocket in order and remov eventTimestamp: 1790000000000, outcome: 'ok', logs: [ - { level: 'log', message: ['binary marker'] }, + { level: 'log', message: ['binary marker \u001b]52;c;ZXZpbA==\u0007'] }, { level: 'error', message: ['error marker'] }, ], }, @@ -1667,6 +1667,8 @@ it('renders binary and text frames from a real Node WebSocket in order and remov expect(output.stderr).not.toContain('malformed tail'); expect(output.stdout).toContain('POST https://isolated.invalid/runtime - ok'); expect(output.stdout).toContain(' (log) binary marker'); + expect(output.stdout).not.toContain('\x1b]'); + expect(output.stdout).not.toContain('\x07'); expect(output.stdout).toContain(' (error) error marker'); expect(output.stdout).toContain('Queue owned-queue - ok'); expect(output.stdout.indexOf('binary marker')).toBeLessThan( diff --git a/packages/void/test/unit/logs-rendering.test.ts b/packages/void/test/unit/logs-rendering.test.ts index e4d9a6e29..746cf8ae5 100644 --- a/packages/void/test/unit/logs-rendering.test.ts +++ b/packages/void/test/unit/logs-rendering.test.ts @@ -3,6 +3,9 @@ import { stripVTControlCharacters } from 'node:util'; import { intro, outro } from '@voidzero-dev/vite-plus-prompts'; import { afterEach, expect, it, vi } from 'vite-plus/test'; import { runLogsCommand } from '../../src/cli/logs.ts'; +import { sanitizeTerminalLogText } from '../../src/cli/terminal-log-text.ts'; + +const logsFixture = vi.hoisted(() => ({ entries: undefined as Array | undefined })); vi.mock('../../src/cli/auth.ts', () => ({ getToken: () => 'fixture-token' })); vi.mock('../../src/cli/project.ts', () => ({ @@ -12,6 +15,9 @@ vi.mock('../../src/cli/project.ts', () => ({ vi.mock('../../src/cli/client.ts', () => ({ PlatformClient: class { async getLogs() { + if (logsFixture.entries) { + return logsFixture.entries; + } return [ { timestamp: 0, @@ -52,7 +58,10 @@ vi.mock('../../src/cli/output.ts', async (original) => { }; }); -afterEach(() => vi.restoreAllMocks()); +afterEach(() => { + logsFixture.entries = undefined; + vi.restoreAllMocks(); +}); it.each([40, 80])('aligns wrapped log messages and exceptions at %i columns', async (columns) => { const output = Object.assign( @@ -86,3 +95,63 @@ it.each([40, 80])('aligns wrapped log messages and exceptions at %i columns', as expect(lines.slice(1).every((line) => line.startsWith(' '.repeat(indent)))).toBe(true); } }); + +it('drops terminal control sequences from app log text but keeps colors and line breaks', async () => { + logsFixture.entries = [ + { + timestamp: 0, + method: 'GET', + url: '/search?q=\x1b]52;c;ZXZpbA==\x07', + status: '500', + outcome: 'exception', + logs: [ + { + level: 'error', + message: ['a\x1b]52;c;ZXZpbA==\x07b\x1b[2J\rc\n\x1b[31mred\x1b[0m'], + }, + ], + exceptions: [ + { + name: 'Error', + message: 'see \x1b]8;;https://evil.example\x07click\x1b]8;;\x07 then \u009b2J', + }, + ], + }, + ]; + const output = Object.assign( + new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }), + { columns: 120 }, + ); + vi.spyOn(process, 'stdout', 'get').mockReturnValue(output as typeof process.stdout); + const print = vi.spyOn(console, 'log').mockImplementation(() => {}); + await runLogsCommand('/fixture', '', '1h'); + + const printed = print.mock.calls.map((args) => String(args[0] ?? '')).join('\n'); + for (const unsafe of ['\x1b]', '\x1b[2J', '\r', '\x07', '\u009b']) { + expect(printed).not.toContain(unsafe); + } + expect(printed).toContain('\x1b[31mred'); + expect(printed).toContain('https://evil.example'); + const error = print.mock.calls + .map((args) => String(args[0] ?? '')) + .find((line) => line.includes('[error]'))!; + expect(stripVTControlCharacters(error).split('\n')).toEqual([ + ' [error] a]52;c;ZXZpbA==b[2Jc', + ' red', + ]); +}); + +it('keeps color codes, newlines and tabs, and drops other control characters', () => { + expect(sanitizeTerminalLogText('\x1b[1;38;2;255;0;0mbold\x1b[0m\tcol\nnext')).toBe( + '\x1b[1;38;2;255;0;0mbold\x1b[0m\tcol\nnext', + ); + expect(sanitizeTerminalLogText('a\x1b]52;c;ZXZpbA==\x07b')).toBe('a]52;c;ZXZpbA==b'); + expect(sanitizeTerminalLogText('x\x1b[2J\x1b[?25l\x1b[1Ay')).toBe('x[2J[?25l[1Ay'); + expect(sanitizeTerminalLogText('over\rwrite')).toBe('overwrite'); + expect(sanitizeTerminalLogText('\u009b2J\u0085\u007f\u0000\u0008end')).toBe('2Jend'); + expect(sanitizeTerminalLogText('plain text, ünïcode ✓')).toBe('plain text, ünïcode ✓'); +}); From 728ef20c7c5876b0e03a306f8f3b28122de94109 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:14:49 +0800 Subject: [PATCH 15/51] Send Analog dev trigger proxy requests to the bound dev server, not the request Host Co-Authored-By: Claude Opus 5.5 (1M context) --- .../void/src/plugin-framework-triggers.ts | 30 +++++++-- .../unit/plugin-framework-triggers.test.ts | 67 +++++++++++++++++++ 2 files changed, 91 insertions(+), 6 deletions(-) diff --git a/packages/void/src/plugin-framework-triggers.ts b/packages/void/src/plugin-framework-triggers.ts index c62d32bb9..4e506958c 100644 --- a/packages/void/src/plugin-framework-triggers.ts +++ b/packages/void/src/plugin-framework-triggers.ts @@ -471,6 +471,22 @@ export function analogDevTriggerInjectPlugin(root: string): Plugin { next(); return; } + // Target the address this dev server is bound to. The request Host + // header comes from the client and must not choose where the proxy + // sends the request. + const address = server.httpServer?.address(); + if (!address || typeof address !== 'object') { + next(); + return; + } + const hostname = + address.address === '0.0.0.0' + ? '127.0.0.1' + : address.address === '::' + ? 'localhost' + : address.address.includes(':') + ? `[${address.address}]` + : address.address; try { const chunks: Array = []; for await (const chunk of req) { @@ -485,12 +501,14 @@ export function analogDevTriggerInjectPlugin(root: string): Plugin { headers.set(key, value.join(', ')); } } - const host = req.headers.host ?? 'localhost'; - const upstream = await fetch(`http://${host}/api/__void/${match.file}`, { - method: 'POST', - headers, - body, - }); + const upstream = await fetch( + `http://${hostname}:${address.port}/api/__void/${match.file}`, + { + method: 'POST', + headers, + body, + }, + ); res.statusCode = upstream.status; upstream.headers.forEach((value, key) => res.setHeader(key, value)); res.end(Buffer.from(await upstream.arrayBuffer())); diff --git a/packages/void/test/unit/plugin-framework-triggers.test.ts b/packages/void/test/unit/plugin-framework-triggers.test.ts index beeeee053..14674549e 100644 --- a/packages/void/test/unit/plugin-framework-triggers.test.ts +++ b/packages/void/test/unit/plugin-framework-triggers.test.ts @@ -622,4 +622,71 @@ describe('analogDevTriggerInjectPlugin', () => { }); expect(registered).toBeGreaterThan(0); }); + + async function proxyTrigger(address: unknown) { + const root = createTempRoot(); + const plugin = analogDevTriggerInjectPlugin(root); + let proxy: ((req: unknown, res: unknown, next: (err?: unknown) => void) => unknown) | undefined; + // The temp root is removed after each test, so skip the process exit cleanup hook. + const once = vi.spyOn(process, 'once').mockReturnValue(process); + callConfigureServer(plugin, { + httpServer: { once() {}, address: () => address }, + middlewares: { + use(fn: typeof proxy) { + proxy = fn; + }, + }, + }); + once.mockRestore(); + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => new Response('ok', { status: 202 })); + const req = { + method: 'POST', + url: '/__void/scheduled', + headers: { host: 'evil.example/.localhost', 'content-type': 'application/json' }, + async *[Symbol.asyncIterator]() { + yield Buffer.from('{"cron":"* * * * *"}'); + }, + }; + const res = { + statusCode: 0, + body: undefined as Buffer | undefined, + setHeader() {}, + end(body: Buffer) { + res.body = body; + }, + }; + const next = vi.fn(); + await proxy!(req, res, next); + return { fetchSpy, res, next }; + } + + it('proxies to the bound dev server, not the request Host', async () => { + const { fetchSpy, res, next } = await proxyTrigger({ + address: '::', + family: 'IPv6', + port: 51234, + }); + expect(next).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy.mock.calls[0]![0]).toBe('http://localhost:51234/api/__void/cron-trigger'); + expect(res.statusCode).toBe(202); + expect(res.body?.toString()).toBe('ok'); + }); + + it.each([ + ['0.0.0.0', 'IPv4', 'http://127.0.0.1:51234/api/__void/cron-trigger'], + ['127.0.0.1', 'IPv4', 'http://127.0.0.1:51234/api/__void/cron-trigger'], + ['::1', 'IPv6', 'http://[::1]:51234/api/__void/cron-trigger'], + ])('proxies to the dev server bound to %s', async (bound, family, expected) => { + const { fetchSpy } = await proxyTrigger({ address: bound, family, port: 51234 }); + expect(fetchSpy.mock.calls[0]![0]).toBe(expected); + }); + + it('leaves trigger requests alone when the dev server is not listening', async () => { + const { fetchSpy, next } = await proxyTrigger(null); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalledWith(); + }); }); From 8a63850607dd02a28c8b57d14f48fc633a0031ca Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:14:51 +0800 Subject: [PATCH 16/51] Refuse prerender paths that resolve outside the output directory Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/void/src/prerender-render.ts | 23 +++++++++++-- .../void/test/unit/prerender-render.test.ts | 33 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/void/src/prerender-render.ts b/packages/void/src/prerender-render.ts index 33b50ba07..c386b2f14 100644 --- a/packages/void/src/prerender-render.ts +++ b/packages/void/src/prerender-render.ts @@ -1,5 +1,5 @@ import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname, join } from 'pathslash'; +import { dirname, isAbsolute, join, relative } from 'pathslash'; export type PrerenderResult = { rendered: Array; @@ -14,6 +14,12 @@ function pageDataFileForPath(path: string): string { return path === '/' ? '_void/pages/index.json' : `_void/pages/${path.slice(1)}.json`; } +/** Whether `path` resolves strictly inside `directory`. */ +function isInside(directory: string, path: string): boolean { + const child = relative(directory, path); + return child !== '' && child !== '..' && !child.startsWith('../') && !isAbsolute(child); +} + function normalizedFile(clientDir: string, path: string): string { return join(clientDir, path).normalize('NFC').toLowerCase(); } @@ -110,6 +116,19 @@ export async function renderPrerenderPaths( const path = paths[index]!; const result = results[index]!; const outputFile = outputFileForPath(path); + const outputPath = join(clientDir, outputFile); + const pageDataPath = join(clientDir, pageDataFileForPath(path)); + // Paths come from getPrerenderPaths and may include unencoded user data + // such as '../', which must not write files outside the output directory. + if ( + !isInside(clientDir, outputPath) || + !isInside(join(clientDir, '_void/pages'), pageDataPath) + ) { + const error = 'prerender path resolves outside the output directory'; + result.failed.push({ path, error }); + onResult?.(path, outputFile, error); + continue; + } try { const response = await fetchPath(path); const html = await response.text(); @@ -119,14 +138,12 @@ export async function renderPrerenderPaths( onResult?.(path, outputFile, error); continue; } - const outputPath = join(clientDir, outputFile); mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, html); if (fetchPageData) { const pageDataResponse = await fetchPageData(path); if (pageDataResponse.headers.get('X-VoidPages')) { const pageData = await pageDataResponse.json(); - const pageDataPath = join(clientDir, pageDataFileForPath(path)); mkdirSync(dirname(pageDataPath), { recursive: true }); writeFileSync(pageDataPath, JSON.stringify(pageData)); } diff --git a/packages/void/test/unit/prerender-render.test.ts b/packages/void/test/unit/prerender-render.test.ts index 8bd43ddbf..a2768233e 100644 --- a/packages/void/test/unit/prerender-render.test.ts +++ b/packages/void/test/unit/prerender-render.test.ts @@ -117,4 +117,37 @@ describe('prerender pool', () => { ); }, ); + + it('refuses prerender paths that escape the output directory', async () => { + const root = fixture(); + const clientDir = join(root, 'client'); + const fetched: Array = []; + const outputs: Array<[string, string, string | undefined]> = []; + const result = await renderPrerenderPaths( + ['/blog/../../escape', '/ok', '/../client/data-escape'], + clientDir, + async (path) => { + fetched.push(path); + return new Response('

page

'); + }, + async (path) => Response.json({ path }, { headers: { 'X-VoidPages': 'true' } }), + (path, outputFile, error) => outputs.push([path, outputFile, error]), + ); + const error = 'prerender path resolves outside the output directory'; + expect(result.rendered).toEqual(['/ok']); + expect(result.failed).toEqual([ + { path: '/blog/../../escape', error }, + { path: '/../client/data-escape', error }, + ]); + expect(fetched).toEqual(['/ok']); + expect(outputs).toContainEqual(['/blog/../../escape', 'blog/../../escape.html', error]); + expect(existsSync(join(root, 'escape.html'))).toBe(false); + expect(existsSync(join(clientDir, '_void/escape.json'))).toBe(false); + expect(existsSync(join(clientDir, 'data-escape.html'))).toBe(false); + expect(existsSync(join(clientDir, '_void/client/data-escape.json'))).toBe(false); + expect(readFileSync(join(clientDir, 'ok.html'), 'utf8')).toBe('

page

'); + expect(JSON.parse(readFileSync(join(clientDir, '_void/pages/ok.json'), 'utf8'))).toEqual({ + path: '/ok', + }); + }); }); From 26ba5617358ae8c7fa21acef6af1c14dc1777e2c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:14:53 +0800 Subject: [PATCH 17/51] Run the Svelte island style loader only during builds Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/svelte/src/plugin-islands.ts | 4 +++ .../svelte/test/unit/plugin-islands.test.ts | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/packages/svelte/src/plugin-islands.ts b/packages/svelte/src/plugin-islands.ts index ae8156bf4..617e6fcc8 100644 --- a/packages/svelte/src/plugin-islands.ts +++ b/packages/svelte/src/plugin-islands.ts @@ -268,6 +268,10 @@ export function islandsPlugin( }, { name: 'void-svelte:islands-style', + // Only the build's client entry imports `?island-style` (dev CSS comes + // from Void's dev CSS collector), so this raw file reader never needs + // to run on the dev server. + apply: 'build', resolveId: { filter: { id: /\?island-style&/ }, diff --git a/packages/svelte/test/unit/plugin-islands.test.ts b/packages/svelte/test/unit/plugin-islands.test.ts index a9c10e3e7..84ccd1c31 100644 --- a/packages/svelte/test/unit/plugin-islands.test.ts +++ b/packages/svelte/test/unit/plugin-islands.test.ts @@ -170,3 +170,30 @@ import Sidebar from "./Sidebar.svelte" with { island: "load" } expect(clientEntry).not.toContain('Sidebar'); }); }); + +describe('islandsPlugin — island style loader', () => { + beforeEach(() => { + mkdirSync(PAGES_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('island-style loader is build-only', () => { + const plugin = islandsPlugin(PAGES_DIR).find( + (candidate) => candidate.name === 'void-svelte:islands-style', + ); + expect(plugin?.apply).toBe('build'); + + writeFile( + 'Card.island.svelte', + `

Card

+ +`, + ); + const load = plugin!.load as HookWithHandler<(id: string) => string>; + const file = join(PAGES_DIR, 'Card.island.svelte'); + expect(load.handler(`${file}?island-style&index=1&lang.css`)).toBe('.second { color: blue; }'); + }); +}); From 107049676af97e2ea6c0660de11622665b4d3464 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:02:38 +0800 Subject: [PATCH 18/51] Show Hono cache() on a public route instead of as global middleware Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/integrations/hono.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/integrations/hono.md b/docs/integrations/hono.md index 8bf70acde..3a2279e71 100644 --- a/docs/integrations/hono.md +++ b/docs/integrations/hono.md @@ -25,13 +25,21 @@ export default defineMiddleware(cors({ origin: 'https://example.com' })); ``` ```ts -// middleware/02.cache.ts -import { defineMiddleware } from 'void'; +// routes/api/products.ts (per-route cache) +import { defineHandler } from 'void'; import { cache } from 'hono/cache'; +import { listProducts } from '../../lib/products'; -export default defineMiddleware(cache({ cacheName: 'my-app', cacheControl: 'max-age=3600' })); +export const GET = defineHandler( + cache({ cacheName: 'products', cacheControl: 'max-age=3600' }), + async () => ({ products: await listProducts() }), +); ``` +::: warning +`cache()` stores one response per URL and returns it to every visitor. It skips requests with an `Authorization` header, but not cookies. Use it only on routes whose response is the same for everyone, never on a route that reads the signed-in user. To cache pages, use [revalidation](../guide/edge/revalidation.md), which skips requests that carry a cookie. +::: + ```ts // routes/api/webhooks/stripe.ts (per-route usage) import { defineHandler } from 'void'; From 8bc5fe091a39c591ff29b17239fc9e52471e8108 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 01:02:39 +0800 Subject: [PATCH 19/51] Pick upload keys on the server and serve stored files as downloads in docs examples Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/guide/pages-routing/actions-and-forms.md | 24 ++++++++++++++----- docs/guide/storage.md | 9 +++++-- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/guide/pages-routing/actions-and-forms.md b/docs/guide/pages-routing/actions-and-forms.md index 0fef233dc..b7e0d6942 100644 --- a/docs/guide/pages-routing/actions-and-forms.md +++ b/docs/guide/pages-routing/actions-and-forms.md @@ -355,20 +355,32 @@ On the server, use `c.req.parseBody()` to access the uploaded file: ```ts // pages/photos.server.ts -import { defineHandler } from 'void'; +import { defineHandler, ValidationError } from 'void/handler'; import { storage } from 'void/storage'; +// file.name and file.type come from the browser. Don't use them as the key or trust the type. +const imageTypes = new Map([ + ['image/jpeg', 'jpg'], + ['image/png', 'png'], + ['image/webp', 'webp'], +]); + export const action = defineHandler(async (c) => { const body = await c.req.parseBody(); - const file = body['photo'] as File; - if (file && file.size > 0) { - await storage.put(file.name, file.stream(), { - httpMetadata: { contentType: file.type }, - }); + const file = body['photo']; + if (!(file instanceof File) || file.size === 0) return; + const ext = imageTypes.get(file.type); + if (!ext) { + throw new ValidationError({ photo: 'Upload a JPEG, PNG, or WebP image.' }); } + await storage.put(`uploads/${crypto.randomUUID()}.${ext}`, file.stream(), { + httpMetadata: { contentType: file.type }, + }); }); ``` +Pick the storage key on the server and allow only the file types you expect. See [Serving Files](../storage.md#serving-files) to send them back safely. + ## Choosing a Primitive | Primitive | Page update | Form state | Framework-specific | diff --git a/docs/guide/storage.md b/docs/guide/storage.md index 5dc659142..d0add4949 100644 --- a/docs/guide/storage.md +++ b/docs/guide/storage.md @@ -42,12 +42,12 @@ const head = await storage.head('uploads/photo.jpg'); A common pattern is serving uploaded files from an API route: ```ts +// routes/files/[key].ts import { defineHandler } from 'void'; import { storage } from 'void/storage'; export const GET = defineHandler(async (c) => { - const key = c.req.param('key'); - const object = await storage.get(key); + const object = await storage.get(`uploads/${c.req.param('key')}`); if (!object) { return c.notFound(); @@ -56,11 +56,16 @@ export const GET = defineHandler(async (c) => { const headers = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.httpEtag); + // Don't let an uploaded HTML or SVG file run as a page on your site. + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('Content-Disposition', 'attachment'); return new Response(object.body, { headers }); }); ``` +Files your users upload can contain HTML or scripts. Store them under keys your server picks, and serve them with `nosniff` and `Content-Disposition: attachment`. `` and `