Skip to content

Commit 36ff5d8

Browse files
lautaropaskeclaude
andcommitted
fix(api): stop /api/grade from fetching arbitrary internal URLs
`/api/grade?url=` handed a caller-supplied string straight to `fetch()`: no scheme check, no address check, no timeout and no size cap, on a route with `maxDuration: 300`. Anything the function could reach, a visitor could reach through it — `169.254.169.254` included — and read back as a 400 or a grade. The multipart branch had the matching problem: with `bodyParser: false` nothing bounded the stream it buffered. The feature is "paste a link to your CV", so a host allowlist isn't on the table. `fetchRemoteResume` guards where the connection actually lands: - https only, no embedded credentials, port 443 only — an arbitrary port turns this into an internal port scanner. - Every resolved address is checked against loopback, RFC1918, CGNAT, link-local, benchmarking, documentation, multicast and reserved space, v4 and v6, including the transition formats that hide a v4 address inside a v6 one. If any record for a host is private the host is refused, so a round-robin that mixes public and private answers doesn't get a second roll. - The socket is pinned to the address we validated, with `servername` and `Host` still set to the name, so a DNS rebind can't swap the metadata endpoint in between the check and the connect. Validate-then-`fetch()` leaves that window open. - Redirects are followed by hand, three at most, each hop re-parsed and re-resolved from scratch — a `Location` is a fresh caller-supplied URL. - 20s deadline across all hops, and a 10MB cap on both `content-length` and the running total, since a chunked response declares nothing. Failures carry stable codes, so the route answers 4xx instead of falling through to the 500, and `getErrorMessage` maps them to copy. It now reads the code before looking at the status: our own 413 knows the route's limit, while Vercel's plain-text 413 is still the one that mentions its 4.5MB payload cap. Tests are network-free: address classification both ways, and the URL shapes refused before a socket opens — decimal and hex loopback encodings among them, which `new URL()` normalizes and the resolver would otherwise reach happily. The timeout and both size-cap branches were checked against real origins locally; those cases stay out of the suite rather than make CI depend on third-party hosts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xa76D7n4CLkdKt99Su1HRy
1 parent 9013deb commit 36ff5d8

6 files changed

Lines changed: 552 additions & 7 deletions

File tree

‎src/pages/api/grade.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import {
2+
fetchRemoteResume,
3+
MAX_RESUME_BYTES,
4+
ResumeFetchError,
5+
} from "@/resume-checker/fetch-resume";
16
import {
27
exampleResponses,
38
messages,
@@ -42,8 +47,17 @@ export default async function handler(
4247
try {
4348
let pdfBuffer: Buffer;
4449
if (isMultipartFormData(req)) {
45-
const chunks = [];
50+
// bodyParser is off, so nothing else bounds this stream: without the
51+
// running total a single request could buffer the whole heap away.
52+
const chunks: Buffer[] = [];
53+
let size = 0;
4654
for await (const chunk of req) {
55+
size += chunk.length;
56+
if (size > MAX_RESUME_BYTES) {
57+
req.destroy();
58+
res.status(413).json({ error: "ResumeTooLarge" });
59+
return;
60+
}
4761
chunks.push(chunk);
4862
}
4963
pdfBuffer = Buffer.concat(chunks);
@@ -54,9 +68,7 @@ export default async function handler(
5468
return;
5569
}
5670

57-
const response = await fetch(resumeUrl);
58-
const arrayBuffer = await response.arrayBuffer();
59-
pdfBuffer = Buffer.from(arrayBuffer);
71+
pdfBuffer = await fetchRemoteResume(resumeUrl);
6072
}
6173

6274
const parsed = await pdf(pdfBuffer);
@@ -84,6 +96,14 @@ export default async function handler(
8496
return;
8597
}
8698

99+
// Bad or hostile resume URLs are the caller's fault, and each carries a
100+
// stable code the badge can render.
101+
if (e instanceof ResumeFetchError) {
102+
console.warn(e);
103+
res.status(e.status).json({ error: e.code });
104+
return;
105+
}
106+
87107
if (
88108
e.message.includes("InvalidPDFException") ||
89109
e.message.includes("Invalid PDF structure")

0 commit comments

Comments
 (0)