@open-mercato/shared 0.5.1-develop.2912.8d7b1fef24 → 0.5.1-develop.2924.d13908516e
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/url-safety.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lookup } from "node:dns/promises";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
|
+
import { Agent } from "undici";
|
|
3
4
|
import { isBlockedHostname, isPrivateIpAddress } from "./network.js";
|
|
4
5
|
class UnsafeOutboundUrlError extends Error {
|
|
5
6
|
constructor(reason, message) {
|
|
@@ -53,10 +54,13 @@ function assertStaticallySafeOutboundUrl(rawUrl, options = {}) {
|
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
56
|
async function assertSafeOutboundUrl(rawUrl, options = {}) {
|
|
57
|
+
await resolveSafeOutboundUrl(rawUrl, options);
|
|
58
|
+
}
|
|
59
|
+
async function resolveSafeOutboundUrl(rawUrl, options = {}) {
|
|
56
60
|
const subject = options.subject ?? "URL";
|
|
57
61
|
const factory = options.errorFactory ?? defaultErrorFactory;
|
|
58
|
-
const { hostname } = parseOutboundUrl(rawUrl, options);
|
|
59
|
-
if (options.allowPrivate) return;
|
|
62
|
+
const { url, hostname } = parseOutboundUrl(rawUrl, options);
|
|
63
|
+
if (options.allowPrivate) return { url, hostname, addresses: null };
|
|
60
64
|
if (isBlockedHostname(hostname)) {
|
|
61
65
|
throw factory("blocked_hostname", `${subject} host "${hostname}" is not allowed`);
|
|
62
66
|
}
|
|
@@ -67,7 +71,7 @@ async function assertSafeOutboundUrl(rawUrl, options = {}) {
|
|
|
67
71
|
`${subject} host "${hostname}" resolves to a private or reserved IP range`
|
|
68
72
|
);
|
|
69
73
|
}
|
|
70
|
-
return;
|
|
74
|
+
return { url, hostname, addresses: null };
|
|
71
75
|
}
|
|
72
76
|
const resolver = options.lookupHost ?? (async (host) => {
|
|
73
77
|
const records = await lookup(host, { all: true, verbatim: true });
|
|
@@ -96,11 +100,54 @@ async function assertSafeOutboundUrl(rawUrl, options = {}) {
|
|
|
96
100
|
);
|
|
97
101
|
}
|
|
98
102
|
}
|
|
103
|
+
return { url, hostname, addresses };
|
|
104
|
+
}
|
|
105
|
+
async function safeOutboundFetch(rawUrl, init = {}, options = {}) {
|
|
106
|
+
const { url, hostname, addresses } = await resolveSafeOutboundUrl(rawUrl, options);
|
|
107
|
+
void url;
|
|
108
|
+
const mergedInit = {
|
|
109
|
+
redirect: "manual",
|
|
110
|
+
...init
|
|
111
|
+
};
|
|
112
|
+
const fetchImpl = options.fetchImpl;
|
|
113
|
+
if (fetchImpl) {
|
|
114
|
+
return fetchImpl(rawUrl, mergedInit);
|
|
115
|
+
}
|
|
116
|
+
if (!addresses || addresses.length === 0) {
|
|
117
|
+
return globalThis.fetch(rawUrl, mergedInit);
|
|
118
|
+
}
|
|
119
|
+
const dispatcher = new Agent({
|
|
120
|
+
connect: {
|
|
121
|
+
lookup: createPinnedDnsLookup(hostname, addresses[0])
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
return await globalThis.fetch(rawUrl, { ...mergedInit, dispatcher });
|
|
126
|
+
} finally {
|
|
127
|
+
dispatcher.close().catch(() => {
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function createPinnedDnsLookup(expectedHostname, pinned) {
|
|
132
|
+
return (host, _opts, cb) => {
|
|
133
|
+
if (host !== expectedHostname) {
|
|
134
|
+
const err = new Error(
|
|
135
|
+
`Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`
|
|
136
|
+
);
|
|
137
|
+
err.code = "EREFUSED";
|
|
138
|
+
cb(err, "", 0);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
cb(null, pinned.address, pinned.family);
|
|
142
|
+
};
|
|
99
143
|
}
|
|
100
144
|
export {
|
|
101
145
|
UnsafeOutboundUrlError,
|
|
102
146
|
assertSafeOutboundUrl,
|
|
103
147
|
assertStaticallySafeOutboundUrl,
|
|
104
|
-
|
|
148
|
+
createPinnedDnsLookup,
|
|
149
|
+
parseOutboundUrl,
|
|
150
|
+
resolveSafeOutboundUrl,
|
|
151
|
+
safeOutboundFetch
|
|
105
152
|
};
|
|
106
153
|
//# sourceMappingURL=url-safety.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/url-safety.ts"],
|
|
4
|
-
"sourcesContent": ["import { lookup } from 'node:dns/promises'\nimport { isIP } from 'node:net'\nimport { isBlockedHostname, isPrivateIpAddress } from './network'\n\nexport type UrlSafetyReason =\n | 'invalid_url'\n | 'forbidden_protocol'\n | 'credentials_in_url'\n | 'missing_host'\n | 'blocked_hostname'\n | 'private_ip_literal'\n | 'private_ip_resolved'\n | 'dns_resolution_failed'\n | 'dns_resolution_empty'\n\nexport class UnsafeOutboundUrlError extends Error {\n public readonly reason: UrlSafetyReason\n\n constructor(reason: UrlSafetyReason, message?: string) {\n super(message ?? `Outbound URL rejected: ${reason}`)\n this.name = 'UnsafeOutboundUrlError'\n this.reason = reason\n }\n}\n\nconst ALLOWED_PROTOCOLS = new Set(['http:', 'https:'])\n\nexport type ParsedOutboundUrl = {\n url: URL\n hostname: string\n}\n\nexport type UrlSafetyErrorFactory = (reason: UrlSafetyReason, message: string) => Error\n\nconst defaultErrorFactory: UrlSafetyErrorFactory = (reason, message) =>\n new UnsafeOutboundUrlError(reason, message)\n\nexport type ParseOutboundUrlOptions = {\n errorFactory?: UrlSafetyErrorFactory\n subject?: string\n}\n\nexport function parseOutboundUrl(\n rawUrl: string,\n options: ParseOutboundUrlOptions = {},\n): ParsedOutboundUrl {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n let url: URL\n try {\n url = new URL(rawUrl)\n } catch {\n throw factory('invalid_url', `${subject} is not a valid URL`)\n }\n if (!ALLOWED_PROTOCOLS.has(url.protocol)) {\n throw factory(\n 'forbidden_protocol',\n `${subject} protocol \"${url.protocol.replace(':', '')}\" is not allowed; use http or https`,\n )\n }\n if (url.username || url.password) {\n throw factory('credentials_in_url', `${subject} must not embed basic-auth credentials`)\n }\n let hostname = url.hostname.trim().toLowerCase()\n if (!hostname) {\n throw factory('missing_host', `${subject} must include a hostname`)\n }\n if (hostname.startsWith('[') && hostname.endsWith(']')) {\n hostname = hostname.slice(1, -1)\n }\n return { url, hostname }\n}\n\nexport type HostLookup = (\n hostname: string,\n) => Promise<ReadonlyArray<{ address: string; family: number }>>\n\nexport type AssertStaticUrlOptions = ParseOutboundUrlOptions & {\n allowPrivate?: boolean\n}\n\nexport function assertStaticallySafeOutboundUrl(\n rawUrl: string,\n options: AssertStaticUrlOptions = {},\n): void {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n const { hostname } = parseOutboundUrl(rawUrl, options)\n if (options.allowPrivate) return\n\n if (isBlockedHostname(hostname)) {\n throw factory('blocked_hostname', `${subject} host \"${hostname}\" is not allowed`)\n }\n if (isIP(hostname) && isPrivateIpAddress(hostname)) {\n throw factory(\n 'private_ip_literal',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP range`,\n )\n }\n}\n\nexport type AssertSafeOutboundUrlOptions = AssertStaticUrlOptions & {\n lookupHost?: HostLookup\n}\n\nexport async function assertSafeOutboundUrl(\n rawUrl: string,\n options: AssertSafeOutboundUrlOptions = {},\n): Promise<void> {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n const { hostname } = parseOutboundUrl(rawUrl, options)\n if (options.allowPrivate) return\n\n if (isBlockedHostname(hostname)) {\n throw factory('blocked_hostname', `${subject} host \"${hostname}\" is not allowed`)\n }\n\n if (isIP(hostname)) {\n if (isPrivateIpAddress(hostname)) {\n throw factory(\n 'private_ip_literal',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP range`,\n )\n }\n return\n }\n\n const resolver: HostLookup =\n options.lookupHost ??\n (async (host) => {\n const records = await lookup(host, { all: true, verbatim: true })\n return records\n })\n\n let addresses: ReadonlyArray<
|
|
5
|
-
"mappings": "AAAA,SAAS,cAAc;AACvB,SAAS,YAAY;AACrB,SAAS,mBAAmB,0BAA0B;AAa/C,MAAM,+BAA+B,MAAM;AAAA,EAGhD,YAAY,QAAyB,SAAkB;AACrD,UAAM,WAAW,0BAA0B,MAAM,EAAE;AACnD,SAAK,OAAO;AACZ,SAAK,SAAS;AAAA,EAChB;AACF;AAEA,MAAM,oBAAoB,oBAAI,IAAI,CAAC,SAAS,QAAQ,CAAC;AASrD,MAAM,sBAA6C,CAAC,QAAQ,YAC1D,IAAI,uBAAuB,QAAQ,OAAO;AAOrC,SAAS,iBACd,QACA,UAAmC,CAAC,GACjB;AACnB,QAAM,UAAU,QAAQ,WAAW;AACnC,QAAM,UAAU,QAAQ,gBAAgB;AACxC,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,MAAM;AAAA,EACtB,QAAQ;AACN,UAAM,QAAQ,eAAe,GAAG,OAAO,qBAAqB;AAAA,EAC9D;AACA,MAAI,CAAC,kBAAkB,IAAI,IAAI,QAAQ,GAAG;AACxC,UAAM;AAAA,MACJ;AAAA,MACA,GAAG,OAAO,cAAc,IAAI,SAAS,QAAQ,KAAK,EAAE,CAAC;AAAA,IACvD;AAAA,EACF;AACA,MAAI,IAAI,YAAY,IAAI,UAAU;AAChC,UAAM,QAAQ,sBAAsB,GAAG,OAAO,wCAAwC;AAAA,EACxF;AACA,MAAI,WAAW,IAAI,SAAS,KAAK,EAAE,YAAY;AAC/C,MAAI,CAAC,UAAU;AACb,UAAM,QAAQ,gBAAgB,GAAG,OAAO,0BAA0B;AAAA,EACpE;AACA,MAAI,SAAS,WAAW,GAAG,KAAK,SAAS,SAAS,GAAG,GAAG;AACtD,eAAW,SAAS,MAAM,GAAG,EAAE;AAAA,EACjC;AACA,SAAO,EAAE,KAAK,SAAS;AACzB;AAUO,SAAS,gCACd,QACA,UAAkC,CAAC,GAC7B;AACN,QAAM,UAAU,QAAQ,WAAW;AACnC,QAAM,UAAU,QAAQ,gBAAgB;AACxC,QAAM,EAAE,SAAS,IAAI,iBAAiB,QAAQ,OAAO;AACrD,MAAI,QAAQ,aAAc;AAE1B,MAAI,kBAAkB,QAAQ,GAAG;AAC/B,UAAM,QAAQ,oBAAoB,GAAG,OAAO,UAAU,QAAQ,kBAAkB;AAAA,EAClF;AACA,MAAI,KAAK,QAAQ,KAAK,mBAAmB,QAAQ,GAAG;AAClD,UAAM;AAAA,MACJ;AAAA,MACA,GAAG,OAAO,UAAU,QAAQ;AAAA,IAC9B;AAAA,EACF;AACF;AAMA,eAAsB,sBACpB,QACA,UAAwC,CAAC,GAC1B;AACf,QAAM,UAAU,QAAQ,WAAW;AACnC,QAAM,UAAU,QAAQ,gBAAgB;AACxC,QAAM,EAAE,SAAS,IAAI,iBAAiB,QAAQ,OAAO;
|
|
4
|
+
"sourcesContent": ["import { lookup } from 'node:dns/promises'\nimport { isIP } from 'node:net'\nimport { Agent, type Dispatcher } from 'undici'\nimport { isBlockedHostname, isPrivateIpAddress } from './network'\n\nexport type UrlSafetyReason =\n | 'invalid_url'\n | 'forbidden_protocol'\n | 'credentials_in_url'\n | 'missing_host'\n | 'blocked_hostname'\n | 'private_ip_literal'\n | 'private_ip_resolved'\n | 'dns_resolution_failed'\n | 'dns_resolution_empty'\n\nexport class UnsafeOutboundUrlError extends Error {\n public readonly reason: UrlSafetyReason\n\n constructor(reason: UrlSafetyReason, message?: string) {\n super(message ?? `Outbound URL rejected: ${reason}`)\n this.name = 'UnsafeOutboundUrlError'\n this.reason = reason\n }\n}\n\nconst ALLOWED_PROTOCOLS = new Set(['http:', 'https:'])\n\nexport type ParsedOutboundUrl = {\n url: URL\n hostname: string\n}\n\nexport type UrlSafetyErrorFactory = (reason: UrlSafetyReason, message: string) => Error\n\nconst defaultErrorFactory: UrlSafetyErrorFactory = (reason, message) =>\n new UnsafeOutboundUrlError(reason, message)\n\nexport type ParseOutboundUrlOptions = {\n errorFactory?: UrlSafetyErrorFactory\n subject?: string\n}\n\nexport function parseOutboundUrl(\n rawUrl: string,\n options: ParseOutboundUrlOptions = {},\n): ParsedOutboundUrl {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n let url: URL\n try {\n url = new URL(rawUrl)\n } catch {\n throw factory('invalid_url', `${subject} is not a valid URL`)\n }\n if (!ALLOWED_PROTOCOLS.has(url.protocol)) {\n throw factory(\n 'forbidden_protocol',\n `${subject} protocol \"${url.protocol.replace(':', '')}\" is not allowed; use http or https`,\n )\n }\n if (url.username || url.password) {\n throw factory('credentials_in_url', `${subject} must not embed basic-auth credentials`)\n }\n let hostname = url.hostname.trim().toLowerCase()\n if (!hostname) {\n throw factory('missing_host', `${subject} must include a hostname`)\n }\n if (hostname.startsWith('[') && hostname.endsWith(']')) {\n hostname = hostname.slice(1, -1)\n }\n return { url, hostname }\n}\n\nexport type HostLookup = (\n hostname: string,\n) => Promise<ReadonlyArray<{ address: string; family: number }>>\n\nexport type AssertStaticUrlOptions = ParseOutboundUrlOptions & {\n allowPrivate?: boolean\n}\n\nexport function assertStaticallySafeOutboundUrl(\n rawUrl: string,\n options: AssertStaticUrlOptions = {},\n): void {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n const { hostname } = parseOutboundUrl(rawUrl, options)\n if (options.allowPrivate) return\n\n if (isBlockedHostname(hostname)) {\n throw factory('blocked_hostname', `${subject} host \"${hostname}\" is not allowed`)\n }\n if (isIP(hostname) && isPrivateIpAddress(hostname)) {\n throw factory(\n 'private_ip_literal',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP range`,\n )\n }\n}\n\nexport type AssertSafeOutboundUrlOptions = AssertStaticUrlOptions & {\n lookupHost?: HostLookup\n}\n\nexport async function assertSafeOutboundUrl(\n rawUrl: string,\n options: AssertSafeOutboundUrlOptions = {},\n): Promise<void> {\n await resolveSafeOutboundUrl(rawUrl, options)\n}\n\nexport type ResolvedHostAddress = { address: string; family: number }\n\nexport type ResolveSafeOutboundUrlResult = {\n url: URL\n hostname: string\n /**\n * The validated DNS records, in lookup order. `null` when the hostname is an IP literal\n * (no DNS lookup performed) or when `allowPrivate` short-circuited validation.\n */\n addresses: ReadonlyArray<ResolvedHostAddress> | null\n}\n\n/**\n * Validates an outbound URL exactly like `assertSafeOutboundUrl()` and additionally returns\n * the resolved DNS records so the caller can pin the subsequent connection to the same\n * address. This is what `safeOutboundFetch()` uses internally to defeat DNS rebinding \u2014\n * call it directly only if you need to drive the fetch yourself.\n */\nexport async function resolveSafeOutboundUrl(\n rawUrl: string,\n options: AssertSafeOutboundUrlOptions = {},\n): Promise<ResolveSafeOutboundUrlResult> {\n const subject = options.subject ?? 'URL'\n const factory = options.errorFactory ?? defaultErrorFactory\n const { url, hostname } = parseOutboundUrl(rawUrl, options)\n if (options.allowPrivate) return { url, hostname, addresses: null }\n\n if (isBlockedHostname(hostname)) {\n throw factory('blocked_hostname', `${subject} host \"${hostname}\" is not allowed`)\n }\n\n if (isIP(hostname)) {\n if (isPrivateIpAddress(hostname)) {\n throw factory(\n 'private_ip_literal',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP range`,\n )\n }\n return { url, hostname, addresses: null }\n }\n\n const resolver: HostLookup =\n options.lookupHost ??\n (async (host) => {\n const records = await lookup(host, { all: true, verbatim: true })\n return records\n })\n\n let addresses: ReadonlyArray<ResolvedHostAddress>\n try {\n addresses = await resolver(hostname)\n } catch (error) {\n throw factory(\n 'dns_resolution_failed',\n `${subject} host \"${hostname}\" could not be resolved: ${\n error instanceof Error ? error.message : 'lookup failed'\n }`,\n )\n }\n\n if (!addresses || addresses.length === 0) {\n throw factory(\n 'dns_resolution_empty',\n `${subject} host \"${hostname}\" has no DNS A/AAAA records`,\n )\n }\n\n for (const record of addresses) {\n if (isPrivateIpAddress(record.address)) {\n throw factory(\n 'private_ip_resolved',\n `${subject} host \"${hostname}\" resolves to a private or reserved IP address (${record.address})`,\n )\n }\n }\n\n return { url, hostname, addresses }\n}\n\nexport type SafeOutboundFetchOptions = AssertSafeOutboundUrlOptions & {\n /**\n * Test/seam injection. When provided, `safeOutboundFetch` calls `fetchImpl(url, init)` after\n * URL validation instead of using the global `fetch` with a DNS-pinned dispatcher. Tests do\n * not actually open sockets, so DNS pinning is unnecessary and would just complicate mocking.\n */\n fetchImpl?: typeof fetch\n}\n\n/**\n * Validates an outbound URL and performs `fetch()` with the connection pinned to a\n * pre-validated IP address, so DNS cannot be re-resolved between validation and connect\n * (DNS rebinding). Always defaults to `redirect: 'manual'` \u2014 callers MUST decide what to\n * do with 3xx responses (re-validate the redirect target before following).\n *\n * For IP literal hosts and `allowPrivate=true`, no DNS pinning is performed because there\n * is no DNS lookup to defeat.\n */\nexport async function safeOutboundFetch(\n rawUrl: string,\n init: RequestInit = {},\n options: SafeOutboundFetchOptions = {},\n): Promise<Response> {\n const { url, hostname, addresses } = await resolveSafeOutboundUrl(rawUrl, options)\n void url\n\n const mergedInit: RequestInit = {\n redirect: 'manual',\n ...init,\n }\n\n const fetchImpl = options.fetchImpl\n if (fetchImpl) {\n return fetchImpl(rawUrl, mergedInit)\n }\n\n if (!addresses || addresses.length === 0) {\n return globalThis.fetch(rawUrl, mergedInit)\n }\n\n const dispatcher: Dispatcher = new Agent({\n connect: {\n lookup: createPinnedDnsLookup(hostname, addresses[0]),\n },\n })\n\n try {\n return await globalThis.fetch(rawUrl, { ...mergedInit, dispatcher } as RequestInit & {\n dispatcher: Dispatcher\n })\n } finally {\n dispatcher.close().catch(() => {})\n }\n}\n\nexport type PinnedDnsLookup = (\n host: string,\n opts: unknown,\n cb: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,\n) => void\n\n/**\n * Builds a `dns.lookup`-shaped callback that always returns the supplied pre-validated\n * address for the expected hostname, and refuses to resolve any other hostname. Used as\n * `Agent.connect.lookup` to bind an outbound TCP connect to an IP that has already been\n * validated, so attacker-controlled DNS cannot rebind between validation and connect.\n */\nexport function createPinnedDnsLookup(\n expectedHostname: string,\n pinned: ResolvedHostAddress,\n): PinnedDnsLookup {\n return (host, _opts, cb) => {\n if (host !== expectedHostname) {\n const err: NodeJS.ErrnoException = new Error(\n `Refusing DNS lookup for unexpected host \"${host}\" (expected \"${expectedHostname}\")`,\n )\n err.code = 'EREFUSED'\n cb(err, '', 0)\n return\n }\n cb(null, pinned.address, pinned.family)\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/version.js
CHANGED
package/dist/lib/version.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/version.ts"],
|
|
4
|
-
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.5.1-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.5.1-develop.2924.d13908516e'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.5.1-develop.
|
|
3
|
+
"version": "0.5.1-develop.2924.d13908516e",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -92,11 +92,12 @@
|
|
|
92
92
|
"@mikro-orm/core": "^7.0.10",
|
|
93
93
|
"@mikro-orm/decorators": "^7.0.10",
|
|
94
94
|
"@mikro-orm/postgresql": "^7.0.10",
|
|
95
|
-
"@open-mercato/cache": "0.5.1-develop.
|
|
95
|
+
"@open-mercato/cache": "0.5.1-develop.2924.d13908516e",
|
|
96
96
|
"dotenv": "^17.4.2",
|
|
97
97
|
"rate-limiter-flexible": "^11.0.1",
|
|
98
98
|
"reflect-metadata": "^0.2.2",
|
|
99
|
-
"sanitize-html": "^2.17.2"
|
|
99
|
+
"sanitize-html": "^2.17.2",
|
|
100
|
+
"undici": "^7.24.0"
|
|
100
101
|
},
|
|
101
102
|
"devDependencies": {
|
|
102
103
|
"@types/jest": "^30.0.0",
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertSafeOutboundUrl,
|
|
3
3
|
assertStaticallySafeOutboundUrl,
|
|
4
|
+
createPinnedDnsLookup,
|
|
4
5
|
parseOutboundUrl,
|
|
6
|
+
resolveSafeOutboundUrl,
|
|
7
|
+
safeOutboundFetch,
|
|
5
8
|
UnsafeOutboundUrlError,
|
|
6
9
|
} from '../url-safety'
|
|
7
10
|
|
|
@@ -174,3 +177,132 @@ describe('url-safety — assertSafeOutboundUrl (DNS rebinding guard)', () => {
|
|
|
174
177
|
).rejects.toMatchObject({ reason: 'forbidden_protocol' })
|
|
175
178
|
})
|
|
176
179
|
})
|
|
180
|
+
|
|
181
|
+
describe('url-safety — resolveSafeOutboundUrl', () => {
|
|
182
|
+
it('returns the validated DNS records for use as pinning input', async () => {
|
|
183
|
+
const records = [
|
|
184
|
+
{ address: '93.184.216.34', family: 4 },
|
|
185
|
+
{ address: '93.184.216.35', family: 4 },
|
|
186
|
+
]
|
|
187
|
+
const lookupHost = jest.fn(async () => records)
|
|
188
|
+
const result = await resolveSafeOutboundUrl('https://good.example/', {
|
|
189
|
+
lookupHost,
|
|
190
|
+
allowPrivate: false,
|
|
191
|
+
})
|
|
192
|
+
expect(result.hostname).toBe('good.example')
|
|
193
|
+
expect(result.addresses).toEqual(records)
|
|
194
|
+
expect(lookupHost).toHaveBeenCalledTimes(1)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('returns null addresses for IP literal hosts (no DNS to pin)', async () => {
|
|
198
|
+
const result = await resolveSafeOutboundUrl('https://1.1.1.1/x', { allowPrivate: false })
|
|
199
|
+
expect(result.addresses).toBeNull()
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('returns null addresses when allowPrivate short-circuits validation', async () => {
|
|
203
|
+
const lookupHost = jest.fn()
|
|
204
|
+
const result = await resolveSafeOutboundUrl('http://internal.example/', {
|
|
205
|
+
lookupHost,
|
|
206
|
+
allowPrivate: true,
|
|
207
|
+
})
|
|
208
|
+
expect(result.addresses).toBeNull()
|
|
209
|
+
expect(lookupHost).not.toHaveBeenCalled()
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
it('throws on private resolution before returning addresses', async () => {
|
|
213
|
+
const lookupHost = jest.fn(async () => [{ address: '10.0.0.5', family: 4 }])
|
|
214
|
+
await expect(
|
|
215
|
+
resolveSafeOutboundUrl('https://rebind.evil.example/', {
|
|
216
|
+
lookupHost,
|
|
217
|
+
allowPrivate: false,
|
|
218
|
+
}),
|
|
219
|
+
).rejects.toMatchObject({ reason: 'private_ip_resolved' })
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
describe('url-safety — createPinnedDnsLookup', () => {
|
|
224
|
+
it('returns the pinned address only for the expected hostname', () => {
|
|
225
|
+
const lookup = createPinnedDnsLookup('good.example', { address: '93.184.216.34', family: 4 })
|
|
226
|
+
const cb = jest.fn()
|
|
227
|
+
lookup('good.example', {}, cb)
|
|
228
|
+
expect(cb).toHaveBeenCalledWith(null, '93.184.216.34', 4)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('refuses to resolve a different hostname (defeats redirect to private host via Host header)', () => {
|
|
232
|
+
const lookup = createPinnedDnsLookup('good.example', { address: '93.184.216.34', family: 4 })
|
|
233
|
+
const cb = jest.fn()
|
|
234
|
+
lookup('attacker.example', {}, cb)
|
|
235
|
+
expect(cb).toHaveBeenCalledTimes(1)
|
|
236
|
+
const [err, address, family] = (cb as jest.Mock).mock.calls[0]
|
|
237
|
+
expect(err).toBeInstanceOf(Error)
|
|
238
|
+
expect((err as NodeJS.ErrnoException).code).toBe('EREFUSED')
|
|
239
|
+
expect(address).toBe('')
|
|
240
|
+
expect(family).toBe(0)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
describe('url-safety — safeOutboundFetch', () => {
|
|
245
|
+
it('rejects unsafe URLs before invoking fetchImpl (no socket attempted)', async () => {
|
|
246
|
+
const fetchImpl = jest.fn() as unknown as typeof fetch
|
|
247
|
+
await expect(
|
|
248
|
+
safeOutboundFetch(
|
|
249
|
+
'http://169.254.169.254/latest/meta-data/',
|
|
250
|
+
{},
|
|
251
|
+
{ fetchImpl, allowPrivate: false },
|
|
252
|
+
),
|
|
253
|
+
).rejects.toMatchObject({ reason: 'private_ip_literal' })
|
|
254
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('rejects DNS-rebinding hosts (lookup returns private) before fetch', async () => {
|
|
258
|
+
const fetchImpl = jest.fn() as unknown as typeof fetch
|
|
259
|
+
const lookupHost = jest.fn(async () => [{ address: '10.0.0.5', family: 4 }])
|
|
260
|
+
await expect(
|
|
261
|
+
safeOutboundFetch(
|
|
262
|
+
'https://rebind.evil.example/',
|
|
263
|
+
{},
|
|
264
|
+
{ fetchImpl, lookupHost, allowPrivate: false },
|
|
265
|
+
),
|
|
266
|
+
).rejects.toMatchObject({ reason: 'private_ip_resolved' })
|
|
267
|
+
expect(fetchImpl).not.toHaveBeenCalled()
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('invokes fetchImpl with redirect:"manual" by default', async () => {
|
|
271
|
+
const fetchImpl = jest.fn(async () => new Response('ok', { status: 200 })) as unknown as typeof fetch
|
|
272
|
+
const lookupHost = jest.fn(async () => [{ address: '93.184.216.34', family: 4 }])
|
|
273
|
+
const response = await safeOutboundFetch(
|
|
274
|
+
'https://good.example/hook',
|
|
275
|
+
{ method: 'POST' },
|
|
276
|
+
{ fetchImpl, lookupHost, allowPrivate: false },
|
|
277
|
+
)
|
|
278
|
+
expect(response.status).toBe(200)
|
|
279
|
+
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
280
|
+
const [calledUrl, calledInit] = (fetchImpl as unknown as jest.Mock).mock.calls[0]
|
|
281
|
+
expect(calledUrl).toBe('https://good.example/hook')
|
|
282
|
+
expect(calledInit).toEqual(
|
|
283
|
+
expect.objectContaining({ method: 'POST', redirect: 'manual' }),
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('preserves caller-provided redirect override', async () => {
|
|
288
|
+
const fetchImpl = jest.fn(async () => new Response('ok', { status: 200 })) as unknown as typeof fetch
|
|
289
|
+
await safeOutboundFetch(
|
|
290
|
+
'http://127.0.0.1:3000/dev',
|
|
291
|
+
{ redirect: 'follow' },
|
|
292
|
+
{ fetchImpl, allowPrivate: true },
|
|
293
|
+
)
|
|
294
|
+
const [, calledInit] = (fetchImpl as unknown as jest.Mock).mock.calls[0]
|
|
295
|
+
expect(calledInit.redirect).toBe('follow')
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('runs DNS validation exactly once (the same address is pinned for connect)', async () => {
|
|
299
|
+
const fetchImpl = jest.fn(async () => new Response('ok', { status: 200 })) as unknown as typeof fetch
|
|
300
|
+
const lookupHost = jest.fn(async () => [{ address: '93.184.216.34', family: 4 }])
|
|
301
|
+
await safeOutboundFetch(
|
|
302
|
+
'https://good.example/hook',
|
|
303
|
+
{},
|
|
304
|
+
{ fetchImpl, lookupHost, allowPrivate: false },
|
|
305
|
+
)
|
|
306
|
+
expect(lookupHost).toHaveBeenCalledTimes(1)
|
|
307
|
+
})
|
|
308
|
+
})
|
package/src/lib/url-safety.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { lookup } from 'node:dns/promises'
|
|
2
2
|
import { isIP } from 'node:net'
|
|
3
|
+
import { Agent, type Dispatcher } from 'undici'
|
|
3
4
|
import { isBlockedHostname, isPrivateIpAddress } from './network'
|
|
4
5
|
|
|
5
6
|
export type UrlSafetyReason =
|
|
@@ -107,10 +108,35 @@ export async function assertSafeOutboundUrl(
|
|
|
107
108
|
rawUrl: string,
|
|
108
109
|
options: AssertSafeOutboundUrlOptions = {},
|
|
109
110
|
): Promise<void> {
|
|
111
|
+
await resolveSafeOutboundUrl(rawUrl, options)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ResolvedHostAddress = { address: string; family: number }
|
|
115
|
+
|
|
116
|
+
export type ResolveSafeOutboundUrlResult = {
|
|
117
|
+
url: URL
|
|
118
|
+
hostname: string
|
|
119
|
+
/**
|
|
120
|
+
* The validated DNS records, in lookup order. `null` when the hostname is an IP literal
|
|
121
|
+
* (no DNS lookup performed) or when `allowPrivate` short-circuited validation.
|
|
122
|
+
*/
|
|
123
|
+
addresses: ReadonlyArray<ResolvedHostAddress> | null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validates an outbound URL exactly like `assertSafeOutboundUrl()` and additionally returns
|
|
128
|
+
* the resolved DNS records so the caller can pin the subsequent connection to the same
|
|
129
|
+
* address. This is what `safeOutboundFetch()` uses internally to defeat DNS rebinding —
|
|
130
|
+
* call it directly only if you need to drive the fetch yourself.
|
|
131
|
+
*/
|
|
132
|
+
export async function resolveSafeOutboundUrl(
|
|
133
|
+
rawUrl: string,
|
|
134
|
+
options: AssertSafeOutboundUrlOptions = {},
|
|
135
|
+
): Promise<ResolveSafeOutboundUrlResult> {
|
|
110
136
|
const subject = options.subject ?? 'URL'
|
|
111
137
|
const factory = options.errorFactory ?? defaultErrorFactory
|
|
112
|
-
const { hostname } = parseOutboundUrl(rawUrl, options)
|
|
113
|
-
if (options.allowPrivate) return
|
|
138
|
+
const { url, hostname } = parseOutboundUrl(rawUrl, options)
|
|
139
|
+
if (options.allowPrivate) return { url, hostname, addresses: null }
|
|
114
140
|
|
|
115
141
|
if (isBlockedHostname(hostname)) {
|
|
116
142
|
throw factory('blocked_hostname', `${subject} host "${hostname}" is not allowed`)
|
|
@@ -123,7 +149,7 @@ export async function assertSafeOutboundUrl(
|
|
|
123
149
|
`${subject} host "${hostname}" resolves to a private or reserved IP range`,
|
|
124
150
|
)
|
|
125
151
|
}
|
|
126
|
-
return
|
|
152
|
+
return { url, hostname, addresses: null }
|
|
127
153
|
}
|
|
128
154
|
|
|
129
155
|
const resolver: HostLookup =
|
|
@@ -133,7 +159,7 @@ export async function assertSafeOutboundUrl(
|
|
|
133
159
|
return records
|
|
134
160
|
})
|
|
135
161
|
|
|
136
|
-
let addresses: ReadonlyArray<
|
|
162
|
+
let addresses: ReadonlyArray<ResolvedHostAddress>
|
|
137
163
|
try {
|
|
138
164
|
addresses = await resolver(hostname)
|
|
139
165
|
} catch (error) {
|
|
@@ -160,4 +186,90 @@ export async function assertSafeOutboundUrl(
|
|
|
160
186
|
)
|
|
161
187
|
}
|
|
162
188
|
}
|
|
189
|
+
|
|
190
|
+
return { url, hostname, addresses }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export type SafeOutboundFetchOptions = AssertSafeOutboundUrlOptions & {
|
|
194
|
+
/**
|
|
195
|
+
* Test/seam injection. When provided, `safeOutboundFetch` calls `fetchImpl(url, init)` after
|
|
196
|
+
* URL validation instead of using the global `fetch` with a DNS-pinned dispatcher. Tests do
|
|
197
|
+
* not actually open sockets, so DNS pinning is unnecessary and would just complicate mocking.
|
|
198
|
+
*/
|
|
199
|
+
fetchImpl?: typeof fetch
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Validates an outbound URL and performs `fetch()` with the connection pinned to a
|
|
204
|
+
* pre-validated IP address, so DNS cannot be re-resolved between validation and connect
|
|
205
|
+
* (DNS rebinding). Always defaults to `redirect: 'manual'` — callers MUST decide what to
|
|
206
|
+
* do with 3xx responses (re-validate the redirect target before following).
|
|
207
|
+
*
|
|
208
|
+
* For IP literal hosts and `allowPrivate=true`, no DNS pinning is performed because there
|
|
209
|
+
* is no DNS lookup to defeat.
|
|
210
|
+
*/
|
|
211
|
+
export async function safeOutboundFetch(
|
|
212
|
+
rawUrl: string,
|
|
213
|
+
init: RequestInit = {},
|
|
214
|
+
options: SafeOutboundFetchOptions = {},
|
|
215
|
+
): Promise<Response> {
|
|
216
|
+
const { url, hostname, addresses } = await resolveSafeOutboundUrl(rawUrl, options)
|
|
217
|
+
void url
|
|
218
|
+
|
|
219
|
+
const mergedInit: RequestInit = {
|
|
220
|
+
redirect: 'manual',
|
|
221
|
+
...init,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const fetchImpl = options.fetchImpl
|
|
225
|
+
if (fetchImpl) {
|
|
226
|
+
return fetchImpl(rawUrl, mergedInit)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (!addresses || addresses.length === 0) {
|
|
230
|
+
return globalThis.fetch(rawUrl, mergedInit)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const dispatcher: Dispatcher = new Agent({
|
|
234
|
+
connect: {
|
|
235
|
+
lookup: createPinnedDnsLookup(hostname, addresses[0]),
|
|
236
|
+
},
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
return await globalThis.fetch(rawUrl, { ...mergedInit, dispatcher } as RequestInit & {
|
|
241
|
+
dispatcher: Dispatcher
|
|
242
|
+
})
|
|
243
|
+
} finally {
|
|
244
|
+
dispatcher.close().catch(() => {})
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export type PinnedDnsLookup = (
|
|
249
|
+
host: string,
|
|
250
|
+
opts: unknown,
|
|
251
|
+
cb: (err: NodeJS.ErrnoException | null, address: string, family: number) => void,
|
|
252
|
+
) => void
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Builds a `dns.lookup`-shaped callback that always returns the supplied pre-validated
|
|
256
|
+
* address for the expected hostname, and refuses to resolve any other hostname. Used as
|
|
257
|
+
* `Agent.connect.lookup` to bind an outbound TCP connect to an IP that has already been
|
|
258
|
+
* validated, so attacker-controlled DNS cannot rebind between validation and connect.
|
|
259
|
+
*/
|
|
260
|
+
export function createPinnedDnsLookup(
|
|
261
|
+
expectedHostname: string,
|
|
262
|
+
pinned: ResolvedHostAddress,
|
|
263
|
+
): PinnedDnsLookup {
|
|
264
|
+
return (host, _opts, cb) => {
|
|
265
|
+
if (host !== expectedHostname) {
|
|
266
|
+
const err: NodeJS.ErrnoException = new Error(
|
|
267
|
+
`Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`,
|
|
268
|
+
)
|
|
269
|
+
err.code = 'EREFUSED'
|
|
270
|
+
cb(err, '', 0)
|
|
271
|
+
return
|
|
272
|
+
}
|
|
273
|
+
cb(null, pinned.address, pinned.family)
|
|
274
|
+
}
|
|
163
275
|
}
|