Skip to content

Commit bdd3bf1

Browse files
chore: improvements to security
[dev] [Marfuen] mariano/chore-improve-api-middleware-hardening
1 parent f7c756c commit bdd3bf1

24 files changed

+799
-27
lines changed

apps/api/src/attachments/attachments.service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { randomBytes } from 'crypto';
1616
import { AttachmentResponseDto } from '../tasks/dto/task-responses.dto';
1717
import { UploadAttachmentDto } from './upload-attachment.dto';
1818
import { s3Client } from '@/app/s3';
19+
import { validateFileContent } from '../utils/file-type-validation';
1920

2021
@Injectable()
2122
export class AttachmentsService {
@@ -123,6 +124,9 @@ export class AttachmentsService {
123124
);
124125
}
125126

127+
// Validate file content matches declared MIME type
128+
validateFileContent(fileBuffer, uploadDto.fileType, uploadDto.fileName);
129+
126130
// Generate unique file key
127131
const fileId = randomBytes(16).toString('hex');
128132
const sanitizedFileName = this.sanitizeFileName(uploadDto.fileName);

apps/api/src/auth/api-key.service.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -55,16 +55,19 @@ export class ApiKeyService {
5555
expiresAt?: string,
5656
scopes?: string[],
5757
) {
58-
// Validate scopes if provided
59-
const validatedScopes = scopes?.length ? scopes : [];
60-
if (validatedScopes.length > 0) {
61-
const availableScopes = this.getAvailableScopes();
62-
const invalid = validatedScopes.filter((s) => !availableScopes.includes(s));
63-
if (invalid.length > 0) {
64-
throw new BadRequestException(
65-
`Invalid scopes: ${invalid.join(', ')}`,
66-
);
67-
}
58+
// New keys must have explicit scopes — no more legacy empty-scope keys
59+
if (!scopes || scopes.length === 0) {
60+
throw new BadRequestException(
61+
'API keys must have at least one scope. Use the "Full Access" preset to grant all permissions.',
62+
);
63+
}
64+
// Validate all scopes against the allowlist
65+
const availableScopes = this.getAvailableScopes();
66+
const invalid = scopes.filter((s) => !availableScopes.includes(s));
67+
if (invalid.length > 0) {
68+
throw new BadRequestException(
69+
`Invalid scopes: ${invalid.join(', ')}`,
70+
);
6871
}
6972

7073
const apiKey = this.generateApiKey();
@@ -103,7 +106,7 @@ export class ApiKeyService {
103106
salt,
104107
expiresAt: expirationDate,
105108
organizationId,
106-
scopes: validatedScopes,
109+
scopes,
107110
},
108111
select: {
109112
id: true,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Tests for the getTrustedOrigins logic.
3+
*
4+
* Because auth.server.ts has side effects at module load time (better-auth
5+
* initialization, DB connections, validateSecurityConfig), we test the logic
6+
* in isolation rather than importing the module directly.
7+
*/
8+
9+
function getTrustedOriginsLogic(authTrustedOrigins: string | undefined): string[] {
10+
if (authTrustedOrigins) {
11+
return authTrustedOrigins.split(',').map((o) => o.trim());
12+
}
13+
14+
return [
15+
'http://localhost:3000',
16+
'http://localhost:3002',
17+
'http://localhost:3333',
18+
'https://app.trycomp.ai',
19+
'https://portal.trycomp.ai',
20+
'https://api.trycomp.ai',
21+
'https://app.staging.trycomp.ai',
22+
'https://portal.staging.trycomp.ai',
23+
'https://api.staging.trycomp.ai',
24+
'https://dev.trycomp.ai',
25+
];
26+
}
27+
28+
describe('getTrustedOrigins', () => {
29+
it('should return env-configured origins when AUTH_TRUSTED_ORIGINS is set', () => {
30+
const origins = getTrustedOriginsLogic('https://a.com, https://b.com');
31+
expect(origins).toEqual(['https://a.com', 'https://b.com']);
32+
});
33+
34+
it('should return hardcoded origins when AUTH_TRUSTED_ORIGINS is not set', () => {
35+
const origins = getTrustedOriginsLogic(undefined);
36+
expect(origins).toContain('https://app.trycomp.ai');
37+
});
38+
39+
it('should never include wildcard origin', () => {
40+
const origins = getTrustedOriginsLogic(undefined);
41+
expect(origins.every((o: string) => o !== '*' && o !== 'true')).toBe(true);
42+
});
43+
44+
it('should trim whitespace from comma-separated origins', () => {
45+
const origins = getTrustedOriginsLogic(' https://a.com , https://b.com ');
46+
expect(origins).toEqual(['https://a.com', 'https://b.com']);
47+
});
48+
49+
it('main.ts should use getTrustedOrigins instead of origin: true', () => {
50+
// Validate the CORS config change was made correctly by checking file content
51+
const fs = require('fs');
52+
const path = require('path');
53+
const mainTs = fs.readFileSync(
54+
path.join(__dirname, '..', 'main.ts'),
55+
'utf-8',
56+
) as string;
57+
expect(mainTs).not.toContain('origin: true');
58+
expect(mainTs).toContain('origin: getTrustedOrigins()');
59+
expect(mainTs).toContain("import { getTrustedOrigins } from './auth/auth.server'");
60+
});
61+
});

apps/api/src/auth/auth.server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function getCookieDomain(): string | undefined {
3636
/**
3737
* Get trusted origins for CORS/auth
3838
*/
39-
function getTrustedOrigins(): string[] {
39+
export function getTrustedOrigins(): string[] {
4040
const origins = process.env.AUTH_TRUSTED_ORIGINS;
4141
if (origins) {
4242
return origins.split(',').map((o) => o.trim());
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { originCheckMiddleware } from './origin-check.middleware';
2+
3+
// Mock getTrustedOrigins
4+
jest.mock('./auth.server', () => ({
5+
getTrustedOrigins: () => [
6+
'http://localhost:3000',
7+
'http://localhost:3002',
8+
'https://app.trycomp.ai',
9+
'https://portal.trycomp.ai',
10+
],
11+
}));
12+
13+
function createMockReq(
14+
method: string,
15+
path: string,
16+
origin?: string,
17+
): Record<string, unknown> {
18+
return {
19+
method,
20+
path,
21+
headers: origin ? { origin } : {},
22+
};
23+
}
24+
25+
function createMockRes(): Record<string, unknown> & { statusCode?: number; body?: unknown } {
26+
const res: Record<string, unknown> & { statusCode?: number; body?: unknown } = {};
27+
res.status = jest.fn().mockImplementation((code: number) => {
28+
res.statusCode = code;
29+
return res;
30+
});
31+
res.json = jest.fn().mockImplementation((body: unknown) => {
32+
res.body = body;
33+
return res;
34+
});
35+
return res;
36+
}
37+
38+
describe('originCheckMiddleware', () => {
39+
it('should allow GET requests regardless of origin', () => {
40+
const req = createMockReq('GET', '/v1/controls', 'http://evil.com');
41+
const res = createMockRes();
42+
const next = jest.fn();
43+
44+
originCheckMiddleware(req as any, res as any, next);
45+
46+
expect(next).toHaveBeenCalled();
47+
});
48+
49+
it('should allow HEAD requests regardless of origin', () => {
50+
const req = createMockReq('HEAD', '/v1/health', 'http://evil.com');
51+
const res = createMockRes();
52+
const next = jest.fn();
53+
54+
originCheckMiddleware(req as any, res as any, next);
55+
56+
expect(next).toHaveBeenCalled();
57+
});
58+
59+
it('should allow OPTIONS requests regardless of origin', () => {
60+
const req = createMockReq('OPTIONS', '/v1/controls', 'http://evil.com');
61+
const res = createMockRes();
62+
const next = jest.fn();
63+
64+
originCheckMiddleware(req as any, res as any, next);
65+
66+
expect(next).toHaveBeenCalled();
67+
});
68+
69+
it('should allow POST from trusted origin', () => {
70+
const req = createMockReq('POST', '/v1/organization/api-keys', 'http://localhost:3000');
71+
const res = createMockRes();
72+
const next = jest.fn();
73+
74+
originCheckMiddleware(req as any, res as any, next);
75+
76+
expect(next).toHaveBeenCalled();
77+
});
78+
79+
it('should block POST from untrusted origin', () => {
80+
const req = createMockReq('POST', '/v1/organization/transfer-ownership', 'http://evil.com');
81+
const res = createMockRes();
82+
const next = jest.fn();
83+
84+
originCheckMiddleware(req as any, res as any, next);
85+
86+
expect(next).not.toHaveBeenCalled();
87+
expect(res.status).toHaveBeenCalledWith(403);
88+
});
89+
90+
it('should block DELETE from untrusted origin', () => {
91+
const req = createMockReq('DELETE', '/v1/organization', 'http://evil.com');
92+
const res = createMockRes();
93+
const next = jest.fn();
94+
95+
originCheckMiddleware(req as any, res as any, next);
96+
97+
expect(next).not.toHaveBeenCalled();
98+
expect(res.status).toHaveBeenCalledWith(403);
99+
});
100+
101+
it('should block PATCH from untrusted origin', () => {
102+
const req = createMockReq('PATCH', '/v1/members/123/role', 'http://evil.com');
103+
const res = createMockRes();
104+
const next = jest.fn();
105+
106+
originCheckMiddleware(req as any, res as any, next);
107+
108+
expect(next).not.toHaveBeenCalled();
109+
expect(res.status).toHaveBeenCalledWith(403);
110+
});
111+
112+
it('should allow POST without Origin header (API key / service token)', () => {
113+
const req = createMockReq('POST', '/v1/controls', undefined);
114+
const res = createMockRes();
115+
const next = jest.fn();
116+
117+
originCheckMiddleware(req as any, res as any, next);
118+
119+
expect(next).toHaveBeenCalled();
120+
});
121+
122+
it('should allow POST to /api/auth routes (better-auth exempt)', () => {
123+
const req = createMockReq('POST', '/api/auth/sign-in', 'http://evil.com');
124+
const res = createMockRes();
125+
const next = jest.fn();
126+
127+
originCheckMiddleware(req as any, res as any, next);
128+
129+
expect(next).toHaveBeenCalled();
130+
});
131+
132+
it('should allow POST to health check', () => {
133+
const req = createMockReq('POST', '/v1/health', 'http://evil.com');
134+
const res = createMockRes();
135+
const next = jest.fn();
136+
137+
originCheckMiddleware(req as any, res as any, next);
138+
139+
expect(next).toHaveBeenCalled();
140+
});
141+
142+
it('should allow production origins', () => {
143+
const req = createMockReq('POST', '/v1/organization/api-keys', 'https://app.trycomp.ai');
144+
const res = createMockRes();
145+
const next = jest.fn();
146+
147+
originCheckMiddleware(req as any, res as any, next);
148+
149+
expect(next).toHaveBeenCalled();
150+
});
151+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Request, Response, NextFunction } from 'express';
2+
import { getTrustedOrigins } from './auth.server';
3+
4+
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
5+
6+
/**
7+
* Paths exempt from Origin validation (webhooks, public endpoints).
8+
* These are called by external services that don't send browser Origin headers.
9+
*/
10+
const EXEMPT_PATH_PREFIXES = [
11+
'/api/auth', // better-auth handles its own CSRF
12+
'/v1/health', // health check
13+
'/api/docs', // swagger
14+
];
15+
16+
/**
17+
* Express middleware that validates the Origin header on state-changing requests.
18+
*
19+
* This is defense-in-depth against CSRF attacks that bypass CORS:
20+
* - HTML form submissions (Content-Type: application/x-www-form-urlencoded)
21+
* don't trigger CORS preflight, so CORS alone doesn't block them.
22+
* - This middleware rejects any state-changing request whose Origin header
23+
* doesn't match a trusted origin.
24+
*
25+
* API keys and service tokens (which don't come from browsers) typically
26+
* don't send an Origin header, so requests without an Origin are allowed
27+
* — they'll be authenticated by HybridAuthGuard instead.
28+
*/
29+
export function originCheckMiddleware(
30+
req: Request,
31+
res: Response,
32+
next: NextFunction,
33+
): void {
34+
// Allow safe (read-only) methods
35+
if (SAFE_METHODS.has(req.method)) {
36+
return next();
37+
}
38+
39+
// Allow exempt paths (webhooks, auth, etc.)
40+
const isExempt = EXEMPT_PATH_PREFIXES.some((prefix) =>
41+
req.path.startsWith(prefix),
42+
);
43+
if (isExempt) {
44+
return next();
45+
}
46+
47+
const origin = req.headers['origin'] as string | undefined;
48+
49+
// No Origin header = not a browser request (API key, service token, curl, etc.)
50+
// These are authenticated via HybridAuthGuard, not cookies, so no CSRF risk.
51+
if (!origin) {
52+
return next();
53+
}
54+
55+
// Validate Origin against trusted origins
56+
const trustedOrigins = getTrustedOrigins();
57+
if (trustedOrigins.includes(origin)) {
58+
return next();
59+
}
60+
61+
res.status(403).json({
62+
statusCode: 403,
63+
message: 'Forbidden',
64+
});
65+
}

0 commit comments

Comments
 (0)