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
6 changes: 3 additions & 3 deletions apps/api/src/auth/auth-server-origins.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});

Expand Down
4 changes: 2 additions & 2 deletions apps/api/src/auth/cors-origin.middleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type MockResponse = Partial<Response> & {
};

const extensionOrigin =
'chrome-extension://panomgbokjppnleifmpcnpchjgpcngan';
'chrome-extension://abcdefghijklmnopabcdefghijklmnop';

function createRequest(params: {
method: string;
Expand Down Expand Up @@ -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();
Expand Down
96 changes: 96 additions & 0 deletions apps/api/src/auth/cors-production-origins.spec.ts
Original file line number Diff line number Diff line change
@@ -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.example.com';

function createRequest(origin: string): Partial<Request> {
return { method: 'GET', path: '/v1/controls', headers: { origin } };
}

function createResponse(): Partial<Response> & { headers: Record<string, string> } {
const response: Partial<Response> & { headers: Record<string, string> } = {
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://untrusted.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);
});
});
18 changes: 9 additions & 9 deletions apps/api/src/auth/origin-check.middleware.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();

Expand All @@ -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();

Expand All @@ -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();

Expand Down Expand Up @@ -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();
Expand All @@ -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();

Expand All @@ -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();
Expand All @@ -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();

Expand All @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/auth/origin-policy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading