diff --git a/docs/guide/security.md b/docs/guide/security.md index 71db9f9..2291087 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -227,8 +227,14 @@ 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`. +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 a80fc45..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. @@ -102,7 +122,21 @@ 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 + } + + if (origin === '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 + } + + callback(null, matchesOrigin(origin) ? origin : false) } } diff --git a/test/security.test.ts b/test/security.test.ts index 5c26a4d..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' @@ -109,7 +114,7 @@ describe('CORS origin policy', () => { } }) - it('reflects Access-Control-Allow-Origin: null for the opaque origin', 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 })) @@ -117,7 +122,92 @@ 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('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 })) + 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('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( + 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) + + try { + const response = await fetch(`${server.url}/api/node/info`, { + headers: { + origin: 'null', + referer: 'https://adm.im/redirected-here' + } + }) + assert.equal(response.headers.get('access-control-allow-origin'), '*') } finally { await server.close() }