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
4 changes: 3 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const generator = await OpenAPIToolGenerator.fromURL(url, {
| ----------------- | -------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dereference` | `boolean` | `true` | Resolve all `$ref` pointers in the spec |
| `baseUrl` | `string` | `''` | Override server URLs from the spec |
| `headers` | `Record<string, string>` | `{}` | Custom HTTP headers for URL loading |
| `headers` | `Record<string, string>` | `{}` | Custom HTTP headers for URL loading. Sent only to the spec URL's origin: dropped once a redirect leaves it, and never sent to external `$ref` hosts |
| `timeout` | `number` | `30000` | HTTP request timeout in milliseconds |
| `validate` | `boolean` | `true` | Validate the OpenAPI document on load |
| `followRedirects` | `boolean` | `true` | Follow HTTP redirects when loading from URL |
Expand All @@ -46,6 +46,8 @@ Controls how external `$ref` pointers are resolved during dereferencing. See [SS
| `blockedHosts` | `string[]` | `[]` | Additional hostnames to block |
| `allowInternalIPs` | `boolean` | `false` | Disable built-in SSRF protection |

Host entries match case-insensitively and ignore trailing root dots and IPv6 brackets: `['internal.corp']` also blocks `internal.corp.` and `INTERNAL.CORP`.
Comment thread
frontegg-david marked this conversation as resolved.
Resolved

---

## GenerateOptions
Expand Down
21 changes: 21 additions & 0 deletions docs/ssrf-prevention.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,24 @@ Out of the box, the following are blocked:
| `::1` | IPv6 loopback |
| `fd00::/8` | IPv6 Unique Local Address |
| `fe80::/10` | IPv6 link-local |
| `64:ff9b:1::/48` | Local-use NAT64 prefix (never globally routable) |

Bracketed IPv6 forms (`[::1]`, `[fd00:...]`, `[fe80:...]`) are also blocked.

IPv6 addresses that embed an IPv4 address are checked against the IPv4 ranges above, because a NAT64 gateway, 6to4 relay, or dual-stack host may carry them to that IPv4 target:

| Form | Example | Embedded IPv4 |
|------|---------|---------------|
| IPv4-mapped (`::ffff:0:0/96`) | `::ffff:7f00:1` | `127.0.0.1` |
| IPv4-translated (`::ffff:0:0:0/96`) | `::ffff:0:a00:1` | `10.0.0.1` |
| IPv4-compatible (`::/96`, deprecated) | `::7f00:1` | `127.0.0.1` |
| NAT64 well-known prefix (`64:ff9b::/96`) | `64:ff9b::a9fe:a9fe` | `169.254.169.254` |
| 6to4 (`2002::/16`) | `2002:c0a8:101::1` | `192.168.1.1` |

### Hostname Matching

Hostnames are compared in a normalized form: lowercase, with trailing root dots and IPv6 brackets removed. This applies to the built-in names above and to every `allowedHosts` / `blockedHosts` entry, on both sides of the comparison. `localhost.`, `LOCALHOST`, and `localhost` are the same host, so a `blockedHosts: ['internal.corp']` entry also blocks `http://internal.corp./`.

---

## Configuration
Expand Down Expand Up @@ -140,6 +155,12 @@ Every network fetch -- the spec URL in `fromURL` and each external `$ref` -- the
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)

### Headers and Redirects

`LoadOptions.headers` (typically the spec host's `Authorization` or API key) is sent only while the request stays on the origin of the URL passed to `fromURL`. If a redirect leaves that origin (another host, another port, or an `https` → `http` downgrade), **all** caller headers are dropped for that hop and for every later hop, even if the chain later returns to the original origin. An open redirect on the spec host therefore cannot pass your credentials to a third party. External `$ref` fetches never send `headers`.

If your spec host redirects to a different origin that also needs credentials, load the final URL directly, or pass `followRedirects: false` to stop at the redirect.

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.

---
Expand Down
64 changes: 64 additions & 0 deletions src/__tests__/generator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,49 @@ paths:
expect(receivedAuth).toBe('value');
});

it('should not forward caller headers to a cross-origin redirect target', async () => {
const cdn = createLoopbackServer(() => (_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(specJson('Redirected'));
});
const cdnBase = await cdn.listen();
loopback.reset();
handler = (_req, res) => {
res.writeHead(302, { location: `${cdnBase}/spec.json` });
res.end();
};
try {
const generator = await OpenAPIToolGenerator.fromURL(
`${baseUrl}/api.json`,
internal({ headers: { Authorization: 'Bearer SECRET-TOKEN', 'X-API-Key': 'SECRET-KEY' } }),
);
expect(generator.getDocument().info.title).toBe('Redirected');
expect(loopback.requests[0].headers.authorization).toBe('Bearer SECRET-TOKEN');
expect(cdn.requests).toHaveLength(1);
expect(cdn.requests[0].headers.authorization).toBeUndefined();
expect(cdn.requests[0].headers['x-api-key']).toBeUndefined();
} finally {
await cdn.close();
}
});

it('should apply blockedHosts to the trailing-dot form of the spec host', async () => {
loopback.reset();
handler = (_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(specJson('Blocked'));
};
const port = new URL(baseUrl).port;
for (const host of ['localhost', 'localhost.', 'LOCALHOST.']) {
await expect(
OpenAPIToolGenerator.fromURL(`http://${host}:${port}/api.json`, {
refResolution: { allowInternalIPs: true, blockedHosts: ['localhost'] },
}),
).rejects.toThrow(/blocked/);
}
expect(loopback.requests).toHaveLength(0);
});

it('should not follow redirects when followRedirects is false', async () => {
handler = (_req, res) => {
res.writeHead(302, { location: `${baseUrl}/elsewhere` });
Expand Down Expand Up @@ -2127,6 +2170,27 @@ describe('SSRF Prevention - $ref resolution security', () => {
expect(canRead({ url: 'https://evil.com/schema.json' })).toBe(false);
});

it('should match allowedHosts / blockedHosts regardless of case and trailing root dots', async () => {
const allowGen = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
refResolution: { allowedHosts: ['Schemas.Example.com.'] },
});
await allowGen.generateTools();
const allowCanRead = derefSpy.mock.calls[0][1]?.resolve?.http?.canRead;
expect(allowCanRead({ url: 'https://schemas.example.com/user.json' })).toBe(true);
expect(allowCanRead({ url: 'https://schemas.example.com./user.json' })).toBe(true);
expect(allowCanRead({ url: 'https://schemas.example.org/user.json' })).toBe(false);

const blockGen = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
refResolution: { blockedHosts: ['Evil.com'] },
});
await blockGen.generateTools();
const blockCanRead = derefSpy.mock.calls[1][1]?.resolve?.http?.canRead;
expect(blockCanRead({ url: 'https://evil.com/schema.json' })).toBe(false);
expect(blockCanRead({ url: 'https://evil.com./schema.json' })).toBe(false);
expect(blockCanRead({ url: 'https://EVIL.COM../schema.json' })).toBe(false);
expect(blockCanRead({ url: 'https://good.com/schema.json' })).toBe(true);
});

it('should support exotic protocols in allowedProtocols', async () => {
const generator = await OpenAPIToolGenerator.fromJSON(minimalSpec, {
refResolution: { allowedProtocols: ['https', 'ftp'] },
Expand Down
Loading
Loading