From efd349419b4bfe62bfb3a678114e7afeac634d13 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 28 Jul 2026 15:06:36 -0400 Subject: [PATCH 1/2] test(api): lock in that production origins still pass CORS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CORS layer was replaced in #3064 to support the questionnaire extension. These assert the four origin classes that actually reach production — the app, the portal, an arbitrary trycomp.ai subdomain, and a customer custom domain — still receive the right headers, that an untrusted origin is still rejected, and that resolution does not depend on AUTH_TRUSTED_ORIGINS, which the API is not given in production. Tests only; no production code changes. --- .../src/auth/cors-production-origins.spec.ts | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 apps/api/src/auth/cors-production-origins.spec.ts diff --git a/apps/api/src/auth/cors-production-origins.spec.ts b/apps/api/src/auth/cors-production-origins.spec.ts new file mode 100644 index 0000000000..93760ccbba --- /dev/null +++ b/apps/api/src/auth/cors-production-origins.spec.ts @@ -0,0 +1,96 @@ +import { corsOriginMiddleware } from './cors-origin.middleware'; +import { isTrustedOrigin } from './auth.server'; +import { isStaticTrustedOrigin } from './origin-policy'; +import type { NextFunction, Request, Response } from 'express'; + +jest.mock('./auth.server', () => ({ isTrustedOrigin: jest.fn() })); + +/** + * The four classes of origin that reach the production API. The CORS layer was + * rewritten to support the questionnaire extension, so these assert the + * existing apps kept working rather than testing anything new. + */ +const APP = 'https://app.trycomp.ai'; +const PORTAL = 'https://portal.trycomp.ai'; +const SUBDOMAIN = 'https://anything.trycomp.ai'; +// A customer's own trust-portal domain: not a trycomp.ai host, so it is only +// ever allowed by the Redis/DB-backed lookup inside isTrustedOrigin. +const CUSTOM_DOMAIN = 'https://trust.acmecorp.com'; + +function createRequest(origin: string): Partial { + return { method: 'GET', path: '/v1/controls', headers: { origin } }; +} + +function createResponse(): Partial & { headers: Record } { + const response: Partial & { headers: Record } = { + headers: {}, + }; + response.vary = jest.fn().mockReturnValue(response); + response.setHeader = jest.fn().mockImplementation((key: string, value: string) => { + response.headers[key] = value; + return response; + }); + response.status = jest.fn().mockReturnValue(response); + response.send = jest.fn().mockReturnValue(response); + return response; +} + +const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); + +describe('production origins still pass CORS', () => { + it.each([ + ['the app', APP], + ['the portal', PORTAL], + ['an arbitrary trycomp.ai subdomain', SUBDOMAIN], + ['a customer custom domain', CUSTOM_DOMAIN], + ])('allows %s', async (_label, origin) => { + jest.mocked(isTrustedOrigin).mockResolvedValue(true); + const response = createResponse(); + const next: NextFunction = jest.fn(); + + corsOriginMiddleware( + createRequest(origin) as Request, + response as Response, + next, + ); + await flushPromises(); + + expect(response.headers['Access-Control-Allow-Origin']).toBe(origin); + expect(response.headers['Access-Control-Allow-Credentials']).toBe('true'); + expect(next).toHaveBeenCalled(); + }); + + it('rejects an untrusted origin', async () => { + jest.mocked(isTrustedOrigin).mockResolvedValue(false); + const response = createResponse(); + const next: NextFunction = jest.fn(); + + corsOriginMiddleware( + createRequest('https://evil.example') as Request, + response as Response, + next, + ); + await flushPromises(); + + expect(response.headers['Access-Control-Allow-Origin']).toBeUndefined(); + }); +}); + +describe('the API resolves origins without AUTH_TRUSTED_ORIGINS', () => { + // Production does not supply AUTH_TRUSTED_ORIGINS to the API, so the built-in + // default list applies. These must not depend on that variable being set. + beforeEach(() => { + delete process.env.AUTH_TRUSTED_ORIGINS; + }); + + it.each([APP, PORTAL, SUBDOMAIN, 'https://api.trycomp.ai'])( + 'trusts %s from the default list', + (origin) => { + expect(isStaticTrustedOrigin(origin)).toBe(true); + }, + ); + + it('does not extend the wildcard to plain HTTP', () => { + expect(isStaticTrustedOrigin('http://anything.trycomp.ai')).toBe(false); + }); +}); From 88cc038df53fde9d545d944e4759eaf7827ef599 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Tue, 28 Jul 2026 15:18:25 -0400 Subject: [PATCH 2/2] test(api): use reserved example domains in the auth specs The specs hardcoded a developer's real local unpacked-extension id and used evil.com / acmecorp.com, both of which are real registrable domains, in a public repository. Replace them with a synthetic extension id and RFC 2606 reserved names (untrusted.example, trust.example.com), and drop the attack framing. Behaviour of the tests is unchanged. --- apps/api/src/auth/auth-server-origins.spec.ts | 6 +++--- .../src/auth/cors-origin.middleware.spec.ts | 4 ++-- .../src/auth/cors-production-origins.spec.ts | 4 ++-- .../src/auth/origin-check.middleware.spec.ts | 18 +++++++++--------- apps/api/src/auth/origin-policy.spec.ts | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/api/src/auth/auth-server-origins.spec.ts b/apps/api/src/auth/auth-server-origins.spec.ts index f017d9ac79..55c54fc4bd 100644 --- a/apps/api/src/auth/auth-server-origins.spec.ts +++ b/apps/api/src/auth/auth-server-origins.spec.ts @@ -61,7 +61,7 @@ describe('getTrustedOrigins', () => { describe('COMP_EXTENSION_TRUSTED_ORIGINS', () => { const extensionOrigin = - 'chrome-extension://panomgbokjppnleifmpcnpchjgpcngan'; + 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; beforeEach(() => { process.env.COMP_EXTENSION_TRUSTED_ORIGINS = extensionOrigin; @@ -141,9 +141,9 @@ describe('isStaticTrustedOrigin', () => { }); it('should reject unknown origins', () => { - expect(isStaticTrustedOrigin('https://evil.com')).toBe(false); + expect(isStaticTrustedOrigin('https://untrusted.example')).toBe(false); expect( - isStaticTrustedOrigin('https://trycomp.ai.evil.com'), + isStaticTrustedOrigin('https://trycomp.ai.untrusted.example'), ).toBe(false); }); diff --git a/apps/api/src/auth/cors-origin.middleware.spec.ts b/apps/api/src/auth/cors-origin.middleware.spec.ts index d42970e50f..c5a081efdd 100644 --- a/apps/api/src/auth/cors-origin.middleware.spec.ts +++ b/apps/api/src/auth/cors-origin.middleware.spec.ts @@ -14,7 +14,7 @@ type MockResponse = Partial & { }; const extensionOrigin = - 'chrome-extension://panomgbokjppnleifmpcnpchjgpcngan'; + 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; function createRequest(params: { method: string; @@ -216,7 +216,7 @@ describe('corsOriginMiddleware', () => { jest.mocked(isTrustedOrigin).mockResolvedValue(false); const request = createRequest({ method: 'GET', - origin: 'https://evil.example', + origin: 'https://untrusted.example', path: '/v1/controls', }); const response = createResponse(); diff --git a/apps/api/src/auth/cors-production-origins.spec.ts b/apps/api/src/auth/cors-production-origins.spec.ts index 93760ccbba..9f365b9f47 100644 --- a/apps/api/src/auth/cors-production-origins.spec.ts +++ b/apps/api/src/auth/cors-production-origins.spec.ts @@ -15,7 +15,7 @@ const PORTAL = 'https://portal.trycomp.ai'; const SUBDOMAIN = 'https://anything.trycomp.ai'; // A customer's own trust-portal domain: not a trycomp.ai host, so it is only // ever allowed by the Redis/DB-backed lookup inside isTrustedOrigin. -const CUSTOM_DOMAIN = 'https://trust.acmecorp.com'; +const CUSTOM_DOMAIN = 'https://trust.example.com'; function createRequest(origin: string): Partial { return { method: 'GET', path: '/v1/controls', headers: { origin } }; @@ -66,7 +66,7 @@ describe('production origins still pass CORS', () => { const next: NextFunction = jest.fn(); corsOriginMiddleware( - createRequest('https://evil.example') as Request, + createRequest('https://untrusted.example') as Request, response as Response, next, ); diff --git a/apps/api/src/auth/origin-check.middleware.spec.ts b/apps/api/src/auth/origin-check.middleware.spec.ts index 6698b4cd7c..621a7b596a 100644 --- a/apps/api/src/auth/origin-check.middleware.spec.ts +++ b/apps/api/src/auth/origin-check.middleware.spec.ts @@ -72,7 +72,7 @@ function runOriginCheck(params: { describe('originCheckMiddleware', () => { const originalExtensionOrigins = process.env.COMP_EXTENSION_TRUSTED_ORIGINS; const extensionOrigin = - 'chrome-extension://panomgbokjppnleifmpcnpchjgpcngan'; + 'chrome-extension://abcdefghijklmnopabcdefghijklmnop'; beforeEach(() => { process.env.COMP_EXTENSION_TRUSTED_ORIGINS = extensionOrigin; @@ -87,7 +87,7 @@ describe('originCheckMiddleware', () => { }); it('should allow GET requests regardless of origin', () => { - const req = createMockReq('GET', '/v1/controls', 'http://evil.com'); + const req = createMockReq('GET', '/v1/controls', 'http://untrusted.example'); const res = createMockRes(); const next = jest.fn(); @@ -97,7 +97,7 @@ describe('originCheckMiddleware', () => { }); it('should allow HEAD requests regardless of origin', () => { - const req = createMockReq('HEAD', '/v1/health', 'http://evil.com'); + const req = createMockReq('HEAD', '/v1/health', 'http://untrusted.example'); const res = createMockRes(); const next = jest.fn(); @@ -107,7 +107,7 @@ describe('originCheckMiddleware', () => { }); it('should allow OPTIONS requests regardless of origin', () => { - const req = createMockReq('OPTIONS', '/v1/controls', 'http://evil.com'); + const req = createMockReq('OPTIONS', '/v1/controls', 'http://untrusted.example'); const res = createMockRes(); const next = jest.fn(); @@ -135,7 +135,7 @@ describe('originCheckMiddleware', () => { const req = createMockReq( 'POST', '/v1/organization/transfer-ownership', - 'http://evil.com', + 'http://untrusted.example', ); const res = createMockRes(); const next = jest.fn(); @@ -148,7 +148,7 @@ describe('originCheckMiddleware', () => { }); it('should block DELETE from untrusted origin', async () => { - const req = createMockReq('DELETE', '/v1/organization', 'http://evil.com'); + const req = createMockReq('DELETE', '/v1/organization', 'http://untrusted.example'); const res = createMockRes(); const next = jest.fn(); @@ -163,7 +163,7 @@ describe('originCheckMiddleware', () => { const req = createMockReq( 'PATCH', '/v1/members/123/role', - 'http://evil.com', + 'http://untrusted.example', ); const res = createMockRes(); const next = jest.fn(); @@ -186,7 +186,7 @@ describe('originCheckMiddleware', () => { }); it('should allow POST to /api/auth routes (better-auth exempt)', () => { - const req = createMockReq('POST', '/api/auth/sign-in', 'http://evil.com'); + const req = createMockReq('POST', '/api/auth/sign-in', 'http://untrusted.example'); const res = createMockRes(); const next = jest.fn(); @@ -196,7 +196,7 @@ describe('originCheckMiddleware', () => { }); it('should allow POST to health check', () => { - const req = createMockReq('POST', '/v1/health', 'http://evil.com'); + const req = createMockReq('POST', '/v1/health', 'http://untrusted.example'); const res = createMockRes(); const next = jest.fn(); diff --git a/apps/api/src/auth/origin-policy.spec.ts b/apps/api/src/auth/origin-policy.spec.ts index fb9f7ca8e3..1e3bc72375 100644 --- a/apps/api/src/auth/origin-policy.spec.ts +++ b/apps/api/src/auth/origin-policy.spec.ts @@ -41,7 +41,7 @@ describe('isStaticTrustedOrigin', () => { }); it('rejects unrelated and malformed origins', () => { - expect(isStaticTrustedOrigin('https://trycomp.ai.evil.example')).toBe(false); + expect(isStaticTrustedOrigin('https://trycomp.ai.untrusted.example')).toBe(false); expect(isStaticTrustedOrigin('https://nottrust.inc')).toBe(false); expect(isStaticTrustedOrigin('not-a-url')).toBe(false); expect(isStaticTrustedOrigin('')).toBe(false);