🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@amritk/resolve-refs

Package Overview
Dependencies
Maintainers
1
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@amritk/resolve-refs - npm Package Compare versions

Comparing version
0.2.2
to
0.2.3
+8
dist/safe-assign.d.ts
/**
* 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;
}
};
+43
-4

@@ -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 };
};
{
"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",