diff --git a/src/pages/api/grade.ts b/src/pages/api/grade.ts index 4ad33b6..1915ebb 100644 --- a/src/pages/api/grade.ts +++ b/src/pages/api/grade.ts @@ -1,3 +1,8 @@ +import { + fetchRemoteResume, + MAX_RESUME_BYTES, + ResumeFetchError, +} from "@/resume-checker/fetch-resume"; import { exampleResponses, messages, @@ -42,8 +47,17 @@ export default async function handler( try { let pdfBuffer: Buffer; if (isMultipartFormData(req)) { - const chunks = []; + // bodyParser is off, so nothing else bounds this stream: without the + // running total a single request could buffer the whole heap away. + const chunks: Buffer[] = []; + let size = 0; for await (const chunk of req) { + size += chunk.length; + if (size > MAX_RESUME_BYTES) { + req.destroy(); + res.status(413).json({ error: "ResumeTooLarge" }); + return; + } chunks.push(chunk); } pdfBuffer = Buffer.concat(chunks); @@ -54,9 +68,7 @@ export default async function handler( return; } - const response = await fetch(resumeUrl); - const arrayBuffer = await response.arrayBuffer(); - pdfBuffer = Buffer.from(arrayBuffer); + pdfBuffer = await fetchRemoteResume(resumeUrl); } const parsed = await pdf(pdfBuffer); @@ -84,6 +96,14 @@ export default async function handler( return; } + // Bad or hostile resume URLs are the caller's fault, and each carries a + // stable code the badge can render. + if (e instanceof ResumeFetchError) { + console.warn(e); + res.status(e.status).json({ error: e.code }); + return; + } + if ( e.message.includes("InvalidPDFException") || e.message.includes("Invalid PDF structure") diff --git a/src/resume-checker/fetch-resume.ts b/src/resume-checker/fetch-resume.ts new file mode 100644 index 0000000..07b96f8 --- /dev/null +++ b/src/resume-checker/fetch-resume.ts @@ -0,0 +1,375 @@ +// SSRF-hardened fetch for the caller-supplied resume URL on /api/grade. +// +// The URL comes straight from whatever a visitor pastes into the resume +// checker, so it cannot be reduced to a host allowlist. Instead every hop is +// resolved ourselves, every resolved address is checked against the private / +// reserved ranges, and the connection is then pinned to the address we +// validated -- so a DNS rebind cannot swap in 169.254.169.254 between the +// check and the connect. On top of that the whole download is bounded by a +// wall-clock deadline and a byte cap, which the 300s route budget otherwise +// left wide open. + +import dns from "node:dns/promises"; +import https from "node:https"; +import net from "node:net"; + +export const MAX_RESUME_BYTES = 10 * 1024 * 1024; + +const REQUEST_TIMEOUT_MS = 20_000; +const MAX_REDIRECTS = 3; + +/** Carries a stable code the API route can hand back as a 4xx. */ +export class ResumeFetchError extends Error { + constructor( + readonly code: string, + readonly status: number, + ) { + super(code); + this.name = "ResumeFetchError"; + } +} + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split("."); + if (parts.length !== 4) return null; + + let value = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = Number(part); + if (octet > 255) return null; + value = value * 256 + octet; + } + return value >>> 0; +} + +function ipv6ToBytes(input: string): Uint8Array | null { + const ip = input.split("%")[0]; + const halves = ip.split("::"); + if (halves.length > 2) return null; + + const toWords = (part: string): number[] | null => { + if (!part) return []; + const words: number[] = []; + const tokens = part.split(":"); + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i]; + if (token.includes(".")) { + // Trailing dotted-quad, as in ::ffff:127.0.0.1. + if (i !== tokens.length - 1) return null; + const v4 = ipv4ToInt(token); + if (v4 === null) return null; + words.push((v4 >>> 16) & 0xffff, v4 & 0xffff); + continue; + } + if (!/^[0-9a-fA-F]{1,4}$/.test(token)) return null; + words.push(parseInt(token, 16)); + } + return words; + }; + + const head = toWords(halves[0]); + const tail = halves.length === 2 ? toWords(halves[1]) : []; + if (!head || !tail) return null; + + let words: number[]; + if (halves.length === 2) { + const fill = 8 - head.length - tail.length; + if (fill < 1) return null; + words = [...head, ...new Array(fill).fill(0), ...tail]; + } else { + words = head; + } + if (words.length !== 8) return null; + + const bytes = new Uint8Array(16); + words.forEach((word, i) => { + bytes[i * 2] = (word >>> 8) & 0xff; + bytes[i * 2 + 1] = word & 0xff; + }); + return bytes; +} + +// Everything that is not globally routable unicast: loopback, link-local +// (incl. the cloud metadata endpoint), RFC1918, CGNAT, benchmarking, +// documentation, multicast and reserved space. +const BLOCKED_V4: ReadonlyArray = [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +]; + +const BLOCKED_V6: ReadonlyArray = [ + ["::", 128], // unspecified + ["::1", 128], // loopback + ["100::", 64], // discard-only + ["2001:db8::", 32], // documentation + ["fc00::", 7], // unique local + ["fe80::", 10], // link local + ["ff00::", 8], // multicast +]; + +function inV4Cidr(value: number, base: string, prefix: number): boolean { + const baseValue = ipv4ToInt(base); + if (baseValue === null) return false; + const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0; + return ((value ^ baseValue) & mask) === 0; +} + +function inV6Cidr(bytes: Uint8Array, base: string, prefix: number): boolean { + const baseBytes = ipv6ToBytes(base); + if (!baseBytes) return false; + + const fullBytes = prefix >> 3; + for (let i = 0; i < fullBytes; i++) { + if (bytes[i] !== baseBytes[i]) return false; + } + const remainingBits = prefix & 7; + if (remainingBits === 0) return true; + + const mask = (0xff << (8 - remainingBits)) & 0xff; + return (bytes[fullBytes] & mask) === (baseBytes[fullBytes] & mask); +} + +function isPublicV4(value: number): boolean { + return !BLOCKED_V4.some(([base, prefix]) => inV4Cidr(value, base, prefix)); +} + +/** True only for addresses that are globally routable unicast. */ +export function isPublicAddress(address: string): boolean { + const family = net.isIP(address); + + if (family === 4) { + const value = ipv4ToInt(address); + return value !== null && isPublicV4(value); + } + + if (family !== 6) return false; + + const bytes = ipv6ToBytes(address); + if (!bytes) return false; + + if (BLOCKED_V6.some(([base, prefix]) => inV6Cidr(bytes, base, prefix))) { + return false; + } + + // Transition formats smuggle an IPv4 address inside an IPv6 one, so the + // embedded address has to clear the IPv4 rules too. + const embeddedV4 = (offset: number) => + ((bytes[offset] << 24) | + (bytes[offset + 1] << 16) | + (bytes[offset + 2] << 8) | + bytes[offset + 3]) >>> + 0; + + if (inV6Cidr(bytes, "::ffff:0:0", 96)) return isPublicV4(embeddedV4(12)); + if (inV6Cidr(bytes, "64:ff9b::", 96)) return isPublicV4(embeddedV4(12)); + if (inV6Cidr(bytes, "2002::", 16)) return isPublicV4(embeddedV4(2)); + + return true; +} + +function bareHostname(url: URL): string { + // URL keeps IPv6 literals bracketed; net/dns want them bare. + return url.hostname.startsWith("[") + ? url.hostname.slice(1, -1) + : url.hostname; +} + +function parseResumeUrl(raw: string): URL { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new ResumeFetchError("InvalidResumeURL", 400); + } + + if (url.protocol !== "https:") { + throw new ResumeFetchError("InvalidResumeURL", 400); + } + // Credentials in the URL are only ever useful for confusing a parser here. + if (url.username || url.password) { + throw new ResumeFetchError("InvalidResumeURL", 400); + } + // Non-443 ports turn this endpoint into an internal port scanner. + if (url.port && url.port !== "443") { + throw new ResumeFetchError("BlockedResumeURL", 400); + } + + return url; +} + +async function resolvePinnedAddress(url: URL): Promise { + const hostname = bareHostname(url); + + if (net.isIP(hostname)) { + if (!isPublicAddress(hostname)) { + throw new ResumeFetchError("BlockedResumeURL", 400); + } + return hostname; + } + + let records: Array<{ address: string }>; + try { + records = await dns.lookup(hostname, { all: true }); + } catch { + throw new ResumeFetchError("ResumeURLUnreachable", 400); + } + + // One bad record poisons the host: a round-robin that mixes a public and a + // private answer is exactly what an attacker would set up. + if ( + records.length === 0 || + !records.every((record) => isPublicAddress(record.address)) + ) { + throw new ResumeFetchError("BlockedResumeURL", 400); + } + + return records[0].address; +} + +type HopResult = + { kind: "body"; body: Buffer } | { kind: "redirect"; location: string }; + +function requestHop( + url: URL, + address: string, + deadline: number, +): Promise { + return new Promise((resolve, reject) => { + const budget = deadline - Date.now(); + if (budget <= 0) { + reject(new ResumeFetchError("ResumeFetchTimeout", 504)); + return; + } + + const hostname = bareHostname(url); + let settled = false; + + const finish = (result: HopResult) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve(result); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(error); + }; + + const request = https.request( + { + host: address, + port: 443, + path: `${url.pathname}${url.search}`, + method: "GET", + // Connect to the address we validated, but keep TLS and routing + // pointed at the name the caller asked for. + servername: net.isIP(hostname) ? undefined : hostname, + headers: { + host: url.host, + accept: "application/pdf,*/*", + "user-agent": "silver-resume-checker", + }, + timeout: budget, + }, + (response) => { + const status = response.statusCode ?? 0; + + if ([301, 302, 303, 307, 308].includes(status)) { + const location = response.headers.location; + response.resume(); + if (!location) { + fail(new ResumeFetchError("ResumeURLUnreachable", 400)); + return; + } + finish({ kind: "redirect", location }); + return; + } + + if (status < 200 || status >= 300) { + response.resume(); + fail(new ResumeFetchError("ResumeURLUnreachable", 400)); + return; + } + + const declared = Number(response.headers["content-length"]); + if (Number.isFinite(declared) && declared > MAX_RESUME_BYTES) { + abort(new ResumeFetchError("ResumeTooLarge", 413)); + return; + } + + const chunks: Buffer[] = []; + let size = 0; + + response.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_RESUME_BYTES) { + abort(new ResumeFetchError("ResumeTooLarge", 413)); + return; + } + chunks.push(chunk); + }); + response.on("end", () => + finish({ kind: "body", body: Buffer.concat(chunks) }), + ); + response.on("error", fail); + }, + ); + + // Settle first, then tear down: destroying the request races an + // ECONNRESET onto the error handler, which would mask the real reason. + const abort = (error: ResumeFetchError) => { + fail(error); + request.destroy(); + }; + + const timer = setTimeout( + () => abort(new ResumeFetchError("ResumeFetchTimeout", 504)), + budget, + ); + + request.on("timeout", () => + abort(new ResumeFetchError("ResumeFetchTimeout", 504)), + ); + request.on("error", () => + fail(new ResumeFetchError("ResumeURLUnreachable", 400)), + ); + request.end(); + }); +} + +/** + * Downloads a resume from a caller-supplied URL, refusing anything that points + * at private space and bounding the transfer in both time and bytes. + */ +export async function fetchRemoteResume(rawUrl: string): Promise { + const deadline = Date.now() + REQUEST_TIMEOUT_MS; + let target = parseResumeUrl(rawUrl); + + for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { + const address = await resolvePinnedAddress(target); + const result = await requestHop(target, address, deadline); + + if (result.kind === "body") return result.body; + + // Re-validate from scratch: a redirect is a brand new caller-supplied URL. + target = parseResumeUrl(new URL(result.location, target).toString()); + } + + throw new ResumeFetchError("TooManyRedirects", 400); +} diff --git a/src/resume-checker/utils.ts b/src/resume-checker/utils.ts index d46d577..31b7183 100644 --- a/src/resume-checker/utils.ts +++ b/src/resume-checker/utils.ts @@ -9,9 +9,17 @@ export const PAYLOAD_TOO_LARGE_MESSAGE = /* Keys are the error codes /api/grade answers with. */ const ERROR_MESSAGES: Record = { + BlockedResumeURL: "Ese link no apunta a un CV que podamos descargar.", InvalidPDFException: "No pudimos leer el PDF. Probá con otro archivo.", + InvalidResumeURL: "El link no es válido. Tiene que ser un PDF con https.", InvalidUploadRequest: "No pudimos leer el archivo que subiste.", MissingURL: "No encontramos el CV que querías analizar.", + ResumeFetchTimeout: + "El link tardó demasiado en responder. Probá subiendo el archivo.", + ResumeTooLarge: + "El PDF es demasiado grande. Probá con uno de menos de 10 MB.", + ResumeURLUnreachable: "No pudimos descargar el CV desde ese link.", + TooManyRedirects: "No pudimos descargar el CV desde ese link.", }; async function readErrorCode(response: Response) { @@ -31,11 +39,16 @@ async function readErrorCode(response: Response) { * and platform failures (413, gateway timeouts) are not JSON at all. */ export async function getErrorMessage(response: Response) { + /* The route's own 413 carries a code and its own limit; Vercel's does not. */ + const code = await readErrorCode(response); + + if (code && ERROR_MESSAGES[code]) { + return ERROR_MESSAGES[code]; + } + if (response.status === 413) { return PAYLOAD_TOO_LARGE_MESSAGE; } - const code = await readErrorCode(response); - - return (code && ERROR_MESSAGES[code]) || DEFAULT_ERROR_MESSAGE; + return DEFAULT_ERROR_MESSAGE; } diff --git a/tests/api/grade.test.ts b/tests/api/grade.test.ts index bc640db..602c7f0 100644 --- a/tests/api/grade.test.ts +++ b/tests/api/grade.test.ts @@ -106,6 +106,41 @@ describe("/api/grade", () => { expect(res.body).toEqual({ error: "MissingURL" }); }); + /* The URL is caller-supplied, so the route must not be a proxy into the VPC. */ + it.each([ + ["loopback", "https://127.0.0.1/cv.pdf"], + ["cloud metadata", "https://169.254.169.254/latest/meta-data/"], + ["decimal-encoded loopback", "https://2130706433/cv.pdf"], + ])("answers 400 to a GET for %s", async (_label, url) => { + const res = await call(request({ query: { url } })); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "BlockedResumeURL" }); + expect(pdf).not.toHaveBeenCalled(); + }); + + it("answers 400 to a GET for a non-https url", async () => { + const res = await call(request({ query: { url: "file:///etc/passwd" } })); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: "InvalidResumeURL" }); + }); + + /* bodyParser is off, so nothing else bounds the upload stream. */ + it("answers 413 to an upload over the size cap", async () => { + const res = await call( + request({ + method: "POST", + headers: MULTIPART, + body: "x".repeat(10 * 1024 * 1024 + 1), + }), + ); + + expect(res.status).toBe(413); + expect(res.body).toEqual({ error: "ResumeTooLarge" }); + expect(pdf).not.toHaveBeenCalled(); + }); + it("answers 405 to unsupported methods", async () => { const res = await call(request({ method: "PUT" })); diff --git a/tests/resume-checker/fetch-resume.test.ts b/tests/resume-checker/fetch-resume.test.ts new file mode 100644 index 0000000..dad2d7d --- /dev/null +++ b/tests/resume-checker/fetch-resume.test.ts @@ -0,0 +1,82 @@ +// Network-free coverage of the /api/grade SSRF guard: address classification +// and every URL shape that must be refused before a socket is opened. + +import { describe, expect, it } from "vitest"; +import { + fetchRemoteResume, + isPublicAddress, +} from "@/resume-checker/fetch-resume"; + +const PRIVATE_ADDRESSES = [ + "0.0.0.0", + "10.1.2.3", + "100.64.0.1", + "127.0.0.1", + "169.254.169.254", // cloud metadata + "172.16.0.1", + "172.31.255.255", + "192.0.0.1", + "192.168.1.1", + "198.18.0.1", + "224.0.0.1", + "255.255.255.255", + "::", + "::1", + "fe80::1", + "fc00::1", + "fd00::abcd", + "ff02::1", + "::ffff:127.0.0.1", // IPv4-mapped + "::ffff:169.254.169.254", + "64:ff9b::127.0.0.1", // NAT64 + "2002:7f00:1::", // 6to4 + "2001:db8::1", +]; + +const PUBLIC_ADDRESSES = [ + "1.1.1.1", + "8.8.8.8", + "172.15.0.1", // just outside 172.16/12 + "172.32.0.1", + "192.169.0.1", + "2606:4700:4700::1111", + "::ffff:8.8.8.8", + "2002:0808:0808::", +]; + +describe("isPublicAddress", () => { + it.each(PRIVATE_ADDRESSES)("blocks %s", (address) => { + expect(isPublicAddress(address)).toBe(false); + }); + + it.each(PUBLIC_ADDRESSES)("allows %s", (address) => { + expect(isPublicAddress(address)).toBe(true); + }); + + it("rejects anything that is not an address", () => { + expect(isPublicAddress("not-an-ip")).toBe(false); + expect(isPublicAddress("")).toBe(false); + }); +}); + +const REFUSED: ReadonlyArray = [ + ["not a url", "InvalidResumeURL"], + ["http://example.com/cv.pdf", "InvalidResumeURL"], + ["file:///etc/passwd", "InvalidResumeURL"], + ["gopher://example.com/", "InvalidResumeURL"], + ["https://user:pw@example.com/cv.pdf", "InvalidResumeURL"], + ["https://example.com:8443/cv.pdf", "BlockedResumeURL"], + ["https://example.com:22/cv.pdf", "BlockedResumeURL"], + ["https://127.0.0.1/cv.pdf", "BlockedResumeURL"], + ["https://[::1]/cv.pdf", "BlockedResumeURL"], + ["https://[::ffff:127.0.0.1]/cv.pdf", "BlockedResumeURL"], + ["https://169.254.169.254/latest/meta-data/", "BlockedResumeURL"], + ["https://2130706433/cv.pdf", "BlockedResumeURL"], // decimal loopback + ["https://0x7f000001/cv.pdf", "BlockedResumeURL"], // hex loopback +]; + +describe("fetchRemoteResume", () => { + it.each(REFUSED)("refuses %s with %s", async (url, code) => { + await expect(fetchRemoteResume(url)).rejects.toMatchObject({ code }); + }); +}); diff --git a/tests/resume-checker/utils.test.ts b/tests/resume-checker/utils.test.ts index e3752b2..3f2638d 100644 --- a/tests/resume-checker/utils.test.ts +++ b/tests/resume-checker/utils.test.ts @@ -32,6 +32,26 @@ describe("getErrorMessage", () => { ).resolves.toBe("No encontramos el CV que querías analizar."); }); + it("translates the codes the SSRF guard answers with", async () => { + await expect( + getErrorMessage(json(400, { error: "BlockedResumeURL" })), + ).resolves.toBe("Ese link no apunta a un CV que podamos descargar."); + await expect( + getErrorMessage(json(504, { error: "ResumeFetchTimeout" })), + ).resolves.toBe( + "El link tardó demasiado en responder. Probá subiendo el archivo.", + ); + }); + + /* Our own 413 knows the route's limit; Vercel's plain-text one does not. */ + it("prefers the route's own limit over the platform message on a 413", async () => { + await expect( + getErrorMessage(json(413, { error: "ResumeTooLarge" })), + ).resolves.toBe( + "El PDF es demasiado grande. Probá con uno de menos de 10 MB.", + ); + }); + /* Vercel rejects oversized uploads itself, with a plain-text body. */ it("explains a payload rejected before the route ran", async () => { await expect(