From 9e591a7ed92ffa9c833a9ee7b024fbc0c4127f18 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Mon, 31 Aug 2026 15:09:07 +0200 Subject: [PATCH 1/7] fix(bff): let a key with allowedOrigins serve non-browser clients --- packages/agent-bff/README.md | 7 ++++-- packages/agent-bff/src/cors/per-key-origin.ts | 3 ++- .../test/cors/per-key-origin.test.ts | 24 +++++++++++++++++-- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index ab545eea70..1f033da571 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -142,8 +142,11 @@ normalized away, no trailing slash, no wildcard, no subdomain matching): blocks). Applies to `POST /oauth/token` too. Preflight `OPTIONS` from an allow-listed origin gets the allowed methods + headers; credentials are never enabled. - **Layer 2 (per-key authorization, Mode 2 only)** — when the resolved key has a non-empty - `allowedOrigins`, the request `Origin` must also be in that list (a missing `Origin` is rejected), - else `403 origin_not_allowed`. An empty per-key list is a no-op. + `allowedOrigins`, an `Origin` sent by the client must be in that list, else + `403 origin_not_allowed`. A request with no `Origin` at all — a server-side client such as curl, + a Node backend or CI, an empty header counting as none — passes: there the API key is the + boundary, not the origin. An opaque `Origin: null` (sandboxed iframe, cross-origin redirect) is a + present origin and is rejected. An empty per-key list is a no-op. **Local development:** browsers still enforce CORS against `localhost`, so add your dev origin(s) to `BFF_ALLOWED_ORIGINS` (e.g. `BFF_ALLOWED_ORIGINS=http://localhost:4200`) — there is no dev bypass. diff --git a/packages/agent-bff/src/cors/per-key-origin.ts b/packages/agent-bff/src/cors/per-key-origin.ts index 7f6fcfc7ee..c57acd7424 100644 --- a/packages/agent-bff/src/cors/per-key-origin.ts +++ b/packages/agent-bff/src/cors/per-key-origin.ts @@ -7,8 +7,9 @@ export default function createPerKeyOriginMiddleware(): Middleware { return async function perKeyOriginMiddleware(ctx, next) { const identity = ctx.state.apiKeyIdentity as { allowedOrigins?: string[] } | undefined; const allowedOrigins = identity?.allowedOrigins ?? []; + const origin = ctx.get('Origin'); - if (allowedOrigins.length > 0 && !originAllowed(ctx.get('Origin'), allowedOrigins)) { + if (allowedOrigins.length > 0 && origin && !originAllowed(origin, allowedOrigins)) { throw originNotAllowed(); } diff --git a/packages/agent-bff/test/cors/per-key-origin.test.ts b/packages/agent-bff/test/cors/per-key-origin.test.ts index f056ddb8ec..1d5961c8e5 100644 --- a/packages/agent-bff/test/cors/per-key-origin.test.ts +++ b/packages/agent-bff/test/cors/per-key-origin.test.ts @@ -58,11 +58,31 @@ describe('per-key origin middleware (layer 2)', () => { expect(response.status).toBe(200); }); - it('returns 403 when the per-key list is non-empty and the request has no Origin', async () => { + it('proceeds when the per-key list is non-empty and the request has no Origin', async () => { const response = await request(buildApp(['https://a.com']).callback()).get('/agent/x'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ reached: true }); + }); + + it('returns 403 when the per-key list is non-empty and the Origin is the opaque null', async () => { + const response = await request(buildApp(['https://a.com']).callback()) + .get('/agent/x') + .set('Origin', 'null'); + expect(response.status).toBe(403); - expect(response.body.error.type).toBe('origin_not_allowed'); + expect(response.body).toEqual({ + error: { type: 'origin_not_allowed', status: 403, message: expect.any(String) }, + }); + }); + + it('treats an empty Origin header like an absent one and proceeds', async () => { + const response = await request(buildApp(['https://a.com']).callback()) + .get('/agent/x') + .set('Origin', ''); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ reached: true }); }); it('does not restrict an oauth request, which carries no per-key identity', async () => { From 181c899d7fb47973b133ce48f8c11333fd39848e Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Mon, 31 Aug 2026 15:55:20 +0200 Subject: [PATCH 2/7] refactor(bff): extract hasOrigin and pin malformed origin rejection --- .../agent-bff/src/cors/cors-middleware.ts | 6 ++--- packages/agent-bff/src/cors/origin.ts | 4 +++ packages/agent-bff/src/cors/per-key-origin.ts | 4 +-- packages/agent-bff/test/cors/origin.test.ts | 18 ++++++++++++- .../test/cors/per-key-origin.test.ts | 25 +++++++++++++------ 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/packages/agent-bff/src/cors/cors-middleware.ts b/packages/agent-bff/src/cors/cors-middleware.ts index b737af31d5..db05f03b82 100644 --- a/packages/agent-bff/src/cors/cors-middleware.ts +++ b/packages/agent-bff/src/cors/cors-middleware.ts @@ -1,6 +1,6 @@ import type { Middleware } from 'koa'; -import { originAllowed } from './origin'; +import { hasOrigin, originAllowed } from './origin'; export const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; export const ALLOWED_HEADERS = @@ -17,9 +17,9 @@ export default function createCorsMiddleware({ return async function corsMiddleware(ctx, next) { const origin = ctx.get('Origin'); - if (origin) ctx.vary('Origin'); + if (hasOrigin(origin)) ctx.vary('Origin'); - const allowed = origin ? originAllowed(origin, allowedOrigins) : false; + const allowed = hasOrigin(origin) && originAllowed(origin, allowedOrigins); if (allowed) ctx.set('Access-Control-Allow-Origin', origin); diff --git a/packages/agent-bff/src/cors/origin.ts b/packages/agent-bff/src/cors/origin.ts index 967c62cf10..008b76578c 100644 --- a/packages/agent-bff/src/cors/origin.ts +++ b/packages/agent-bff/src/cors/origin.ts @@ -1,3 +1,7 @@ +export function hasOrigin(raw: string): boolean { + return raw !== ''; +} + export function normalizeOrigin(raw: string | undefined | null): string | null { if (raw === undefined || raw === null) return null; diff --git a/packages/agent-bff/src/cors/per-key-origin.ts b/packages/agent-bff/src/cors/per-key-origin.ts index c57acd7424..a3f1656d68 100644 --- a/packages/agent-bff/src/cors/per-key-origin.ts +++ b/packages/agent-bff/src/cors/per-key-origin.ts @@ -1,6 +1,6 @@ import type { Middleware } from 'koa'; -import { originAllowed } from './origin'; +import { hasOrigin, originAllowed } from './origin'; import { originNotAllowed } from '../http/bff-http-error'; export default function createPerKeyOriginMiddleware(): Middleware { @@ -9,7 +9,7 @@ export default function createPerKeyOriginMiddleware(): Middleware { const allowedOrigins = identity?.allowedOrigins ?? []; const origin = ctx.get('Origin'); - if (allowedOrigins.length > 0 && origin && !originAllowed(origin, allowedOrigins)) { + if (allowedOrigins.length > 0 && hasOrigin(origin) && !originAllowed(origin, allowedOrigins)) { throw originNotAllowed(); } diff --git a/packages/agent-bff/test/cors/origin.test.ts b/packages/agent-bff/test/cors/origin.test.ts index 94d912888b..e2628a8cd8 100644 --- a/packages/agent-bff/test/cors/origin.test.ts +++ b/packages/agent-bff/test/cors/origin.test.ts @@ -1,4 +1,20 @@ -import { normalizeOrigin, originAllowed, parseAllowedOrigins } from '../../src/cors/origin'; +import { + hasOrigin, + normalizeOrigin, + originAllowed, + parseAllowedOrigins, +} from '../../src/cors/origin'; + +describe('hasOrigin', () => { + it('is false for the empty string koa returns when the header is absent', () => { + expect(hasOrigin('')).toBe(false); + }); + + it('is true for any present value, even an opaque or malformed one', () => { + expect(hasOrigin('null')).toBe(true); + expect(hasOrigin('not a url')).toBe(true); + }); +}); describe('normalizeOrigin', () => { it.each([ diff --git a/packages/agent-bff/test/cors/per-key-origin.test.ts b/packages/agent-bff/test/cors/per-key-origin.test.ts index 1d5961c8e5..f4f4a7c6ed 100644 --- a/packages/agent-bff/test/cors/per-key-origin.test.ts +++ b/packages/agent-bff/test/cors/per-key-origin.test.ts @@ -21,6 +21,13 @@ function buildApp(allowedOrigins?: string[]) { return app; } +function expectOriginForbidden(response: request.Response) { + expect(response.status).toBe(403); + expect(response.body).toEqual({ + error: { type: 'origin_not_allowed', status: 403, message: expect.any(String) }, + }); +} + describe('per-key origin middleware (layer 2)', () => { it('proceeds when the origin is in the per-key list', async () => { const response = await request(buildApp(['https://a.com']).callback()) @@ -36,10 +43,7 @@ describe('per-key origin middleware (layer 2)', () => { .get('/agent/x') .set('Origin', 'https://b.com'); - expect(response.status).toBe(403); - expect(response.body).toEqual({ - error: { type: 'origin_not_allowed', status: 403, message: expect.any(String) }, - }); + expectOriginForbidden(response); }); it('matches a per-key origin returned by SaaS in a non-normalized form', async () => { @@ -70,10 +74,15 @@ describe('per-key origin middleware (layer 2)', () => { .get('/agent/x') .set('Origin', 'null'); - expect(response.status).toBe(403); - expect(response.body).toEqual({ - error: { type: 'origin_not_allowed', status: 403, message: expect.any(String) }, - }); + expectOriginForbidden(response); + }); + + it('returns 403 when the per-key list is non-empty and the Origin is present but not a parseable origin', async () => { + const response = await request(buildApp(['https://a.com']).callback()) + .get('/agent/x') + .set('Origin', 'garbage'); + + expectOriginForbidden(response); }); it('treats an empty Origin header like an absent one and proceeds', async () => { From 808a3ac214d4eefe61c654bbe72a0a490a0dd93e Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Mon, 31 Aug 2026 17:16:48 +0200 Subject: [PATCH 3/7] test(bff): pin the forbidden-origin error message --- packages/agent-bff/test/cors/per-key-origin.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/agent-bff/test/cors/per-key-origin.test.ts b/packages/agent-bff/test/cors/per-key-origin.test.ts index f4f4a7c6ed..fb22ebe843 100644 --- a/packages/agent-bff/test/cors/per-key-origin.test.ts +++ b/packages/agent-bff/test/cors/per-key-origin.test.ts @@ -24,7 +24,11 @@ function buildApp(allowedOrigins?: string[]) { function expectOriginForbidden(response: request.Response) { expect(response.status).toBe(403); expect(response.body).toEqual({ - error: { type: 'origin_not_allowed', status: 403, message: expect.any(String) }, + error: { + type: 'origin_not_allowed', + status: 403, + message: 'Origin is not allowed for this key', + }, }); } From da266f6867ac910318cf4f5562c7b76d9f9e0577 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 1 Sep 2026 23:45:41 +0200 Subject: [PATCH 4/7] fix(bff): align the origin contract and document the per-key rule --- packages/agent-bff/README.md | 3 ++- packages/agent-bff/src/cors/cors-middleware.ts | 6 +++--- packages/agent-bff/src/cors/origin.ts | 4 ++-- packages/agent-bff/src/openapi/openapi-document.ts | 7 ++++++- packages/agent-bff/test/cors/per-key-origin.test.ts | 9 +++++++++ packages/agent-bff/test/openapi/openapi-document.test.ts | 9 +++++++++ 6 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index 1f033da571..f8f3176f3f 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -146,7 +146,8 @@ normalized away, no trailing slash, no wildcard, no subdomain matching): `403 origin_not_allowed`. A request with no `Origin` at all — a server-side client such as curl, a Node backend or CI, an empty header counting as none — passes: there the API key is the boundary, not the origin. An opaque `Origin: null` (sandboxed iframe, cross-origin redirect) is a - present origin and is rejected. An empty per-key list is a no-op. + present origin and is rejected. An empty per-key list is a no-op. The OpenAPI document says the + same on the `bffApiKey` security scheme, so a consumer reading the document alone learns it too. **Local development:** browsers still enforce CORS against `localhost`, so add your dev origin(s) to `BFF_ALLOWED_ORIGINS` (e.g. `BFF_ALLOWED_ORIGINS=http://localhost:4200`) — there is no dev bypass. diff --git a/packages/agent-bff/src/cors/cors-middleware.ts b/packages/agent-bff/src/cors/cors-middleware.ts index db05f03b82..b737af31d5 100644 --- a/packages/agent-bff/src/cors/cors-middleware.ts +++ b/packages/agent-bff/src/cors/cors-middleware.ts @@ -1,6 +1,6 @@ import type { Middleware } from 'koa'; -import { hasOrigin, originAllowed } from './origin'; +import { originAllowed } from './origin'; export const ALLOWED_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; export const ALLOWED_HEADERS = @@ -17,9 +17,9 @@ export default function createCorsMiddleware({ return async function corsMiddleware(ctx, next) { const origin = ctx.get('Origin'); - if (hasOrigin(origin)) ctx.vary('Origin'); + if (origin) ctx.vary('Origin'); - const allowed = hasOrigin(origin) && originAllowed(origin, allowedOrigins); + const allowed = origin ? originAllowed(origin, allowedOrigins) : false; if (allowed) ctx.set('Access-Control-Allow-Origin', origin); diff --git a/packages/agent-bff/src/cors/origin.ts b/packages/agent-bff/src/cors/origin.ts index 008b76578c..63ae2a58df 100644 --- a/packages/agent-bff/src/cors/origin.ts +++ b/packages/agent-bff/src/cors/origin.ts @@ -1,5 +1,5 @@ -export function hasOrigin(raw: string): boolean { - return raw !== ''; +export function hasOrigin(raw: string | undefined | null): boolean { + return raw !== undefined && raw !== null && raw.trim() !== ''; } export function normalizeOrigin(raw: string | undefined | null): string | null { diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index 8103504403..3d650d738e 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -401,7 +401,12 @@ export function generateOpenApiDocument( type: 'apiKey', in: 'header', name: 'X-Forest-Bff-Key', - description: 'Mode 2: a BFF API key. Never send both this and an Authorization header.', + description: + 'Mode 2: a BFF API key. Never send both this and an Authorization header. A key created ' + + 'with allowedOrigins restricts browser callers only: an Origin the client sends must be in ' + + 'that list, else 403 origin_not_allowed, while a request with no Origin at all — curl, a ' + + 'server-side client, CI — passes, because there the key is the boundary rather than the ' + + 'origin. The opaque Origin null is a present origin and is rejected.', }); registry.registerPath({ diff --git a/packages/agent-bff/test/cors/per-key-origin.test.ts b/packages/agent-bff/test/cors/per-key-origin.test.ts index fb22ebe843..df7b23db70 100644 --- a/packages/agent-bff/test/cors/per-key-origin.test.ts +++ b/packages/agent-bff/test/cors/per-key-origin.test.ts @@ -89,6 +89,15 @@ describe('per-key origin middleware (layer 2)', () => { expectOriginForbidden(response); }); + it('treats a whitespace-only Origin header like an absent one and proceeds', async () => { + const response = await request(buildApp(['https://a.com']).callback()) + .get('/agent/x') + .set('Origin', ' '); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ reached: true }); + }); + it('treats an empty Origin header like an absent one and proceeds', async () => { const response = await request(buildApp(['https://a.com']).callback()) .get('/agent/x') diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index eb62e23b72..5dbd55df61 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -197,6 +197,15 @@ describe('generateOpenApiDocument', () => { expect(session.description).toContain('every data and action route'); }); + it('should say in the api key scheme that allowedOrigins gates browsers only', () => { + const apiKey = (document.components?.securitySchemes as Record) + .bffApiKey; + + expect(apiKey.description).toContain('restricts browser callers only'); + expect(apiKey.description).toContain('a request with no Origin at all'); + expect(apiKey.description).toContain('opaque Origin null is a present origin and is rejected'); + }); + it('should require a body where parentId or recordIds is mandatory', () => { const requiredByPath = Object.fromEntries( Object.entries(document.paths ?? {}) From 1e6f3032a19f6a78cb68ada4572db386659df397 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Tue, 1 Sep 2026 23:49:24 +0200 Subject: [PATCH 5/7] test(bff): pin the hasOrigin blank input contract --- packages/agent-bff/test/cors/origin.test.ts | 6 ++++++ packages/agent-bff/test/cors/per-key-origin.test.ts | 9 --------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/agent-bff/test/cors/origin.test.ts b/packages/agent-bff/test/cors/origin.test.ts index e2628a8cd8..6f3827f3b8 100644 --- a/packages/agent-bff/test/cors/origin.test.ts +++ b/packages/agent-bff/test/cors/origin.test.ts @@ -14,6 +14,12 @@ describe('hasOrigin', () => { expect(hasOrigin('null')).toBe(true); expect(hasOrigin('not a url')).toBe(true); }); + + it('is false for blank or absent input, the same contract normalizeOrigin takes', () => { + expect(hasOrigin(' ')).toBe(false); + expect(hasOrigin(undefined)).toBe(false); + expect(hasOrigin(null)).toBe(false); + }); }); describe('normalizeOrigin', () => { diff --git a/packages/agent-bff/test/cors/per-key-origin.test.ts b/packages/agent-bff/test/cors/per-key-origin.test.ts index df7b23db70..fb22ebe843 100644 --- a/packages/agent-bff/test/cors/per-key-origin.test.ts +++ b/packages/agent-bff/test/cors/per-key-origin.test.ts @@ -89,15 +89,6 @@ describe('per-key origin middleware (layer 2)', () => { expectOriginForbidden(response); }); - it('treats a whitespace-only Origin header like an absent one and proceeds', async () => { - const response = await request(buildApp(['https://a.com']).callback()) - .get('/agent/x') - .set('Origin', ' '); - - expect(response.status).toBe(200); - expect(response.body).toEqual({ reached: true }); - }); - it('treats an empty Origin header like an absent one and proceeds', async () => { const response = await request(buildApp(['https://a.com']).callback()) .get('/agent/x') From fe4ec5ce84f6dd30e7c4c13708baeab7cf1a5de7 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 2 Sep 2026 13:05:13 +0200 Subject: [PATCH 6/7] refactor(bff): fold the blank origin check into hasOrigin --- packages/agent-bff/src/cors/origin.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/agent-bff/src/cors/origin.ts b/packages/agent-bff/src/cors/origin.ts index 63ae2a58df..784040208c 100644 --- a/packages/agent-bff/src/cors/origin.ts +++ b/packages/agent-bff/src/cors/origin.ts @@ -1,12 +1,12 @@ -export function hasOrigin(raw: string | undefined | null): boolean { +export function hasOrigin(raw: string | undefined | null): raw is string { return raw !== undefined && raw !== null && raw.trim() !== ''; } export function normalizeOrigin(raw: string | undefined | null): string | null { - if (raw === undefined || raw === null) return null; + if (!hasOrigin(raw)) return null; const trimmed = raw.trim(); - if (trimmed === '' || trimmed === 'null') return null; + if (trimmed === 'null') return null; let url: URL; From 574f353744376fb9f29727904e49390b947f09a4 Mon Sep 17 00:00:00 2001 From: Anthony Guimard Date: Wed, 2 Sep 2026 13:05:14 +0200 Subject: [PATCH 7/7] docs(bff): state the empty-origin rule in the openapi scheme --- packages/agent-bff/README.md | 3 +-- packages/agent-bff/src/openapi/openapi-document.ts | 5 +++-- packages/agent-bff/test/openapi/openapi-document.test.ts | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/agent-bff/README.md b/packages/agent-bff/README.md index f8f3176f3f..1f033da571 100644 --- a/packages/agent-bff/README.md +++ b/packages/agent-bff/README.md @@ -146,8 +146,7 @@ normalized away, no trailing slash, no wildcard, no subdomain matching): `403 origin_not_allowed`. A request with no `Origin` at all — a server-side client such as curl, a Node backend or CI, an empty header counting as none — passes: there the API key is the boundary, not the origin. An opaque `Origin: null` (sandboxed iframe, cross-origin redirect) is a - present origin and is rejected. An empty per-key list is a no-op. The OpenAPI document says the - same on the `bffApiKey` security scheme, so a consumer reading the document alone learns it too. + present origin and is rejected. An empty per-key list is a no-op. **Local development:** browsers still enforce CORS against `localhost`, so add your dev origin(s) to `BFF_ALLOWED_ORIGINS` (e.g. `BFF_ALLOWED_ORIGINS=http://localhost:4200`) — there is no dev bypass. diff --git a/packages/agent-bff/src/openapi/openapi-document.ts b/packages/agent-bff/src/openapi/openapi-document.ts index 3d650d738e..3f86a5a491 100644 --- a/packages/agent-bff/src/openapi/openapi-document.ts +++ b/packages/agent-bff/src/openapi/openapi-document.ts @@ -405,8 +405,9 @@ export function generateOpenApiDocument( 'Mode 2: a BFF API key. Never send both this and an Authorization header. A key created ' + 'with allowedOrigins restricts browser callers only: an Origin the client sends must be in ' + 'that list, else 403 origin_not_allowed, while a request with no Origin at all — curl, a ' + - 'server-side client, CI — passes, because there the key is the boundary rather than the ' + - 'origin. The opaque Origin null is a present origin and is rejected.', + 'server-side client, CI, an empty Origin header counting as none — passes, because there ' + + 'the key is the boundary rather than the origin. The opaque Origin null is a present origin ' + + 'and is rejected.', }); registry.registerPath({ diff --git a/packages/agent-bff/test/openapi/openapi-document.test.ts b/packages/agent-bff/test/openapi/openapi-document.test.ts index 5dbd55df61..341419c34d 100644 --- a/packages/agent-bff/test/openapi/openapi-document.test.ts +++ b/packages/agent-bff/test/openapi/openapi-document.test.ts @@ -203,6 +203,7 @@ describe('generateOpenApiDocument', () => { expect(apiKey.description).toContain('restricts browser callers only'); expect(apiKey.description).toContain('a request with no Origin at all'); + expect(apiKey.description).toContain('an empty Origin header counting as none'); expect(apiKey.description).toContain('opaque Origin null is a present origin and is rejected'); });