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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions docs/guide/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -139,6 +139,7 @@ if (trustProxy === false) {
app.use(httpLogger)
app.use(collectHttpMetrics)

app.use(varyOriginForCorsMiddleware())
app.use(
cors({
origin: createCorsOriginDelegate(config.cors.allowedOrigins),
Expand Down
36 changes: 35 additions & 1 deletion src/security/cors.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CorsOptions } from 'cors'
import type { NextFunction, Request, Response } from 'express'

type OriginRule = {
protocol: string
Expand Down Expand Up @@ -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.
Expand All @@ -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)
}
}

Expand Down
96 changes: 93 additions & 3 deletions test/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -109,15 +114,100 @@ 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 }))
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')
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()
}
Expand Down
Loading