Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/push.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [20.x, 22.x, 24.x]
node-version: [22.x, 24.x]
steps:
- uses: actions/checkout@v7
with:
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ yarn clean # Remove dist/ and coverage/

## Testing

- **Framework**: Jest 29 with SWC transformer (`@swc/jest`)
- **Framework**: Jest 30 with SWC transformer (`@swc/jest`)
- **Coverage provider**: V8 (`coverageProvider: 'v8'` in jest.config.js)
- **Coverage target**: 100% statements, branches, functions, lines — a UNIT-suite contract; the e2e suite never runs under `test:coverage`
- **Unit tests**: `src/__tests__/*.spec.ts` (one per module)
Expand Down Expand Up @@ -109,7 +109,7 @@ yarn clean # Remove dist/ and coverage/

### ESM Dependency Handling

`@apidevtools/json-schema-ref-parser` v15 is ESM-only. The project uses dynamic `import()` in `generator.ts` so it works from both CJS and ESM contexts. Jest transforms the package via `transformIgnorePatterns` in `jest.config.js`.
`@apidevtools/json-schema-ref-parser` v16 is ESM-only. The project uses dynamic `import()` in `generator.ts` so it works from both CJS and ESM contexts. Jest transforms the package via `transformIgnorePatterns` in `jest.config.js`.

## Options Flow

Expand Down
9 changes: 9 additions & 0 deletions docs/ssrf-prevention.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,15 @@ The library configures `@apidevtools/json-schema-ref-parser` with a custom `canR

This happens transparently during the dereference step when loading a spec.

Every network fetch -- the spec URL in `fromURL` and each external `$ref` -- then goes through `safeFetch`, which:

1. Resolves the hostname and rejects it if **any** resolved address is internal (this closes the `127.0.0.1.nip.io` class of bypass)
2. Pins the connection to the exact address it just validated, so the socket never looks the name up again (this closes DNS rebinding), while keeping the original hostname for the `Host` header and TLS SNI
3. Opens a fresh socket for every request, so a pooled keep-alive socket that was connected elsewhere is never reused
4. Re-validates every redirect hop before following it (`$ref` fetches refuse redirects entirely)

Connection pinning needs Node's `node:http`/`node:https`. On runtimes without them (Web/edge), and whenever you pass a custom `fetchImpl` to `safeFetch`, only the pre-fetch checks apply. For untrusted input there, combine `allowedHosts` with network egress controls.

---

**Related:** [Security](./security.md) | [Configuration](./configuration.md) | [Getting Started](./getting-started.md)
16 changes: 8 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
},
"homepage": "https://github.com/agentfront/mcp-from-openapi#readme",
"engines": {
"node": ">=20.0.0"
"node": ">=22.19.0"
},
"type": "commonjs",
"main": "./dist/index.js",
Expand All @@ -55,14 +55,14 @@
"build": "yarn build:cjs && yarn build:esm && yarn build:types && cp package.json dist/ && node scripts/strip-dist-from-pkg.js dist/package.json && cp README.md LICENSE dist/",
"test": "jest",
"test:unit": "jest --testPathIgnorePatterns='integration'",
"test:integration": "jest --testPathPattern='integration'",
"test:integration": "jest --testPathPatterns='integration'",
"test:e2e": "jest -c jest.e2e.config.js",
"test:coverage": "jest --coverage",
"clean": "rm -rf dist coverage",
"prepublishOnly": "yarn clean && yarn build && yarn test"
},
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^15.5.1",
"@apidevtools/json-schema-ref-parser": "^16.0.2",
"openapi-types": "^12.1.3",
"yaml": "^2.9.0"
},
Expand All @@ -71,15 +71,15 @@
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
"@swc/core": "~1.5.7",
"@swc/core": "~1.16.1",
"@swc/helpers": "^0.5.18",
"@swc/jest": "~0.2.38",
"@types/jest": "^29.5.0",
"@types/jest": "^30.0.0",
"@types/json-schema": "^7.0.15",
"@types/node": "^24.0.0",
"@types/node": "^26.4.1",
"ajv": "^8.17.0",
"esbuild": "^0.27.2",
"jest": "^29.7.0",
"esbuild": "^0.28.2",
"jest": "^30.5.1",
"tslib": "^2.8.1",
"typescript": "^5.0.0",
"zod": "^4.0.0"
Expand Down
59 changes: 59 additions & 0 deletions src/__tests__/generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { ResponseBuilder } from '../response-builder';
import { ParseError, LoadError } from '../errors';
import type { OpenAPIDocument } from '../types';
import { createLoopbackServer, type LoopbackHandler } from './helpers/loopback';
import { makePinnedLookup } from '../ssrf';
import dns from 'node:dns';

describe('OpenAPIToolGenerator', () => {
const simpleOpenAPI: OpenAPIDocument = {
Expand Down Expand Up @@ -2526,6 +2528,63 @@ describe('External $ref resolution over the SSRF-safe pinned transport', () => {
});
});

describe('DNS rebinding against spec and external $ref fetches', () => {
const loopback = createLoopbackServer(() => (_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ type: 'string' }));
});
let reboundOrigin: string;

beforeAll(async () => {
const port = new URL(await loopback.listen()).port;
reboundOrigin = `http://rebind.attacker.invalid:${port}`;
});

afterAll(async () => {
await loopback.close();
});

beforeEach(() => {
jest.spyOn(dns.promises, 'lookup').mockResolvedValue([{ address: '203.0.113.7', family: 4 }] as never);
jest
.spyOn(dns, 'lookup')
.mockImplementation(makePinnedLookup([{ address: '127.0.0.1', family: 4 }]) as unknown as typeof dns.lookup);
});

afterEach(() => {
jest.restoreAllMocks();
loopback.reset();
});

it('fromURL never reaches the address a connect-time lookup would return', async () => {
await expect(OpenAPIToolGenerator.fromURL(`${reboundOrigin}/openapi.json`, { timeout: 300 })).rejects.toThrow(
LoadError,
);
expect(loopback.requests).toHaveLength(0);
});

it('external $ref resolution never reaches the address a connect-time lookup would return', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(
{
openapi: '3.0.0',
info: { title: 'Rebind API', version: '1.0.0' },
paths: {
'/thing': {
get: {
operationId: 'getThing',
parameters: [{ name: 'q', in: 'query', schema: { $ref: `${reboundOrigin}/schema.json` } }],
responses: { '200': { description: 'OK' } },
},
},
},
},
{ timeout: 300, validate: false },
);
await expect(generator.generateTools()).rejects.toThrow();
expect(loopback.requests).toHaveLength(0);
});
});

describe('includeExamples option', () => {
const specWithExamples: any = {
openapi: '3.0.0',
Expand Down
44 changes: 44 additions & 0 deletions src/__tests__/ssrf.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import dns from 'node:dns';
import * as httpMod from 'node:http';
import * as httpsMod from 'node:https';
import { createLoopbackServer, type LoopbackHandler } from './helpers/loopback';
Expand Down Expand Up @@ -469,6 +470,30 @@ describe('ssrf: nodePinnedTransport (real server, connection pinning)', () => {
transport(`http://127.0.0.1:${serverPort}/big`, { signal: freshSignal(), pinned: [], maxBytes: 100 }),
).rejects.toThrow(SsrfError);
});

it('never reuses a pooled keep-alive socket that was connected to another address', async () => {
serverHandler = (_req, res) => {
res.writeHead(200);
res.end('internal');
};
const pooledOrigin = `http://pooled.pinned.invalid:${serverPort}`;
await new Promise<void>((resolve, reject) => {
httpMod
.get(`${pooledOrigin}/warm`, { lookup: makePinnedLookup([{ address: '127.0.0.1', family: 4 }]) }, (res) => {
res.resume();
res.on('end', () => resolve());
})
.on('error', reject);
});

await expect(
transport(`${pooledOrigin}/spec`, {
signal: AbortSignal.timeout(300),
pinned: [{ address: '203.0.113.7', family: 4 }],
}),
).rejects.toBeDefined();
expect(loopback.requests.map((request) => request.url)).toEqual(['/warm']);
});
});

describe('ssrf: safeFetch over the Node transport (real server)', () => {
Expand All @@ -489,4 +514,23 @@ describe('ssrf: safeFetch over the Node transport (real server)', () => {
globalThis.fetch = originalFetch;
}
});

it('dials the validated address instead of re-resolving the host at connect time (DNS rebinding)', async () => {
const connectTimeLookup = jest
.spyOn(dns, 'lookup')
.mockImplementation(makePinnedLookup([{ address: '127.0.0.1', family: 4 }]) as unknown as typeof dns.lookup);
try {
await expect(
safeFetch(`http://rebind.attacker.invalid:${serverPort}/openapi.json`, {
ssrf: OPEN,
lookup: lookupTo('203.0.113.7'),
timeoutMs: 300,
}),
).rejects.toBeDefined();
expect(connectTimeLookup).not.toHaveBeenCalled();
expect(loopback.requests).toHaveLength(0);
} finally {
connectTimeLookup.mockRestore();
}
});
});
2 changes: 1 addition & 1 deletion src/request-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,7 @@ export function buildHttpRequest(
if (typeof Blob !== 'undefined' && v instanceof Blob) {
form.append(k, v);
} else if (v instanceof Uint8Array) {
form.append(k, new Blob([v]));
form.append(k, new Blob([new Uint8Array(v)]));
} else if (isPlainObject(v) || Array.isArray(v)) {
form.append(k, JSON.stringify(v));
} else {
Expand Down
10 changes: 7 additions & 3 deletions src/ssrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
* guard and the socket share one DNS resolution. This closes the
* DNS-rebinding TOCTOU where the client would otherwise re-resolve the
* hostname at connect time and reach a different (internal) address. The
* original hostname is preserved for the `Host` header and TLS SNI;
* original hostname is preserved for the `Host` header and TLS SNI, and
* every request opens a fresh socket (never a pooled keep-alive one);
* - re-validates every redirect hop ({@link safeFetch}) instead of letting the
* HTTP client follow 3xx blindly.
*
Expand Down Expand Up @@ -390,8 +391,9 @@ const NULL_BODY_STATUS: ReadonlySet<number> = new Set([101, 103, 204, 205, 304])
/**
* Node transport that pins the connection to the validated address(es) via a
* custom `lookup`, preserving the original hostname for the `Host` header and
* TLS SNI. Manual redirects only (no `lookup` re-resolution between hops).
* Exported for tests.
* TLS SNI. Each request uses a one-off agent so a shared keep-alive pool can't
* hand it a socket connected elsewhere. Manual redirects only (no `lookup`
* re-resolution between hops). Exported for tests.
*/
export function nodePinnedTransport(modules: NodeHttpModules): SsrfTransport {
return (url, { headers, signal, pinned, maxBytes }) =>
Expand All @@ -401,6 +403,8 @@ export function nodePinnedTransport(modules: NodeHttpModules): SsrfTransport {
const requestOptions: Record<string, unknown> = {
method: 'GET',
signal,
// A pooled keep-alive socket is keyed by host:port only and would skip the pinned lookup.
agent: false,
headers: { ...headers, 'accept-encoding': 'identity' },
};
if (pinned.length > 0) {
Expand Down
Loading
Loading