UNPKG

@apidevtools/json-schema-ref-parser

Version:

Parse, Resolve, and Dereference JSON Schema $ref pointers

277 lines (242 loc) 8.99 kB
import * as url from "../util/url.js"; import { ResolverError } from "../util/errors.js"; import type { FileInfo, HTTPResolverOptions, JSONSchema } from "../types/index.js"; 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<unknown>; } interface PinnedTransport { dispatcher: Agent; fetch: UndiciModule["fetch"]; } export default { /** * The order that this resolver will run, in relation to other resolvers. */ order: 200, /** * HTTP headers to send when downloading files. * * @example: * { * "User-Agent": "JSON Schema $Ref Parser", * Accept: "application/json" * } */ headers: null, /** * HTTP request timeout (in milliseconds). */ timeout: 60_000, // 60 seconds /** * The maximum number of HTTP redirects to follow. * To disable automatic following of redirects, set this to zero. */ redirects: 5, /** * The `withCredentials` option of XMLHttpRequest. * Set this to `true` if you're downloading files from a CORS-enabled server that requires authentication */ withCredentials: false, /** * Set this to `false` if you want to allow unsafe URLs (e.g., `127.0.0.1`, localhost, and other internal URLs). */ safeUrlResolver: true, /** * 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. * Resolvers that return false will not be given a chance to resolve the file. */ canRead(file: FileInfo) { return url.isHttp(file.url) && (!this.safeUrlResolver || !url.isUnsafeUrl(file.url)); }, /** * Reads the given URL and returns its raw contents as a Buffer. */ read(file: FileInfo) { const u = url.parse(file.url); if (typeof window !== "undefined" && !u.protocol) { // Use the protocol of the current page u.protocol = url.parse(location.href).protocol; } return download(u, this); }, } as HTTPResolverOptions<JSONSchema>; /** * Downloads the given file. * @returns * The promise resolves with the raw downloaded data, or rejects if there is an HTTP error. */ async function download<S extends object = JSONSchema>( u: URL | string, httpOptions: HTTPResolverOptions<S>, _redirects?: string[], ): Promise<Buffer> { u = url.parse(u); const redirects = _redirects || []; redirects.push(u.href); let pendingResponse: PendingResponse | undefined; try { let resolvedAddresses: url.ResolvedUrlAddress[] | undefined; if (httpOptions.safeUrlResolver) { const safety = await url.resolveUrlSafety(u.href); if (safety.unsafe) { throw new Error(`Unsafe URL blocked by safeUrlResolver: ${u.href}`); } resolvedAddresses = safety.addresses; } pendingResponse = await get(u, httpOptions, resolvedAddresses); const res = pendingResponse.response; if (res.status >= 400) { const error = new Error(`HTTP ERROR ${res.status}`) as Error & { status?: number }; error.status = res.status; throw error; } else if (res.status >= 300) { if (!Number.isNaN(httpOptions.redirects) && redirects.length > httpOptions.redirects!) { const error = new Error( `Error downloading ${redirects[0]}. \nToo many redirects: \n ${redirects.join(" \n ")}`, ) as Error & { status?: number }; error.status = res.status; throw new ResolverError(error); } else { const location = getHeader(res, "location"); if (!location) { const error = new Error(`HTTP ${res.status} redirect with no location header`) as Error & { status?: number }; error.status = res.status; throw error; } const redirectTo = url.resolve(u.href, location); const redirectOptions = url.parse(redirectTo).origin === u.origin ? httpOptions : withoutSensitiveHeaders(httpOptions); return download(redirectTo, redirectOptions, redirects); } } else { if (res.body) { const buf = await res.arrayBuffer(); return Buffer.from(buf); } return Buffer.alloc(0); } } catch (err: unknown) { const cause = err instanceof Error ? err : new Error(String(err)); const wrappedError = new Error(`Error downloading ${u.href}: ${cause.message}`, { cause }); if ("code" in cause) { (wrappedError as Error & { code?: unknown }).code = cause.code; } throw new ResolverError(wrappedError, u.href); } finally { pendingResponse?.cancelTimeout(); } } interface PendingResponse { response: Response; cancelTimeout(): void; } /** * Sends an HTTP GET request. * The promise resolves with the HTTP Response object and a timeout cleanup * function. The caller keeps the timeout active until it has consumed the * response body. */ async function get<S extends object = JSONSchema>( u: URL, httpOptions: HTTPResolverOptions<S>, resolvedAddresses?: readonly url.ResolvedUrlAddress[], ): Promise<PendingResponse> { const pinnedTransport = resolvedAddresses && resolvedAddresses.length > 0 ? await createPinnedTransport(u, resolvedAddresses) : undefined; const dispatcher = pinnedTransport?.dispatcher; let controller: AbortController | undefined; let timeoutId: ReturnType<typeof setTimeout> | undefined; if (httpOptions.timeout && typeof AbortController !== "undefined") { const abortController = new AbortController(); controller = abortController; timeoutId = setTimeout(() => abortController.abort(), httpOptions.timeout); } const cancelTimeout = () => { if (timeoutId !== undefined) { 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); return { response, cancelTimeout }; } catch (error) { cancelTimeout(); throw error; } } /** * 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. */ async function createPinnedTransport( requestUrl: URL, resolvedAddresses: readonly url.ResolvedUrlAddress[], ): Promise<PinnedTransport> { // 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 pinnedAddresses = resolvedAddresses.map(({ address, family }) => ({ address, family })); const lookup: LookupFunction = (hostname, options, callback) => { const normalizedHostname = normalizeLookupHostname(hostname); 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; error.code = "ENOTFOUND"; callback(error, "", 0); } else if (options.all) { callback(null, candidates); } else { const selected = candidates[0]; 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 { return hostname .replace(/^\[|\]$/g, "") .replace(/\.+$/, "") .toLowerCase(); } function getHeader(response: Response, name: string): string | null { return response.headers.get(name); } function withoutSensitiveHeaders<S extends object>(httpOptions: HTTPResolverOptions<S>): HTTPResolverOptions<S> { if (!httpOptions.headers) { return httpOptions; } const headers = new Headers(httpOptions.headers); headers.delete("authorization"); headers.delete("proxy-authorization"); headers.delete("cookie"); return { ...httpOptions, headers }; }