From ff92a51dbf2d493c042eba1961244f512132d284 Mon Sep 17 00:00:00 2001 From: Frode Aannevik Date: Thu, 20 Aug 2026 01:39:07 +0200 Subject: [PATCH] fix: harden pinned http requests without undici Drop undici to cut a 2.3 MB dependency, using node:http/https with the same DNS-pinned lookup --- docs/options.md | 1 + lib/resolvers/http.ts | 267 +++++++++++++++++++------- lib/types/index.ts | 5 + package.json | 3 +- pnpm-lock.yaml | 3 - test/specs/http-security.spec.ts | 314 ++++++++++++++++++++++--------- 6 files changed, 438 insertions(+), 155 deletions(-) diff --git a/docs/options.md b/docs/options.md index 7a0d040b..aa81d819 100644 --- a/docs/options.md +++ b/docs/options.md @@ -76,6 +76,7 @@ JSON Schema $Ref Parser comes with built-in support for HTTP and HTTPS, as well | `http.timeout` | `number` | The amount of time (in milliseconds) to wait for a response from the server when downloading files. The default is 60 seconds. | | `http.redirects` | `number` | The maximum number of HTTP redirects to follow per file. The default is 5. To disable automatic following of redirects, set this to zero. | | `http.withCredentials` | `boolean` | Set this to `true` if you're downloading files from a CORS-enabled server that requires authentication | +| `http.maxContentLength` | `number` | The maximum size (in bytes) of a downloaded file. Larger responses are rejected. The default is 50 MB. | `resolveExcludedPathMatcher(path, value)` is a top-level option that stops resolution and fetching for a root-relative occurrence and all of its descendants. It is separate from the `resolve` map because every key in that map can be a custom resolver plugin. A reference and its resolved effective value can both be evaluated at the same root-relative path; extended-reference values are merged before their descendants are inspected. Internal alias traversal uses `dereference.maxDepth` as its safety limit. diff --git a/lib/resolvers/http.ts b/lib/resolvers/http.ts index 3772d8c8..c783a3d8 100644 --- a/lib/resolvers/http.ts +++ b/lib/resolvers/http.ts @@ -1,18 +1,24 @@ import * as url from "../util/url.js"; import { ResolverError } from "../util/errors.js"; import type { FileInfo, HTTPResolverOptions, JSONSchema } from "../types/index.js"; +import type { IncomingMessage } from "node:http"; +import type { RequestOptions, request as httpsRequest } from "node:https"; import type { LookupFunction } from "node:net"; -import type { Agent } from "undici"; -interface UndiciModule { - Agent: new (options?: { connect?: { lookup: LookupFunction }; autoSelectFamily?: boolean }) => Agent; - fetch(input: URL | string, init?: RequestInit & { dispatcher?: Agent }): Promise; -} - -interface PinnedTransport { - dispatcher: Agent; - fetch: UndiciModule["fetch"]; -} +const DEFAULT_MAX_CONTENT_LENGTH = 50 * 1024 * 1024; + +const FORBIDDEN_PINNED_REQUEST_HEADERS = [ + "connection", + "content-length", + "expect", + "host", + "keep-alive", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; export default { /** @@ -53,6 +59,11 @@ export default { */ safeUrlResolver: true, + /** + * The maximum size of a downloaded file (in bytes). Larger responses are rejected. + */ + maxContentLength: DEFAULT_MAX_CONTENT_LENGTH, + /** * Determines whether this resolver can read a given file reference. * Resolvers that return true will be tried in order, until one successfully resolves the file. @@ -116,7 +127,7 @@ async function download( error.status = res.status; throw new ResolverError(error); } else { - const location = getHeader(res, "location"); + const location = res.getHeader("location"); if (!location) { const error = new Error(`HTTP ${res.status} redirect with no location header`) as Error & { status?: number }; @@ -130,11 +141,7 @@ async function download( return download(redirectTo, redirectOptions, redirects); } } else { - if (res.body) { - const buf = await res.arrayBuffer(); - return Buffer.from(buf); - } - return Buffer.alloc(0); + return await res.readBody(httpOptions.maxContentLength ?? DEFAULT_MAX_CONTENT_LENGTH); } } catch (err: unknown) { const cause = err instanceof Error ? err : new Error(String(err)); @@ -147,21 +154,25 @@ async function download( throw new ResolverError(wrappedError, u.href); } finally { pendingResponse?.cancelTimeout(); - const response = pendingResponse?.response; - if (response?.body && !response.bodyUsed) { - await response.body.cancel().catch(() => undefined); - } + await pendingResponse?.response.discard(); } } +interface HttpResponse { + status: number; + getHeader(name: string): string | null; + readBody(maxContentLength: number): Promise; + discard(): Promise; +} + interface PendingResponse { - response: Response; + response: HttpResponse; cancelTimeout(): void; } /** * Sends an HTTP GET request. - * The promise resolves with the HTTP Response object and a timeout cleanup + * The promise resolves with the HTTP response and a timeout cleanup * function. The caller keeps the timeout active until it has consumed the * response body. */ @@ -170,9 +181,6 @@ async function get( httpOptions: HTTPResolverOptions, resolvedAddresses?: readonly url.ResolvedUrlAddress[], ): Promise { - const pinnedTransport = - resolvedAddresses && resolvedAddresses.length > 0 ? await createPinnedTransport(u, resolvedAddresses) : undefined; - const dispatcher = pinnedTransport?.dispatcher; let controller: AbortController | undefined; let timeoutId: ReturnType | undefined; if (httpOptions.timeout && typeof AbortController !== "undefined") { @@ -186,26 +194,21 @@ async function get( clearTimeout(timeoutId); timeoutId = undefined; } - if (dispatcher && !dispatcher.destroyed) { - void dispatcher.destroy().catch(() => undefined); - } }; try { - const requestOptions: RequestInit & { dispatcher?: Agent } = { - method: "GET", - headers: httpOptions.headers || {}, - credentials: httpOptions.withCredentials ? "include" : "same-origin", - redirect: "manual", - signal: controller ? controller.signal : null, - }; - if (dispatcher) { - requestOptions.dispatcher = dispatcher; - } - - const response = pinnedTransport - ? ((await pinnedTransport.fetch(u, requestOptions)) as Response) - : await fetch(u, requestOptions); + const response = + resolvedAddresses && resolvedAddresses.length > 0 + ? await pinnedGet(u, httpOptions, resolvedAddresses, controller?.signal) + : fromFetchResponse( + await fetch(u, { + method: "GET", + headers: httpOptions.headers || {}, + credentials: httpOptions.withCredentials ? "include" : "same-origin", + redirect: "manual", + signal: controller ? controller.signal : null, + }), + ); return { response, cancelTimeout }; } catch (error) { @@ -214,28 +217,172 @@ async function get( } } +function fromFetchResponse(response: Response): HttpResponse { + return { + status: response.status, + getHeader: (name) => response.headers.get(name), + readBody: (maxContentLength) => readFetchBody(response, maxContentLength), + async discard() { + if (response.body && !response.bodyUsed) { + await response.body.cancel().catch(() => undefined); + } + }, + }; +} + +async function readFetchBody(response: Response, maxContentLength: number): Promise { + assertContentLengthWithin(response.headers.get("content-length"), maxContentLength); + if (!response.body) { + return Buffer.alloc(0); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let received = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + return Buffer.concat(chunks, received); + } + received += value.byteLength; + if (received > maxContentLength) { + await reader.cancel().catch(() => undefined); + throw contentTooLargeError(maxContentLength); + } + chunks.push(value); + } +} + /** - * Creates a per-request dispatcher whose DNS callback can only return addresses - * from the validation lookup. The URL hostname is still used for the Host - * header and TLS SNI/certificate verification. + * Sends the request with Node's own HTTP client, with DNS resolution pinned to + * the addresses that passed the safety check, so a second lookup (e.g. a DNS + * rebind) can't send the connection elsewhere. The URL hostname is still used + * for TLS SNI/certificate verification. */ -async function createPinnedTransport( - requestUrl: URL, +async function pinnedGet( + u: URL, + httpOptions: HTTPResolverOptions, resolvedAddresses: readonly url.ResolvedUrlAddress[], -): Promise { + signal: AbortSignal | undefined, +): Promise { // Keep the Node-only implementation out of browser module graphs. - const undiciModuleName = "undici"; - const undici = (await import(undiciModuleName)) as UndiciModule; - const expectedHostname = normalizeLookupHostname(requestUrl.hostname); + const httpModuleName = u.protocol === "https:" ? "node:https" : "node:http"; + const { request } = (await import(httpModuleName)) as { request: typeof httpsRequest }; + + const requestOptions: RequestOptions & { autoSelectFamily: boolean } = { + method: "GET", + headers: pinnedRequestHeaders(httpOptions.headers), + lookup: createPinnedLookup(u.hostname, resolvedAddresses), + agent: false, + autoSelectFamily: true, + }; + if (signal) { + requestOptions.signal = signal; + } + + const res = await new Promise((resolve, reject) => { + const req = request(u, requestOptions, resolve); + req.on("error", reject); + req.end(); + }); + res.on("error", () => undefined); + + const status = res.statusCode; + if (status === undefined || status < 200 || status > 599) { + res.destroy(); + throw new Error(`Invalid HTTP status code: ${status}`); + } + + return { + status, + getHeader(name) { + const value = res.headers[name.toLowerCase()]; + return Array.isArray(value) ? value.join(", ") : (value ?? null); + }, + readBody: (maxContentLength) => readIncomingBody(res, maxContentLength), + async discard() { + if (!res.destroyed) { + res.destroy(); + } + }, + }; +} + +function pinnedRequestHeaders(headers: HTTPResolverOptions["headers"]): Record { + const merged = new Headers({ accept: "*/*", "user-agent": "node" }); + new Headers(headers ?? undefined).forEach((value, name) => merged.set(name, value)); + for (const name of FORBIDDEN_PINNED_REQUEST_HEADERS) { + merged.delete(name); + } + merged.set("accept-encoding", "identity"); + + const result: Record = {}; + merged.forEach((value, name) => { + result[name] = value; + }); + return result; +} + +async function readIncomingBody(res: IncomingMessage, maxContentLength: number): Promise { + assertIdentityEncoding(res.headers["content-encoding"]); + assertContentLengthWithin(res.headers["content-length"], maxContentLength); + const incompleteBodyError = new Error("The connection was closed before the response body was complete"); + if (res.destroyed) { + throw incompleteBodyError; + } + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let received = 0; + res.on("data", (chunk: Buffer) => { + received += chunk.length; + if (received > maxContentLength) { + res.destroy(contentTooLargeError(maxContentLength)); + } else { + chunks.push(chunk); + } + }); + res.on("end", () => resolve(Buffer.concat(chunks, received))); + res.on("error", reject); + res.on("close", () => reject(incompleteBodyError)); + }); +} + +function assertIdentityEncoding(contentEncoding: string | undefined) { + const codings = (contentEncoding ?? "") + .split(",") + .map((coding) => coding.trim().toLowerCase()) + .filter((coding) => coding && coding !== "identity"); + if (codings.length > 0) { + throw new Error(`Unsupported content-encoding: ${codings.join(", ")}`); + } +} + +function assertContentLengthWithin(contentLength: string | null | undefined, maxContentLength: number) { + if (contentLength && Number(contentLength) > maxContentLength) { + throw contentTooLargeError(maxContentLength); + } +} + +function contentTooLargeError(maxContentLength: number): Error { + return new Error(`Response body exceeds the maxContentLength of ${maxContentLength} bytes`); +} + +/** + * Builds a DNS lookup that can only return addresses from the validation + * lookup, and refuses any other hostname. + */ +function createPinnedLookup(hostname: string, resolvedAddresses: readonly url.ResolvedUrlAddress[]): LookupFunction { + const expectedHostname = normalizeLookupHostname(hostname); const pinnedAddresses = resolvedAddresses.map(({ address, family }) => ({ address, family })); - const lookup: LookupFunction = (hostname, options, callback) => { - const normalizedHostname = normalizeLookupHostname(hostname); + return (lookupHostname, options, callback) => { + const normalizedHostname = normalizeLookupHostname(lookupHostname); const requestedFamily = options.family === "IPv4" ? 4 : options.family === "IPv6" ? 6 : options.family || 0; const candidates = pinnedAddresses.filter(({ family }) => requestedFamily === 0 || family === requestedFamily); if (normalizedHostname !== expectedHostname || candidates.length === 0) { - const error = new Error(`No validated address is available for ${hostname}`) as NodeJS.ErrnoException; + const error = new Error(`No validated address is available for ${lookupHostname}`) as NodeJS.ErrnoException; error.code = "ENOTFOUND"; callback(error, "", 0); } else if (options.all) { @@ -245,14 +392,6 @@ async function createPinnedTransport( callback(null, selected.address, selected.family); } }; - - return { - dispatcher: new undici.Agent({ - connect: { lookup }, - autoSelectFamily: true, - }), - fetch: (input, init) => undici.fetch(input, init), - }; } function normalizeLookupHostname(hostname: string): string { @@ -262,10 +401,6 @@ function normalizeLookupHostname(hostname: string): string { .toLowerCase(); } -function getHeader(response: Response, name: string): string | null { - return response.headers.get(name); -} - function withoutSensitiveHeaders(httpOptions: HTTPResolverOptions): HTTPResolverOptions { if (!httpOptions.headers) { return httpOptions; diff --git a/lib/types/index.ts b/lib/types/index.ts index f5f4cc14..ba8f6d89 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -49,6 +49,11 @@ export interface HTTPResolverOptions extends Part * Set this to `false` if you want to allow unsafe URLs (e.g., `127.0.0.1`, localhost, and other internal URLs). */ safeUrlResolver?: boolean; + + /** + * The maximum size (in bytes) of a downloaded file. Larger responses are rejected. The default is 50 MB. + */ + maxContentLength?: number; } /** diff --git a/package.json b/package.json index 87715669..62bb8995 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,7 @@ "@types/json-schema": "^7.0.15" }, "dependencies": { - "js-yaml": "^5.4.2", - "undici": "^8.10.2" + "js-yaml": "^5.4.2" }, "devDependencies": { "@types/json-schema": "^7.0.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92e9473a..0492be6b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,9 +169,6 @@ importers: js-yaml: specifier: ^5.4.2 version: 5.4.2 - undici: - specifier: ^8.10.2 - version: 8.10.2 devDependencies: '@types/json-schema': specifier: ^7.0.15 diff --git a/test/specs/http-security.spec.ts b/test/specs/http-security.spec.ts index a2745d21..f56c5f7e 100644 --- a/test/specs/http-security.spec.ts +++ b/test/specs/http-security.spec.ts @@ -1,39 +1,82 @@ /// +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { gzipSync } from "node:zlib"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import $RefParser from "../../lib/index.js"; const lookupMock = vi.hoisted(() => vi.fn()); -const undiciMock = vi.hoisted(() => ({ - fetch: vi.fn(), - agentOptions: [] as Array<{ - connect?: { lookup?: unknown }; - autoSelectFamily?: boolean; - }>, - agents: [] as Array<{ destroyed: boolean }>, -})); +const httpsRequestMock = vi.hoisted(() => vi.fn()); vi.mock("node:dns/promises", () => ({ lookup: lookupMock, })); -vi.mock("undici", () => ({ - Agent: class { - destroyed = false; - - constructor(options: (typeof undiciMock.agentOptions)[number]) { - undiciMock.agentOptions.push(options); - undiciMock.agents.push(this); - } - - async destroy() { - this.destroyed = true; - } - }, - fetch: undiciMock.fetch, +vi.mock("node:https", () => ({ + request: httpsRequestMock, })); const isBrowser = typeof window !== "undefined"; +type FakeIncomingMessage = Readable & { statusCode: number; headers: Record; complete: boolean }; + +interface PinnedRequestOptions { + headers: Record; + lookup: ( + hostname: string, + options: { all: boolean; family: number }, + callback: ( + error: Error | null, + addresses: string | Array<{ address: string; family: number }>, + family?: number, + ) => void, + ) => void; + agent: unknown; + autoSelectFamily: boolean; + signal?: AbortSignal; +} + +function fakeResponse( + statusCode: number, + headers: Record = {}, + body: Buffer | Readable = Buffer.alloc(0), +): FakeIncomingMessage { + const stream = body instanceof Readable ? body : Readable.from(body.length > 0 ? [body] : []); + const res = Object.assign(stream, { + statusCode, + headers: Object.fromEntries(Object.entries(headers).map(([name, value]) => [name.toLowerCase(), value])), + complete: false, + }); + res.on("end", () => { + res.complete = true; + }); + return res; +} + +function jsonResponse(value: unknown) { + return fakeResponse(200, { "content-type": "application/json" }, Buffer.from(JSON.stringify(value))); +} + +function stalledBody() { + return new Readable({ read() {} }); +} + +function mockRequestOnce(res: FakeIncomingMessage) { + httpsRequestMock.mockImplementationOnce( + (_url: URL, options: PinnedRequestOptions, callback: (res: FakeIncomingMessage) => void) => { + const req = Object.assign(new EventEmitter(), { + end: () => queueMicrotask(() => callback(res)), + }); + options.signal?.addEventListener("abort", () => res.destroy(new Error("aborted")), { once: true }); + return req; + }, + ); +} + +function requestOptions(call: number): PinnedRequestOptions { + return httpsRequestMock.mock.calls[call]?.[1]; +} + describe.skipIf(isBrowser)("HTTP request security", () => { beforeEach(() => { lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]); @@ -43,9 +86,7 @@ describe.skipIf(isBrowser)("HTTP request security", () => { vi.useRealTimers(); vi.restoreAllMocks(); lookupMock.mockReset(); - undiciMock.fetch.mockReset(); - undiciMock.agentOptions.length = 0; - undiciMock.agents.length = 0; + httpsRequestMock.mockReset(); }); it("blocks hostnames that resolve to a private address before fetching", async () => { @@ -59,19 +100,15 @@ describe.skipIf(isBrowser)("HTTP request security", () => { expect(lookupMock).toHaveBeenCalledWith("public-looking.example", { all: true, verbatim: true }); expect(fetchMock).not.toHaveBeenCalled(); - expect(undiciMock.fetch).not.toHaveBeenCalled(); + expect(httpsRequestMock).not.toHaveBeenCalled(); }); it("validates a redirect target's resolved addresses before fetching it", async () => { lookupMock .mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]) .mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]); - undiciMock.fetch.mockResolvedValueOnce( - new Response(null, { - status: 302, - headers: { location: "https://rebinding.example/schema.json" }, - }), - ); + const redirect = fakeResponse(302, { location: "https://rebinding.example/schema.json" }, stalledBody()); + mockRequestOnce(redirect); const parser = new $RefParser(); await expect(parser.parse("https://trusted.example/schema.json")).rejects.toThrow( @@ -79,7 +116,8 @@ describe.skipIf(isBrowser)("HTTP request security", () => { ); expect(lookupMock).toHaveBeenNthCalledWith(2, "rebinding.example", { all: true, verbatim: true }); - expect(undiciMock.fetch).toHaveBeenCalledTimes(1); + expect(httpsRequestMock).toHaveBeenCalledTimes(1); + expect(redirect.destroyed).toBe(true); }); it("preserves Headers options and strips credentials on cross-origin redirects", async () => { @@ -89,19 +127,8 @@ describe.skipIf(isBrowser)("HTTP request security", () => { "Proxy-Authorization": "Basic top-secret", "X-Request-ID": "safe-to-forward", }); - const fetchMock = undiciMock.fetch - .mockResolvedValueOnce( - new Response(null, { - status: 302, - headers: { location: "https://attacker.example/schema.json" }, - }), - ) - .mockResolvedValueOnce( - new Response(JSON.stringify({ type: "string" }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); + mockRequestOnce(fakeResponse(302, { location: "https://attacker.example/schema.json" })); + mockRequestOnce(jsonResponse({ type: "string" })); const parser = new $RefParser(); await expect( @@ -110,17 +137,17 @@ describe.skipIf(isBrowser)("HTTP request security", () => { }), ).resolves.toEqual({ type: "string" }); - const firstRequestHeaders = fetchMock.mock.calls[0]?.[1]?.headers; - const redirectedHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers); - - expect(firstRequestHeaders).toBe(headers); - expect(redirectedHeaders.get("authorization")).toBeNull(); - expect(redirectedHeaders.get("proxy-authorization")).toBeNull(); - expect(redirectedHeaders.get("cookie")).toBeNull(); - expect(redirectedHeaders.get("x-request-id")).toBe("safe-to-forward"); - expect(fetchMock.mock.calls[0]?.[1]?.dispatcher).toBe(undiciMock.agents[0]); - expect(fetchMock.mock.calls[1]?.[1]?.dispatcher).toBe(undiciMock.agents[1]); - expect(undiciMock.agents.every(({ destroyed }) => destroyed)).toBe(true); + expect(requestOptions(0).headers).toMatchObject({ + authorization: "Bearer top-secret", + cookie: "session=top-secret", + "proxy-authorization": "Basic top-secret", + "x-request-id": "safe-to-forward", + }); + const redirectedHeaders = requestOptions(1).headers; + expect(redirectedHeaders).not.toHaveProperty("authorization"); + expect(redirectedHeaders).not.toHaveProperty("proxy-authorization"); + expect(redirectedHeaders).not.toHaveProperty("cookie"); + expect(redirectedHeaders["x-request-id"]).toBe("safe-to-forward"); }); it("pins the request lookup to the exact addresses that passed validation", async () => { @@ -128,42 +155,161 @@ describe.skipIf(isBrowser)("HTTP request security", () => { { address: "93.184.216.34", family: 4 }, { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 }, ]); - undiciMock.fetch.mockResolvedValueOnce( - new Response(JSON.stringify({ type: "string" }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); + mockRequestOnce(jsonResponse({ type: "string" })); const parser = new $RefParser(); await expect(parser.parse("https://trusted.example/schema.json")).resolves.toEqual({ type: "string" }); - const lookup = undiciMock.agentOptions[0]?.connect?.lookup as ( - hostname: string, - options: { all: boolean; family: number }, - callback: ( - error: Error | null, - addresses: string | Array<{ address: string; family: number }>, - family?: number, - ) => void, - ) => void; - const pinnedAddresses = await new Promise>((resolve, reject) => { - lookup("trusted.example", { all: true, family: 0 }, (error, addresses) => { - if (error) { - reject(error); - } else if (Array.isArray(addresses)) { - resolve(addresses); - } else { - reject(new Error("Expected the pinned lookup to return all addresses")); - } + const { lookup, agent, autoSelectFamily } = requestOptions(0); + const lookupAll = (hostname: string) => + new Promise>((resolve, reject) => { + lookup(hostname, { all: true, family: 0 }, (error, addresses) => { + if (error) { + reject(error); + } else if (Array.isArray(addresses)) { + resolve(addresses); + } else { + reject(new Error("Expected the pinned lookup to return all addresses")); + } + }); }); - }); - expect(pinnedAddresses).toEqual([ + await expect(lookupAll("trusted.example")).resolves.toEqual([ { address: "93.184.216.34", family: 4 }, { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 }, ]); - expect(undiciMock.agentOptions[0]?.autoSelectFamily).toBe(true); + await expect(lookupAll("other.example")).rejects.toThrow("No validated address is available for other.example"); + expect(agent).toBe(false); + expect(autoSelectFamily).toBe(true); + }); + + it("requests identity encoding and strips connection headers", async () => { + mockRequestOnce(jsonResponse({ type: "string" })); + + const parser = new $RefParser(); + await parser.parse("https://trusted.example/schema.json", { + resolve: { + http: { + headers: { + Host: "internal.example", + "Accept-Encoding": "gzip", + Connection: "upgrade", + Upgrade: "websocket", + "Transfer-Encoding": "chunked", + "X-Custom": "kept", + }, + }, + }, + }); + + expect(requestOptions(0).headers).toEqual({ + accept: "*/*", + "accept-encoding": "identity", + "user-agent": "node", + "x-custom": "kept", + }); + }); + + it("rejects compressed responses", async () => { + const body = gzipSync(Buffer.from(JSON.stringify({ type: "string" }))); + const res = fakeResponse(200, { "content-type": "application/json", "content-encoding": "gzip" }, body); + mockRequestOnce(res); + + const parser = new $RefParser(); + await expect(parser.parse("https://trusted.example/schema.json")).rejects.toThrow( + "Unsupported content-encoding: gzip", + ); + + expect(res.destroyed).toBe(true); + }); + + it("follows redirects regardless of content-encoding", async () => { + mockRequestOnce(fakeResponse(302, { location: "/target.json", "content-encoding": "br, br, br" })); + mockRequestOnce(jsonResponse({ type: "string" })); + + const parser = new $RefParser(); + await expect(parser.parse("https://trusted.example/schema.json")).resolves.toEqual({ type: "string" }); + }); + + it("rejects an invalid HTTP status", async () => { + const res = fakeResponse(600, {}, stalledBody()); + mockRequestOnce(res); + + const parser = new $RefParser(); + await expect(parser.parse("https://trusted.example/schema.json")).rejects.toThrow("Invalid HTTP status code: 600"); + + expect(res.destroyed).toBe(true); + }); + + it("rejects a content-length above maxContentLength", async () => { + const res = fakeResponse(200, { "content-length": "11" }, stalledBody()); + mockRequestOnce(res); + + const parser = new $RefParser(); + await expect( + parser.parse("https://trusted.example/schema.json", { resolve: { http: { maxContentLength: 10 } } }), + ).rejects.toThrow("Response body exceeds the maxContentLength of 10 bytes"); + + expect(res.destroyed).toBe(true); + }); + + it("stops reading a body above maxContentLength", async () => { + const body = stalledBody(); + const res = fakeResponse(200, {}, body); + mockRequestOnce(res); + + const parser = new $RefParser(); + const parsing = parser.parse("https://trusted.example/schema.json", { + resolve: { http: { maxContentLength: 10 } }, + }); + body.push(Buffer.alloc(6)); + body.push(Buffer.alloc(6)); + + await expect(parsing).rejects.toThrow("Response body exceeds the maxContentLength of 10 bytes"); + expect(res.destroyed).toBe(true); + }); + + it("rejects a truncated response body", async () => { + const body = stalledBody(); + mockRequestOnce(fakeResponse(200, { "content-type": "application/json" }, body)); + + const parser = new $RefParser(); + const parsing = parser.parse("https://trusted.example/schema.json"); + body.push(Buffer.from('{"type":')); + setTimeout(() => body.destroy(), 0); + + await expect(parsing).rejects.toThrow("The connection was closed before the response body was complete"); + }); + + it("keeps the timeout active for a pinned response body", async () => { + vi.useFakeTimers(); + const res = fakeResponse(200, { "content-type": "application/json" }, stalledBody()); + mockRequestOnce(res); + + const parser = new $RefParser(); + const rejection = parser + .parse("https://trusted.example/stalled.json", { resolve: { http: { timeout: 25 } } }) + .catch((error: unknown) => error); + + await vi.advanceTimersByTimeAsync(26); + const error = await rejection; + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain("Error downloading https://trusted.example/stalled.json"); + expect(requestOptions(0).signal?.aborted).toBe(true); + expect(res.destroyed).toBe(true); + expect(vi.getTimerCount()).toBe(0); + }); + + it("applies maxContentLength to fetch responses", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(new Response("x".repeat(11))); + + const parser = new $RefParser(); + await expect( + parser.parse("https://example.com/schema.json", { + resolve: { http: { safeUrlResolver: false, maxContentLength: 10 } }, + }), + ).rejects.toThrow("Response body exceeds the maxContentLength of 10 bytes"); }); it("keeps the timeout active while consuming the response body", async () => {