Skip to content

Commit 54995c1

Browse files
rsbhclaude
andauthored
fix: harden CORS proxy with timeout, size limit, and encoded path check (#145)
* fix: harden CORS proxy with timeout, size limit, and encoded path check - Decode path before traversal check to catch %2e%2e encoded variants - Add AbortSignal.timeout(30s) to upstream fetch calls - Reject request bodies larger than 1MB via Content-Length check - Return 504 for upstream timeouts instead of generic 502 Closes #118 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: increase proxy body size limit to 10MB Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: increase proxy upstream timeout to 2 minutes Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: use StatusCodes from http-status-codes package Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5bb911c commit 54995c1

1 file changed

Lines changed: 32 additions & 7 deletions

File tree

packages/chronicle/src/server/api/apis-proxy.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { defineHandler, HTTPError } from 'nitro';
2+
import { StatusCodes } from 'http-status-codes';
23
import { loadConfig } from '@/lib/config';
34
import { loadApiSpecs } from '@/lib/openapi';
45

6+
const MAX_BODY_SIZE = 10_485_760; // 10 MB
7+
const UPSTREAM_TIMEOUT_MS = 120_000;
8+
59
interface ProxyRequest {
610
specName: string;
711
method: string;
@@ -10,17 +14,34 @@ interface ProxyRequest {
1014
body?: unknown;
1115
}
1216

17+
function isPathSafe(p: string): boolean {
18+
let decoded: string;
19+
try {
20+
decoded = decodeURIComponent(p);
21+
} catch {
22+
return false;
23+
}
24+
if (/^[a-z]+:\/\//i.test(decoded)) return false;
25+
const normalized = new URL(decoded, 'http://localhost').pathname;
26+
return !normalized.split('/').includes('..');
27+
}
28+
1329
export default defineHandler(async event => {
1430
if (event.req.method !== 'POST') {
15-
throw new HTTPError({ status: 405, message: 'Method not allowed' });
31+
throw new HTTPError({ status: StatusCodes.METHOD_NOT_ALLOWED, message: 'Method not allowed' });
32+
}
33+
34+
const contentLength = parseInt(event.req.headers.get('content-length') ?? '0', 10);
35+
if (contentLength > MAX_BODY_SIZE) {
36+
throw new HTTPError({ status: StatusCodes.CONTENT_TOO_LARGE, message: `Request body too large (max ${MAX_BODY_SIZE} bytes)` });
1637
}
1738

1839
const { specName, method, path, headers, body } =
1940
(await event.req.json()) as ProxyRequest;
2041

2142
if (!specName || !method || !path) {
2243
throw new HTTPError({
23-
status: 400,
44+
status: StatusCodes.BAD_REQUEST,
2445
message: 'Missing specName, method, or path'
2546
});
2647
}
@@ -30,11 +51,11 @@ export default defineHandler(async event => {
3051
const spec = specs.find(s => s.name === specName);
3152

3253
if (!spec) {
33-
throw new HTTPError({ status: 404, message: `Unknown spec: ${specName}` });
54+
throw new HTTPError({ status: StatusCodes.NOT_FOUND, message: `Unknown spec: ${specName}` });
3455
}
3556

36-
if (/^[a-z]+:\/\//i.test(path) || path.includes('..')) {
37-
throw new HTTPError({ status: 400, message: 'Invalid path' });
57+
if (!isPathSafe(path)) {
58+
throw new HTTPError({ status: StatusCodes.BAD_REQUEST, message: 'Invalid path' });
3859
}
3960

4061
const url = spec.server.url + path;
@@ -43,7 +64,8 @@ export default defineHandler(async event => {
4364
const response = await fetch(url, {
4465
method,
4566
headers,
46-
body: body ? JSON.stringify(body) : undefined
67+
body: body ? JSON.stringify(body) : undefined,
68+
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
4769
});
4870

4971
const contentType = response.headers.get('content-type') ?? '';
@@ -64,12 +86,15 @@ export default defineHandler(async event => {
6486
headers: responseHeaders
6587
});
6688
} catch (error) {
89+
if (error instanceof DOMException && error.name === 'TimeoutError') {
90+
throw new HTTPError({ status: StatusCodes.GATEWAY_TIMEOUT, message: `Upstream request timed out after ${UPSTREAM_TIMEOUT_MS}ms` });
91+
}
6792
const message =
6893
error instanceof Error
6994
? `${error.message}${error.cause ? `: ${(error.cause as Error).message}` : ''}`
7095
: 'Request failed';
7196
throw new HTTPError({
72-
status: 502,
97+
status: StatusCodes.BAD_GATEWAY,
7398
message: `Could not reach ${url}\n${message}`
7499
});
75100
}

0 commit comments

Comments
 (0)