From 3acefc5b05a40f40216abf6ee5e5e930726dcee8 Mon Sep 17 00:00:00 2001 From: John Ferlito Date: Wed, 28 Oct 2026 10:27:47 +1100 Subject: [PATCH] fix: limit CORS to the API Only /api gets CORS headers, so the OIDC provider keeps its own per-client CORS and future same-origin pages get none. Any origin may call it: auth is by bearer token, never cookies. Methods are trimmed to the ones the API serves, and preflights are cached for two hours. --- src/app.test.ts | 33 +++++++++++++++++++++++++++++++ src/app.ts | 52 +++++++++++++++++++++++++------------------------ 2 files changed, 60 insertions(+), 25 deletions(-) diff --git a/src/app.test.ts b/src/app.test.ts index 767fcd4..bacc6f5 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -18,6 +18,39 @@ describe('buildApp', () => { await app.close(); }); + it('allows any origin to call the API, without credentials', async () => { + const app = buildApp({ repository, search }); + const origin = 'https://elsewhere.example.org'; + + const preflight = await app.inject({ + method: 'OPTIONS', + url: `${config.prefix}/search`, + headers: { origin, 'access-control-request-method': 'POST', 'access-control-request-headers': 'authorization' }, + }); + expect(preflight.statusCode).toBe(204); + expect(preflight.headers).toMatchObject({ + 'access-control-allow-origin': '*', + 'access-control-allow-methods': 'GET, HEAD, POST', + 'access-control-max-age': '7200', + }); + expect(preflight.headers['access-control-allow-credentials']).toBeUndefined(); + + const response = await app.inject({ method: 'GET', url: `${config.prefix}/version`, headers: { origin } }); + expect(response.headers['access-control-allow-origin']).toBe('*'); + await app.close(); + }); + + it('sends no CORS headers outside the API', async () => { + const app = buildApp({ repository, search }); + const headers = { origin: 'https://elsewhere.example.org', 'access-control-request-method': 'POST' }; + + for (const url of ['/admin/', '/oauth/token']) { + const response = await app.inject({ method: 'OPTIONS', url, headers }); + expect(response.headers['access-control-allow-origin'], url).toBeUndefined(); + } + await app.close(); + }); + it('has no OIDC provider without an upstream provider', async () => { const app = buildApp({ repository, search }); diff --git a/src/app.ts b/src/app.ts index 16b7174..760b1e2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -39,31 +39,33 @@ export function buildApp({ repository, search }: AppDeps) { fastify.setErrorHandler(errorHandler); fastify.setNotFoundHandler(notFoundHandler); fastify.register(fastifySensible); - fastify.register(cors, { - methods: ['HEAD', 'GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], - }); - fastify.register(arocapi, { - prisma, - search, - access, - isAuthenticated, - entityVisibilityFilter: AllVisibleEntityFilter, - searchVisibilityFilter: AllVisibleSearchFilter, - entityTransformers: [ - (entity) => ({ - ...entity, - accessControl: 'Public', - counts: { - collections: 0, - objects: 0, - files: 0, - }, - }), - ], - storage: { metadata: EntityMetaMetadata(), content: repository.storage().content }, - prefix, - }); - fastify.register(ldacapi, { prefix, repository }); + fastify.register( + async (api) => { + api.register(cors, { origin: '*', methods: ['GET', 'HEAD', 'POST'], maxAge: 2 * 60 * 60 }); + api.register(arocapi, { + prisma, + search, + access, + isAuthenticated, + entityVisibilityFilter: AllVisibleEntityFilter, + searchVisibilityFilter: AllVisibleSearchFilter, + entityTransformers: [ + (entity) => ({ + ...entity, + accessControl: 'Public', + counts: { + collections: 0, + objects: 0, + files: 0, + }, + }), + ], + storage: { metadata: EntityMetaMetadata(), content: repository.storage().content }, + }); + api.register(ldacapi, { repository }); + }, + { prefix }, + ); if (config.auth) { fastify.register(oidcProvider, { auth: config.auth }); }