@amritk/resolve-refs
Advanced tools
| /** | ||
| * Assigns `value` under `key` on a rebuilt object without letting a `__proto__` | ||
| * key hit the prototype setter. Ref resolution rebuilds every object node of a | ||
| * document — including remote/external content — so a plain `target[key] = value` | ||
| * would let a `{ "__proto__": … }` node corrupt the rebuilt object's prototype. | ||
| * Defining it as an own data property preserves the key verbatim instead. | ||
| */ | ||
| export declare const assignKey: (target: Record<string, unknown>, key: string, value: unknown) => void; |
| /** | ||
| * Assigns `value` under `key` on a rebuilt object without letting a `__proto__` | ||
| * key hit the prototype setter. Ref resolution rebuilds every object node of a | ||
| * document — including remote/external content — so a plain `target[key] = value` | ||
| * would let a `{ "__proto__": … }` node corrupt the rebuilt object's prototype. | ||
| * Defining it as an own data property preserves the key verbatim instead. | ||
| */ | ||
| export const assignKey = (target, key, value) => { | ||
| if (key === '__proto__') { | ||
| Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }); | ||
| } | ||
| else { | ||
| target[key] = value; | ||
| } | ||
| }; |
@@ -5,2 +5,3 @@ import { readFileSync } from 'node:fs'; | ||
| import { isPrivateHost } from './is-private-host.js'; | ||
| import { assignKey } from './safe-assign.js'; | ||
| // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
@@ -51,3 +52,31 @@ // means a cycle, so we return `{}` instead of recursing forever. | ||
| const MAX_REDIRECTS = 5; | ||
| /** Abort a remote fetch that has not responded within this many milliseconds. */ | ||
| const FETCH_TIMEOUT_MS = 30_000; | ||
| /** Refuse to buffer a remote document larger than this (bytes) to bound memory. */ | ||
| const MAX_REMOTE_BYTES = 16 * 1024 * 1024; | ||
| /** | ||
| * Reads a response body, refusing to buffer more than {@link MAX_REMOTE_BYTES}. | ||
| * A `Content-Length` over the limit is rejected up front; a missing/lying header | ||
| * is caught while streaming so a chunked response can't exhaust memory either. | ||
| */ | ||
| const readCapped = async (response) => { | ||
| const declared = Number(response.headers.get('content-length')); | ||
| if (Number.isFinite(declared) && declared > MAX_REMOTE_BYTES) { | ||
| throw new Error(`remote document exceeds ${MAX_REMOTE_BYTES} bytes`); | ||
| } | ||
| const body = response.body; | ||
| if (!body) | ||
| return response.text(); | ||
| const decoder = new TextDecoder(); | ||
| let text = ''; | ||
| let total = 0; | ||
| for await (const chunk of body) { | ||
| total += chunk.byteLength; | ||
| if (total > MAX_REMOTE_BYTES) | ||
| throw new Error(`remote document exceeds ${MAX_REMOTE_BYTES} bytes`); | ||
| text += decoder.decode(chunk, { stream: true }); | ||
| } | ||
| return text + decoder.decode(); | ||
| }; | ||
| /** | ||
| * Fetches and parses a remote document, following redirects manually so the | ||
@@ -65,3 +94,7 @@ * SSRF guard is re-applied to every hop. `fetch` follows redirects by default, | ||
| throw new Error(`refusing to follow redirect (${reason}): ${current}`); | ||
| const response = await fetch(current, { redirect: 'manual' }); | ||
| const response = await fetch(current, { | ||
| redirect: 'manual', | ||
| // Cap the wait for a response so a stalling host can't hang resolution forever. | ||
| signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), | ||
| }); | ||
| if (response.status >= 300 && response.status < 400) { | ||
@@ -79,3 +112,3 @@ const next = response.headers.get('location'); | ||
| // (e.g. `.yaml` vs `.json`) and any relative refs key off a stable identity. | ||
| return parse(await response.text(), location); | ||
| return parse(await readCapped(response), location); | ||
| } | ||
@@ -95,2 +128,8 @@ throw new Error(`too many redirects (>${MAX_REDIRECTS}): ${location}`); | ||
| } | ||
| // Only http(s) may be fetched. Without this, a redirect to `file:///etc/passwd` | ||
| // or a `data:` URL passes every host check below (their `hostname` is empty, so | ||
| // `isPrivateHost('')` is false) and Bun's `fetch` would happily read the file. | ||
| if (url.protocol !== 'http:' && url.protocol !== 'https:') { | ||
| return `unsupported URL protocol "${url.protocol}" (only http and https are allowed)`; | ||
| } | ||
| const allow = options.allowedHosts; | ||
@@ -256,3 +295,3 @@ // An explicit allow-list entry is an intentional opt-in and bypasses the | ||
| for (const key of siblingKeys) | ||
| siblings[key] = resolveAt(obj[key], baseLocation, docCache, refCache, origins); | ||
| assignKey(siblings, key, resolveAt(obj[key], baseLocation, docCache, refCache, origins)); | ||
| const existingAllOf = Array.isArray(siblings['allOf']) ? siblings['allOf'] : []; | ||
@@ -267,3 +306,3 @@ const merged = { ...siblings, allOf: [...existingAllOf, resolved] }; | ||
| for (const key of Object.keys(obj)) { | ||
| result[key] = resolveAt(obj[key], baseLocation, docCache, refCache, origins); | ||
| assignKey(result, key, resolveAt(obj[key], baseLocation, docCache, refCache, origins)); | ||
| } | ||
@@ -270,0 +309,0 @@ return result; |
+18
-7
| import { getByPointer, pointerToPath } from './get-by-pointer.js'; | ||
| import { assignKey } from './safe-assign.js'; | ||
| // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
@@ -12,7 +13,7 @@ // means we have looped, so we return `{}` instead of recursing forever. | ||
| */ | ||
| const resolveInternal = (node, root, cache, origins) => { | ||
| const resolveInternal = (node, root, cache, origins, errors) => { | ||
| if (node === null || typeof node !== 'object') | ||
| return node; | ||
| if (Array.isArray(node)) | ||
| return node.map((item) => resolveInternal(item, root, cache, origins)); | ||
| return node.map((item) => resolveInternal(item, root, cache, origins, errors)); | ||
| const obj = node; | ||
@@ -30,3 +31,12 @@ if (typeof obj['$ref'] === 'string') { | ||
| cache.set(ref, CYCLE); | ||
| target = resolveInternal(getByPointer(root, ref.slice(1)), root, cache, origins); | ||
| const pointed = getByPointer(root, ref.slice(1)); | ||
| if (pointed === undefined) { | ||
| // An internal pointer that resolves to nothing (a typo'd or missing | ||
| // definition) was previously inlined as literal `undefined` with no trace. | ||
| // Record it and keep the original `$ref` node so the failure is visible. | ||
| errors.push({ message: `Cannot resolve internal $ref "${ref}"`, path: pointerToPath(ref.slice(1)) }); | ||
| cache.set(ref, obj); | ||
| return obj; | ||
| } | ||
| target = resolveInternal(pointed, root, cache, origins, errors); | ||
| cache.set(ref, target); | ||
@@ -50,3 +60,3 @@ // Stamp the inlined node with the path it was defined at (see resolveAt). | ||
| for (const key of siblingKeys) | ||
| siblings[key] = resolveInternal(obj[key], root, cache, origins); | ||
| assignKey(siblings, key, resolveInternal(obj[key], root, cache, origins, errors)); | ||
| const existingAllOf = Array.isArray(siblings['allOf']) ? siblings['allOf'] : []; | ||
@@ -62,3 +72,3 @@ const merged = { ...siblings, allOf: [...existingAllOf, target] }; | ||
| for (const key of Object.keys(obj)) { | ||
| result[key] = resolveInternal(obj[key], root, cache, origins); | ||
| assignKey(result, key, resolveInternal(obj[key], root, cache, origins, errors)); | ||
| } | ||
@@ -74,4 +84,5 @@ return result; | ||
| const origins = options.trackOrigins ? new Map() : undefined; | ||
| const resolved = resolveInternal(data, data, new Map(), origins); | ||
| return origins ? { resolved, errors: [], origins } : { resolved, errors: [] }; | ||
| const errors = []; | ||
| const resolved = resolveInternal(data, data, new Map(), origins, errors); | ||
| return origins ? { resolved, errors, origins } : { resolved, errors }; | ||
| }; |
+1
-1
| { | ||
| "name": "@amritk/resolve-refs", | ||
| "version": "0.2.2", | ||
| "version": "0.2.3", | ||
| "description": "Resolve and inline JSON Schema / OpenAPI $refs — internal, cross-file, and remote — with session caching and a default-deny SSRF guard.", | ||
@@ -5,0 +5,0 @@ "module": "./dist/index.js", |
36664
11.29%16
14.29%728
11.15%