From 01f5fcf24d3413c54f79b165f7843c7aa4e323e1 Mon Sep 17 00:00:00 2001 From: metalisk Date: Mon, 28 Sep 2026 19:12:03 +0300 Subject: [PATCH 1/3] fix: add CORS origin rewrite middleware for handling opaque origins --- src/index.ts | 3 ++- src/security/cors.ts | 55 ++++++++++++++++++++++++++++++++++++++++++- test/security.test.ts | 46 +++++++++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index 1b9e43a..a9c2fb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import * as routers from './api/index.js' import { errorHandler, notFoundHandler } from './middleware/errorHandler.js' import { mountApiRoutes } from './security/accessPolicy.js' import { createApiKeyAuth } from './security/apiKey.js' -import { createCorsOriginDelegate } from './security/cors.js' +import { createCorsOriginDelegate, createCorsOriginRewriteMiddleware } from './security/cors.js' import { parseTrustProxy } from './security/trustProxy.js' import { setStartupReconciliationResult, @@ -139,6 +139,7 @@ if (trustProxy === false) { app.use(httpLogger) app.use(collectHttpMetrics) +app.use(createCorsOriginRewriteMiddleware(config.cors.allowedOrigins)) app.use( cors({ origin: createCorsOriginDelegate(config.cors.allowedOrigins), diff --git a/src/security/cors.ts b/src/security/cors.ts index a80fc45..7b38aa3 100644 --- a/src/security/cors.ts +++ b/src/security/cors.ts @@ -1,4 +1,5 @@ import type { CorsOptions } from 'cors' +import type { NextFunction, Request, Response } from 'express' type OriginRule = { protocol: string @@ -102,7 +103,59 @@ export function createCorsOriginDelegate(allowedOrigins: unknown): CorsOptions[' const matchesOrigin = createOriginMatcher(allowedOrigins) return (origin, callback): void => { - callback(null, origin === undefined || matchesOrigin(origin)) + if (origin === undefined) { + callback(null, true) + return + } + + // Referer rewrite runs first; a remaining `Origin: null` is usually Tor + // cross-`.onion` while the document origin is the PWA onion. Browsers reject + // `Access-Control-Allow-Origin: null` in that case. Public routes use + // `credentials: false`, so `*` is valid. True opaque origins are uncommon here. + if (origin === 'null') { + callback(null, '*') + return + } + + callback(null, matchesOrigin(origin) ? origin : false) + } +} + +/** + * Tor Browser may send `Origin: null` on cross-`.onion` fetches while the document + * origin remains the PWA hidden service. Browsers compare `Access-Control-Allow-Origin` + * to the document origin, so reflect the `Referer` origin when it is allowlisted. + * + * @param allowedOrigins browser origins accepted by the API + * @returns Express middleware that runs before `cors` + */ +export function createCorsOriginRewriteMiddleware( + allowedOrigins: unknown +): (req: Request, _res: Response, next: NextFunction) => void { + const matchesOrigin = createOriginMatcher(allowedOrigins) + + return (req, _res, next): void => { + if (req.headers.origin !== 'null') { + next() + return + } + + const referer = req.headers.referer + if (typeof referer !== 'string') { + next() + return + } + + try { + const refererOrigin = new URL(referer).origin + if (matchesOrigin(refererOrigin)) { + req.headers.origin = refererOrigin + } + } catch { + // ignore malformed Referer + } + + next() } } diff --git a/test/security.test.ts b/test/security.test.ts index 5c26a4d..9209827 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -7,7 +7,11 @@ import multer from 'multer' import { mountApiRoutes } from '../src/security/accessPolicy.js' import { createApiKeyAuth } from '../src/security/apiKey.js' import { validateSecurityConfig } from '../src/security/config.js' -import { createCorsOriginDelegate, createOriginMatcher } from '../src/security/cors.js' +import { + createCorsOriginDelegate, + createCorsOriginRewriteMiddleware, + createOriginMatcher +} from '../src/security/cors.js' import { getPublicError, InvalidRequestError } from '../src/security/errors.js' import { createRateLimiter } from '../src/security/rateLimit.js' import { parseTrustProxy } from '../src/security/trustProxy.js' @@ -109,7 +113,7 @@ describe('CORS origin policy', () => { } }) - it('reflects Access-Control-Allow-Origin: null for the opaque origin', async () => { + it('reflects Access-Control-Allow-Origin * when the browser sends Origin null', async () => { const app = express() app.use(cors({ origin: createCorsOriginDelegate(['null']) })) app.get('/api/node/info', (req, res) => res.send({ ok: true })) @@ -117,7 +121,43 @@ describe('CORS origin policy', () => { try { const response = await fetch(`${server.url}/api/node/info`, { headers: { origin: 'null' } }) - assert.equal(response.headers.get('access-control-allow-origin'), 'null') + assert.equal(response.headers.get('access-control-allow-origin'), '*') + } finally { + await server.close() + } + }) + + it('uses Access-Control-Allow-Origin * when Origin is null without Referer', async () => { + const app = express() + app.use(cors({ origin: createCorsOriginDelegate(['http://*.onion']) })) + app.get('/api/node/info', (req, res) => res.send({ ok: true })) + const server = await startServer(app) + + try { + const response = await fetch(`${server.url}/api/node/info`, { headers: { origin: 'null' } }) + assert.equal(response.headers.get('access-control-allow-origin'), '*') + } finally { + await server.close() + } + }) + + it('rewrites Origin null to an allowlisted Referer origin for Tor cross-onion calls', async () => { + const allowed = ['http://*.onion', 'null'] + const pwa = 'http://adamant6457join2rxdkr2y7iqatar7n4n72lordxeknj435i4cjhpyd.onion' + const app = express() + app.use(createCorsOriginRewriteMiddleware(allowed)) + app.use(cors({ origin: createCorsOriginDelegate(allowed) })) + app.get('/api/node/info', (req, res) => res.send({ ok: true })) + const server = await startServer(app) + + try { + const response = await fetch(`${server.url}/api/node/info`, { + headers: { + origin: 'null', + referer: `${pwa}/options/nodes` + } + }) + assert.equal(response.headers.get('access-control-allow-origin'), pwa) } finally { await server.close() } From 73cbc7f7d49370116d8a1ec29033b217d4a307c8 Mon Sep 17 00:00:00 2001 From: metalisk Date: Mon, 28 Sep 2026 19:20:59 +0300 Subject: [PATCH 2/3] fix: gate opaque Origin null on config and drop Referer rewrite Only emit Access-Control-Allow-Origin * when the literal null entry is allowlisted; do not rewrite Origin from Referer, which broke redirect flows. Document * response semantics for opted-in opaque origins. Co-authored-by: Cursor --- docs/guide/security.md | 8 +++++-- src/index.ts | 3 +-- src/security/cors.ts | 49 +++++------------------------------------- test/security.test.ts | 42 +++++++++++++++++++++++------------- 4 files changed, 39 insertions(+), 63 deletions(-) diff --git a/docs/guide/security.md b/docs/guide/security.md index 71db9f9..ba14782 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -227,8 +227,12 @@ an explicit opt-in rather than a default. Some browsers, including Tor Browser on certain cross-`.onion` fetches, send the header `Origin: null`. The configuration entry is the literal four-character string `null`, not JSON -null. Enabling it tells the CORS middleware to reflect `Access-Control-Allow-Origin: null` for -those requests. +null. Enabling it tells the CORS middleware to respond with `Access-Control-Allow-Origin: *` for +those requests (not the literal header value `null`). Tor Browser cross-`.onion` fetches often +send `Origin: null` while the document origin is an onion URL; reflecting `null` fails the browser +CORS check in that case. With `credentials: false`, `*` is valid. Redirect and other flows that +keep `Origin: null` while a normal origin appears only in `Referer` must not rewrite the request +origin server-side; the browser compares ACAO to the request origin, not to `Referer`. **This permission is not limited to Tor.** Any opaque browser origin serializes as `null`. That includes `data:` documents, some `file:` documents, and a cross-origin diff --git a/src/index.ts b/src/index.ts index a9c2fb0..1b9e43a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import * as routers from './api/index.js' import { errorHandler, notFoundHandler } from './middleware/errorHandler.js' import { mountApiRoutes } from './security/accessPolicy.js' import { createApiKeyAuth } from './security/apiKey.js' -import { createCorsOriginDelegate, createCorsOriginRewriteMiddleware } from './security/cors.js' +import { createCorsOriginDelegate } from './security/cors.js' import { parseTrustProxy } from './security/trustProxy.js' import { setStartupReconciliationResult, @@ -139,7 +139,6 @@ if (trustProxy === false) { app.use(httpLogger) app.use(collectHttpMetrics) -app.use(createCorsOriginRewriteMiddleware(config.cors.allowedOrigins)) app.use( cors({ origin: createCorsOriginDelegate(config.cors.allowedOrigins), diff --git a/src/security/cors.ts b/src/security/cors.ts index 7b38aa3..cf22f8c 100644 --- a/src/security/cors.ts +++ b/src/security/cors.ts @@ -1,5 +1,4 @@ import type { CorsOptions } from 'cors' -import type { NextFunction, Request, Response } from 'express' type OriginRule = { protocol: string @@ -108,12 +107,12 @@ export function createCorsOriginDelegate(allowedOrigins: unknown): CorsOptions[' return } - // Referer rewrite runs first; a remaining `Origin: null` is usually Tor - // cross-`.onion` while the document origin is the PWA onion. Browsers reject - // `Access-Control-Allow-Origin: null` in that case. Public routes use - // `credentials: false`, so `*` is valid. True opaque origins are uncommon here. if (origin === 'null') { - callback(null, '*') + // Opt-in only via the literal `null` entry. Tor cross-`.onion` fetches often + // send this header while the document origin is an onion URL; reflecting + // `Access-Control-Allow-Origin: null` fails the browser CORS check in that + // case. Public routes use `credentials: false`, so `*` is valid when opted in. + callback(null, matchesOrigin('null') ? '*' : false) return } @@ -121,44 +120,6 @@ export function createCorsOriginDelegate(allowedOrigins: unknown): CorsOptions[' } } -/** - * Tor Browser may send `Origin: null` on cross-`.onion` fetches while the document - * origin remains the PWA hidden service. Browsers compare `Access-Control-Allow-Origin` - * to the document origin, so reflect the `Referer` origin when it is allowlisted. - * - * @param allowedOrigins browser origins accepted by the API - * @returns Express middleware that runs before `cors` - */ -export function createCorsOriginRewriteMiddleware( - allowedOrigins: unknown -): (req: Request, _res: Response, next: NextFunction) => void { - const matchesOrigin = createOriginMatcher(allowedOrigins) - - return (req, _res, next): void => { - if (req.headers.origin !== 'null') { - next() - return - } - - const referer = req.headers.referer - if (typeof referer !== 'string') { - next() - return - } - - try { - const refererOrigin = new URL(referer).origin - if (matchesOrigin(refererOrigin)) { - req.headers.origin = refererOrigin - } - } catch { - // ignore malformed Referer - } - - next() - } -} - function parseOriginRule(value: unknown): OriginRule { if (typeof value !== 'string' || value.length === 0 || value.length > 255) { throw new Error('Each CORS origin must be a non-empty string of at most 255 characters') diff --git a/test/security.test.ts b/test/security.test.ts index 9209827..eda3e6c 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -7,11 +7,7 @@ import multer from 'multer' import { mountApiRoutes } from '../src/security/accessPolicy.js' import { createApiKeyAuth } from '../src/security/apiKey.js' import { validateSecurityConfig } from '../src/security/config.js' -import { - createCorsOriginDelegate, - createCorsOriginRewriteMiddleware, - createOriginMatcher -} from '../src/security/cors.js' +import { createCorsOriginDelegate, createOriginMatcher } from '../src/security/cors.js' import { getPublicError, InvalidRequestError } from '../src/security/errors.js' import { createRateLimiter } from '../src/security/rateLimit.js' import { parseTrustProxy } from '../src/security/trustProxy.js' @@ -113,7 +109,7 @@ describe('CORS origin policy', () => { } }) - it('reflects Access-Control-Allow-Origin * when the browser sends Origin null', async () => { + it('reflects Access-Control-Allow-Origin * when null is configured and Origin is null', async () => { const app = express() app.use(cors({ origin: createCorsOriginDelegate(['null']) })) app.get('/api/node/info', (req, res) => res.send({ ok: true })) @@ -127,7 +123,21 @@ describe('CORS origin policy', () => { } }) - it('uses Access-Control-Allow-Origin * when Origin is null without Referer', async () => { + it('omits Access-Control-Allow-Origin when Origin is null and null is not configured', async () => { + const app = express() + app.use(cors({ origin: createCorsOriginDelegate(['https://app.example.org']) })) + app.get('/api/node/info', (req, res) => res.send({ ok: true })) + const server = await startServer(app) + + try { + const response = await fetch(`${server.url}/api/node/info`, { headers: { origin: 'null' } }) + assert.equal(response.headers.get('access-control-allow-origin'), null) + } finally { + await server.close() + } + }) + + it('omits Access-Control-Allow-Origin for Origin null when only onion wildcards are configured', async () => { const app = express() app.use(cors({ origin: createCorsOriginDelegate(['http://*.onion']) })) app.get('/api/node/info', (req, res) => res.send({ ok: true })) @@ -135,18 +145,20 @@ describe('CORS origin policy', () => { try { const response = await fetch(`${server.url}/api/node/info`, { headers: { origin: 'null' } }) - assert.equal(response.headers.get('access-control-allow-origin'), '*') + assert.equal(response.headers.get('access-control-allow-origin'), null) } finally { await server.close() } }) - it('rewrites Origin null to an allowlisted Referer origin for Tor cross-onion calls', async () => { - const allowed = ['http://*.onion', 'null'] - const pwa = 'http://adamant6457join2rxdkr2y7iqatar7n4n72lordxeknj435i4cjhpyd.onion' + it('does not reflect Referer as ACAO when Origin is null and null is configured', async () => { const app = express() - app.use(createCorsOriginRewriteMiddleware(allowed)) - app.use(cors({ origin: createCorsOriginDelegate(allowed) })) + app.use( + cors({ + origin: createCorsOriginDelegate(['https://adm.im', 'null']), + credentials: false + }) + ) app.get('/api/node/info', (req, res) => res.send({ ok: true })) const server = await startServer(app) @@ -154,10 +166,10 @@ describe('CORS origin policy', () => { const response = await fetch(`${server.url}/api/node/info`, { headers: { origin: 'null', - referer: `${pwa}/options/nodes` + referer: 'https://adm.im/redirected-here' } }) - assert.equal(response.headers.get('access-control-allow-origin'), pwa) + assert.equal(response.headers.get('access-control-allow-origin'), '*') } finally { await server.close() } From 958c910561dcb10539bf83b06d9552de60a26739 Mon Sep 17 00:00:00 2001 From: metalisk Date: Mon, 28 Sep 2026 19:32:13 +0300 Subject: [PATCH 3/3] fix: set Vary Origin before CORS for opaque null responses The cors package skips Vary when ACAO is *, but download responses use private caching. Vary Origin keeps per-origin CORS headers distinct. Co-authored-by: Cursor --- docs/guide/security.md | 2 ++ src/index.ts | 3 ++- src/security/cors.ts | 20 ++++++++++++++++++++ test/security.test.ts | 40 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 63 insertions(+), 2 deletions(-) diff --git a/docs/guide/security.md b/docs/guide/security.md index ba14782..2291087 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -233,6 +233,8 @@ send `Origin: null` while the document origin is an onion URL; reflecting `null` CORS check in that case. With `credentials: false`, `*` is valid. Redirect and other flows that keep `Origin: null` while a normal origin appears only in `Referer` must not rewrite the request origin server-side; the browser compares ACAO to the request origin, not to `Referer`. +The node also sets `Vary: Origin` on every API response before CORS runs so private caches +(including on file downloads) cannot store one origin's `*` response and serve it to another. **This permission is not limited to Tor.** Any opaque browser origin serializes as `null`. That includes `data:` documents, some `file:` documents, and a cross-origin diff --git a/src/index.ts b/src/index.ts index 1b9e43a..c24b16f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,7 @@ import * as routers from './api/index.js' import { errorHandler, notFoundHandler } from './middleware/errorHandler.js' import { mountApiRoutes } from './security/accessPolicy.js' import { createApiKeyAuth } from './security/apiKey.js' -import { createCorsOriginDelegate } from './security/cors.js' +import { createCorsOriginDelegate, varyOriginForCorsMiddleware } from './security/cors.js' import { parseTrustProxy } from './security/trustProxy.js' import { setStartupReconciliationResult, @@ -139,6 +139,7 @@ if (trustProxy === false) { app.use(httpLogger) app.use(collectHttpMetrics) +app.use(varyOriginForCorsMiddleware()) app.use( cors({ origin: createCorsOriginDelegate(config.cors.allowedOrigins), diff --git a/src/security/cors.ts b/src/security/cors.ts index cf22f8c..192e932 100644 --- a/src/security/cors.ts +++ b/src/security/cors.ts @@ -1,4 +1,5 @@ import type { CorsOptions } from 'cors' +import type { NextFunction, Request, Response } from 'express' type OriginRule = { protocol: string @@ -91,6 +92,25 @@ export function createOriginMatcher(allowedOrigins: unknown): (origin: string) = } } +/** + * Mark responses as varying on `Origin` before the `cors` middleware runs. The + * package omits `Vary: Origin` when it emits `Access-Control-Allow-Origin: *`, + * but ACAO still depends on the request origin. Private download caching needs + * the header so caches do not reuse a `*` response for a disallowed origin. + * + * @returns Express middleware to register ahead of `cors()` + */ +export function varyOriginForCorsMiddleware(): ( + req: Request, + res: Response, + next: NextFunction +) => void { + return (_req, res, next): void => { + res.vary('Origin') + next() + } +} + /** * Create the callback used by the Express CORS middleware. Requests without an * Origin header are non-browser requests and are allowed. diff --git a/test/security.test.ts b/test/security.test.ts index eda3e6c..d792ff7 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -7,7 +7,12 @@ import multer from 'multer' import { mountApiRoutes } from '../src/security/accessPolicy.js' import { createApiKeyAuth } from '../src/security/apiKey.js' import { validateSecurityConfig } from '../src/security/config.js' -import { createCorsOriginDelegate, createOriginMatcher } from '../src/security/cors.js' +import { + createCorsOriginDelegate, + createOriginMatcher, + varyOriginForCorsMiddleware +} from '../src/security/cors.js' +import { setDownloadHeaders } from '../src/utils/downloadResponse.js' import { getPublicError, InvalidRequestError } from '../src/security/errors.js' import { createRateLimiter } from '../src/security/rateLimit.js' import { parseTrustProxy } from '../src/security/trustProxy.js' @@ -151,6 +156,39 @@ describe('CORS origin policy', () => { } }) + it('sets Vary Origin on private-cached downloads when opaque null gets ACAO *', async () => { + const app = express() + app.use(varyOriginForCorsMiddleware()) + app.use( + cors({ + origin: createCorsOriginDelegate(['https://adm.im', 'null']), + credentials: false + }) + ) + app.get('/api/file/download/bafytest', (req, res) => { + setDownloadHeaders(res, { cid: 'bafytest', fileSize: BigInt(4) }) + res.send('data') + }) + const server = await startServer(app) + + try { + const opaque = await fetch(`${server.url}/api/file/download/bafytest`, { + headers: { origin: 'null' } + }) + const disallowed = await fetch(`${server.url}/api/file/download/bafytest`, { + headers: { origin: 'https://evil.example' } + }) + + assert.equal(opaque.headers.get('access-control-allow-origin'), '*') + assert.match(opaque.headers.get('vary') ?? '', /origin/i) + assert.match(opaque.headers.get('cache-control') ?? '', /private/) + assert.equal(disallowed.headers.get('access-control-allow-origin'), null) + assert.match(disallowed.headers.get('vary') ?? '', /origin/i) + } finally { + await server.close() + } + }) + it('does not reflect Referer as ACAO when Origin is null and null is configured', async () => { const app = express() app.use(