@open-mercato/shared 0.6.6-develop.6542.1.fee3e68aa6 → 0.6.6-develop.6551.1.1511bfa890
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/network.js +20 -1
- package/dist/lib/network.js.map +2 -2
- package/dist/lib/url-safety.js +14 -7
- package/dist/lib/url-safety.js.map +2 -2
- package/dist/lib/version.js +1 -1
- package/dist/lib/version.js.map +1 -1
- package/dist/modules/payment_gateways/types.js.map +2 -2
- package/package.json +2 -2
- package/src/lib/__tests__/url-safety.test.ts +28 -0
- package/src/lib/network.ts +28 -1
- package/src/lib/url-safety.ts +33 -14
- package/src/modules/payment_gateways/types.ts +2 -0
package/dist/lib/network.js
CHANGED
|
@@ -69,11 +69,30 @@ function isPrivateIPv6(address) {
|
|
|
69
69
|
if (segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1) return true;
|
|
70
70
|
if (isIPv4CompatibleIPv6(segments)) return true;
|
|
71
71
|
const embedded = embeddedIPv4FromIPv6(segments);
|
|
72
|
-
if (embedded
|
|
72
|
+
if (embedded) return isPrivateIPv4(embedded);
|
|
73
73
|
const [a] = segments;
|
|
74
74
|
if ((a & 65024) === 64512) return true;
|
|
75
75
|
if ((a & 65472) === 65152) return true;
|
|
76
76
|
if ((a & 65280) === 65280) return true;
|
|
77
|
+
if (a === 256 && segments[1] === 0 && segments[2] === 0 && segments[3] === 0) {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (a === 8193 && (segments[1] & 65024) === 0) {
|
|
81
|
+
return !isGloballyReachableIetfProtocolAssignment(segments);
|
|
82
|
+
}
|
|
83
|
+
if (a === 8193 && segments[1] === 3512) return true;
|
|
84
|
+
if ((a & 65520) === 16368) return true;
|
|
85
|
+
if ((a & 57344) !== 8192) return true;
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
function isGloballyReachableIetfProtocolAssignment(segments) {
|
|
89
|
+
const [, b, c, d, e, f, g, h] = segments;
|
|
90
|
+
const trailingZero = c === 0 && d === 0 && e === 0 && f === 0 && g === 0;
|
|
91
|
+
if (b === 1 && trailingZero && h >= 1 && h <= 3) return true;
|
|
92
|
+
if (b === 3) return true;
|
|
93
|
+
if (b === 4 && c === 274) return true;
|
|
94
|
+
if ((b & 65520) === 32) return true;
|
|
95
|
+
if ((b & 65520) === 48) return true;
|
|
77
96
|
return false;
|
|
78
97
|
}
|
|
79
98
|
function expandIPv6(address) {
|
package/dist/lib/network.js.map
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/network.ts"],
|
|
4
|
-
"sourcesContent": ["import { isIP } from 'node:net'\n\nconst BLOCKED_HOSTNAMES = new Set([\n 'localhost',\n 'ip6-localhost',\n 'ip6-loopback',\n 'broadcasthost',\n])\n\nexport function isPrivateIpAddress(address: string): boolean {\n const family = isIP(address)\n if (family === 4) return isPrivateIPv4(address)\n if (family === 6) return isPrivateIPv6(address)\n return false\n}\n\nexport function isBlockedHostname(hostname: string): boolean {\n const normalized = normalizeHostname(hostname)\n if (!normalized) return true\n if (BLOCKED_HOSTNAMES.has(normalized)) return true\n if (normalized.endsWith('.localhost')) return true\n if (normalized.endsWith('.internal')) return true\n if (normalized.endsWith('.local')) return true\n return false\n}\n\nexport function isPrivateUrl(rawUrl: string): boolean {\n const urlToParse = rawUrl.replace(/\\[([0-9a-fA-F:]+)%25[^\\]]*\\]/g, '[$1]')\n let url: URL\n try {\n url = new URL(urlToParse)\n } catch {\n return false\n }\n\n const hostname = normalizeHostname(url.hostname)\n if (isBlockedHostname(hostname)) return true\n return isPrivateIpAddress(hostname)\n}\n\nfunction normalizeHostname(hostname: string): string {\n let normalized = hostname.trim().toLowerCase()\n if (normalized.startsWith('[') && normalized.endsWith(']')) {\n normalized = normalized.slice(1, -1)\n }\n while (normalized.endsWith('.')) {\n normalized = normalized.slice(0, -1)\n }\n return normalized\n}\n\nexport function isPrivateIPv4(address: string): boolean {\n const parts = address.split('.')\n if (parts.length !== 4) return false\n const octets = parts.map((part) => Number(part))\n if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return false\n\n const [a, b, c] = octets\n if (a === 0) return true\n if (a === 10) return true\n if (a === 100 && b >= 64 && b <= 127) return true\n if (a === 127) return true\n if (a === 169 && b === 254) return true\n if (a === 172 && b >= 16 && b <= 31) return true\n if (a === 192 && b === 0 && (c === 0 || c === 2)) return true\n if (a === 192 && b === 168) return true\n if (a === 198 && (b === 18 || b === 19)) return true\n if (a === 198 && b === 51 && c === 100) return true\n if (a === 203 && b === 0 && c === 113) return true\n if (a >= 224) return true\n return false\n}\n\nexport function isPrivateIPv6(address: string): boolean {\n const segments = expandIPv6(address)\n if (!segments) return false\n\n if (segments.every((segment) => segment === 0)) return true\n if (segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1) return true\n if (isIPv4CompatibleIPv6(segments)) return true\n\n const embedded = embeddedIPv4FromIPv6(segments)\n if (embedded
|
|
5
|
-
"mappings": "AAAA,SAAS,YAAY;AAErB,MAAM,oBAAoB,oBAAI,IAAI;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,CAAC;AAEM,SAAS,mBAAmB,SAA0B;AAC3D,QAAM,SAAS,KAAK,OAAO;AAC3B,MAAI,WAAW,EAAG,QAAO,cAAc,OAAO;AAC9C,MAAI,WAAW,EAAG,QAAO,cAAc,OAAO;AAC9C,SAAO;AACT;AAEO,SAAS,kBAAkB,UAA2B;AAC3D,QAAM,aAAa,kBAAkB,QAAQ;AAC7C,MAAI,CAAC,WAAY,QAAO;AACxB,MAAI,kBAAkB,IAAI,UAAU,EAAG,QAAO;AAC9C,MAAI,WAAW,SAAS,YAAY,EAAG,QAAO;AAC9C,MAAI,WAAW,SAAS,WAAW,EAAG,QAAO;AAC7C,MAAI,WAAW,SAAS,QAAQ,EAAG,QAAO;AAC1C,SAAO;AACT;AAEO,SAAS,aAAa,QAAyB;AACpD,QAAM,aAAa,OAAO,QAAQ,iCAAiC,MAAM;AACzE,MAAI;AACJ,MAAI;AACF,UAAM,IAAI,IAAI,UAAU;AAAA,EAC1B,QAAQ;AACN,WAAO;AAAA,EACT;AAEA,QAAM,WAAW,kBAAkB,IAAI,QAAQ;AAC/C,MAAI,kBAAkB,QAAQ,EAAG,QAAO;AACxC,SAAO,mBAAmB,QAAQ;AACpC;AAEA,SAAS,kBAAkB,UAA0B;AACnD,MAAI,aAAa,SAAS,KAAK,EAAE,YAAY;AAC7C,MAAI,WAAW,WAAW,GAAG,KAAK,WAAW,SAAS,GAAG,GAAG;AAC1D,iBAAa,WAAW,MAAM,GAAG,EAAE;AAAA,EACrC;AACA,SAAO,WAAW,SAAS,GAAG,GAAG;AAC/B,iBAAa,WAAW,MAAM,GAAG,EAAE;AAAA,EACrC;AACA,SAAO;AACT;AAEO,SAAS,cAAc,SAA0B;AACtD,QAAM,QAAQ,QAAQ,MAAM,GAAG;AAC/B,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,QAAM,SAAS,MAAM,IAAI,CAAC,SAAS,OAAO,IAAI,CAAC;AAC/C,MAAI,OAAO,KAAK,CAAC,MAAM,CAAC,OAAO,UAAU,CAAC,KAAK,IAAI,KAAK,IAAI,GAAG,EAAG,QAAO;AAEzE,QAAM,CAAC,GAAG,GAAG,CAAC,IAAI;AAClB,MAAI,MAAM,EAAG,QAAO;AACpB,MAAI,MAAM,GAAI,QAAO;AACrB,MAAI,MAAM,OAAO,KAAK,MAAM,KAAK,IAAK,QAAO;AAC7C,MAAI,MAAM,IAAK,QAAO;AACtB,MAAI,MAAM,OAAO,MAAM,IAAK,QAAO;AACnC,MAAI,MAAM,OAAO,KAAK,MAAM,KAAK,GAAI,QAAO;AAC5C,MAAI,MAAM,OAAO,MAAM,MAAM,MAAM,KAAK,MAAM,GAAI,QAAO;AACzD,MAAI,MAAM,OAAO,MAAM,IAAK,QAAO;AACnC,MAAI,MAAM,QAAQ,MAAM,MAAM,MAAM,IAAK,QAAO;AAChD,MAAI,MAAM,OAAO,MAAM,MAAM,MAAM,IAAK,QAAO;AAC/C,MAAI,MAAM,OAAO,MAAM,KAAK,MAAM,IAAK,QAAO;AAC9C,MAAI,KAAK,IAAK,QAAO;AACrB,SAAO;AACT;AAEO,SAAS,cAAc,SAA0B;AACtD,QAAM,WAAW,WAAW,OAAO;AACnC,MAAI,CAAC,SAAU,QAAO;AAEtB,MAAI,SAAS,MAAM,CAAC,YAAY,YAAY,CAAC,EAAG,QAAO;AACvD,MAAI,SAAS,MAAM,GAAG,CAAC,EAAE,MAAM,CAAC,YAAY,YAAY,CAAC,KAAK,SAAS,CAAC,MAAM,EAAG,QAAO;AACxF,MAAI,qBAAqB,QAAQ,EAAG,QAAO;AAE3C,QAAM,WAAW,qBAAqB,QAAQ;AAC9C,MAAI,
|
|
4
|
+
"sourcesContent": ["import { isIP } from 'node:net'\n\nconst BLOCKED_HOSTNAMES = new Set([\n 'localhost',\n 'ip6-localhost',\n 'ip6-loopback',\n 'broadcasthost',\n])\n\nexport function isPrivateIpAddress(address: string): boolean {\n const family = isIP(address)\n if (family === 4) return isPrivateIPv4(address)\n if (family === 6) return isPrivateIPv6(address)\n return false\n}\n\nexport function isBlockedHostname(hostname: string): boolean {\n const normalized = normalizeHostname(hostname)\n if (!normalized) return true\n if (BLOCKED_HOSTNAMES.has(normalized)) return true\n if (normalized.endsWith('.localhost')) return true\n if (normalized.endsWith('.internal')) return true\n if (normalized.endsWith('.local')) return true\n return false\n}\n\nexport function isPrivateUrl(rawUrl: string): boolean {\n const urlToParse = rawUrl.replace(/\\[([0-9a-fA-F:]+)%25[^\\]]*\\]/g, '[$1]')\n let url: URL\n try {\n url = new URL(urlToParse)\n } catch {\n return false\n }\n\n const hostname = normalizeHostname(url.hostname)\n if (isBlockedHostname(hostname)) return true\n return isPrivateIpAddress(hostname)\n}\n\nfunction normalizeHostname(hostname: string): string {\n let normalized = hostname.trim().toLowerCase()\n if (normalized.startsWith('[') && normalized.endsWith(']')) {\n normalized = normalized.slice(1, -1)\n }\n while (normalized.endsWith('.')) {\n normalized = normalized.slice(0, -1)\n }\n return normalized\n}\n\nexport function isPrivateIPv4(address: string): boolean {\n const parts = address.split('.')\n if (parts.length !== 4) return false\n const octets = parts.map((part) => Number(part))\n if (octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) return false\n\n const [a, b, c] = octets\n if (a === 0) return true\n if (a === 10) return true\n if (a === 100 && b >= 64 && b <= 127) return true\n if (a === 127) return true\n if (a === 169 && b === 254) return true\n if (a === 172 && b >= 16 && b <= 31) return true\n if (a === 192 && b === 0 && (c === 0 || c === 2)) return true\n if (a === 192 && b === 168) return true\n if (a === 198 && (b === 18 || b === 19)) return true\n if (a === 198 && b === 51 && c === 100) return true\n if (a === 203 && b === 0 && c === 113) return true\n if (a >= 224) return true\n return false\n}\n\nexport function isPrivateIPv6(address: string): boolean {\n const segments = expandIPv6(address)\n if (!segments) return false\n\n if (segments.every((segment) => segment === 0)) return true\n if (segments.slice(0, 7).every((segment) => segment === 0) && segments[7] === 1) return true\n if (isIPv4CompatibleIPv6(segments)) return true\n\n const embedded = embeddedIPv4FromIPv6(segments)\n if (embedded) return isPrivateIPv4(embedded)\n\n const [a] = segments\n if ((a & 0xfe00) === 0xfc00) return true\n if ((a & 0xffc0) === 0xfe80) return true\n if ((a & 0xff00) === 0xff00) return true\n if (\n a === 0x0100 &&\n segments[1] === 0 &&\n segments[2] === 0 &&\n segments[3] === 0\n ) {\n return true\n }\n if (a === 0x2001 && (segments[1] & 0xfe00) === 0) {\n return !isGloballyReachableIetfProtocolAssignment(segments)\n }\n if (a === 0x2001 && segments[1] === 0x0db8) return true\n if ((a & 0xfff0) === 0x3ff0) return true\n // Except for the explicitly handled IPv4 translation ranges above, globally\n // routable unicast IPv6 addresses are allocated from 2000::/3.\n if ((a & 0xe000) !== 0x2000) return true\n return false\n}\n\nfunction isGloballyReachableIetfProtocolAssignment(segments: number[]): boolean {\n const [, b, c, d, e, f, g, h] = segments\n const trailingZero = c === 0 && d === 0 && e === 0 && f === 0 && g === 0\n if (b === 0x0001 && trailingZero && h >= 1 && h <= 3) return true\n if (b === 0x0003) return true\n if (b === 0x0004 && c === 0x0112) return true\n if ((b & 0xfff0) === 0x0020) return true\n if ((b & 0xfff0) === 0x0030) return true\n return false\n}\n\nfunction expandIPv6(address: string): number[] | null {\n let normalized = address.toLowerCase()\n if (normalized.includes('.')) {\n const lastColon = normalized.lastIndexOf(':')\n if (lastColon === -1) return null\n const ipv4Segments = ipv4ToHexSegments(normalized.slice(lastColon + 1))\n if (!ipv4Segments) return null\n normalized = `${normalized.slice(0, lastColon)}:${ipv4Segments[0].toString(16)}:${ipv4Segments[1].toString(16)}`\n }\n\n const doubleColonParts = normalized.split('::')\n if (doubleColonParts.length > 2) return null\n\n const head = doubleColonParts[0] ? doubleColonParts[0].split(':') : []\n const tail = doubleColonParts[1] ? doubleColonParts[1].split(':') : []\n const headSegments = parseIPv6SegmentList(head)\n const tailSegments = parseIPv6SegmentList(tail)\n if (!headSegments || !tailSegments) return null\n\n if (doubleColonParts.length === 1) {\n return headSegments.length === 8 ? headSegments : null\n }\n\n const missing = 8 - headSegments.length - tailSegments.length\n if (missing < 1) return null\n return [\n ...headSegments,\n ...Array.from({ length: missing }, () => 0),\n ...tailSegments,\n ]\n}\n\nfunction parseIPv6SegmentList(parts: string[]): number[] | null {\n const segments: number[] = []\n for (const part of parts) {\n if (!/^[0-9a-f]{1,4}$/.test(part)) return null\n const value = Number.parseInt(part, 16)\n if (!Number.isInteger(value) || value < 0 || value > 0xffff) return null\n segments.push(value)\n }\n return segments\n}\n\nfunction ipv4ToHexSegments(address: string): [number, number] | null {\n const octets = address.split('.').map((part) => Number(part))\n if (octets.length !== 4) return null\n if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null\n return [\n (octets[0] << 8) + octets[1],\n (octets[2] << 8) + octets[3],\n ]\n}\n\nfunction embeddedIPv4FromIPv6(segments: number[]): string | null {\n const firstFiveZero = segments.slice(0, 5).every((segment) => segment === 0)\n const isMapped = firstFiveZero && segments[5] === 0xffff\n const isNat64 = segments[0] === 0x0064\n && segments[1] === 0xff9b\n && segments.slice(2, 6).every((segment) => segment === 0)\n const isSixToFour = segments[0] === 0x2002\n\n if (isMapped || isNat64) {\n return ipv4FromHexSegments(segments[6], segments[7])\n }\n if (isSixToFour) {\n return ipv4FromHexSegments(segments[1], segments[2])\n }\n return null\n}\n\nfunction isIPv4CompatibleIPv6(segments: number[]): boolean {\n return segments.slice(0, 6).every((segment) => segment === 0)\n}\n\nfunction ipv4FromHexSegments(high: number, low: number): string {\n return [\n (high >> 8) & 0xff,\n high & 0xff,\n (low >> 8) & 0xff,\n low & 0xff,\n ].join('.')\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/dist/lib/url-safety.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { lookup } from "node:dns/promises";
|
|
2
2
|
import { isIP } from "node:net";
|
|
3
|
-
import { Agent } from "undici";
|
|
3
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
4
4
|
import { isBlockedHostname, isPrivateIpAddress } from "./network.js";
|
|
5
5
|
class UnsafeOutboundUrlError extends Error {
|
|
6
6
|
constructor(reason, message) {
|
|
@@ -60,12 +60,14 @@ async function resolveSafeOutboundUrl(rawUrl, options = {}) {
|
|
|
60
60
|
const subject = options.subject ?? "URL";
|
|
61
61
|
const factory = options.errorFactory ?? defaultErrorFactory;
|
|
62
62
|
const { url, hostname } = parseOutboundUrl(rawUrl, options);
|
|
63
|
-
if (options.allowPrivate)
|
|
64
|
-
|
|
63
|
+
if (options.allowPrivate && !options.pinAllowedPrivate) {
|
|
64
|
+
return { url, hostname, addresses: null };
|
|
65
|
+
}
|
|
66
|
+
if (!options.allowPrivate && isBlockedHostname(hostname)) {
|
|
65
67
|
throw factory("blocked_hostname", `${subject} host "${hostname}" is not allowed`);
|
|
66
68
|
}
|
|
67
69
|
if (isIP(hostname)) {
|
|
68
|
-
if (isPrivateIpAddress(hostname)) {
|
|
70
|
+
if (!options.allowPrivate && isPrivateIpAddress(hostname)) {
|
|
69
71
|
throw factory(
|
|
70
72
|
"private_ip_literal",
|
|
71
73
|
`${subject} host "${hostname}" resolves to a private or reserved IP range`
|
|
@@ -93,7 +95,7 @@ async function resolveSafeOutboundUrl(rawUrl, options = {}) {
|
|
|
93
95
|
);
|
|
94
96
|
}
|
|
95
97
|
for (const record of addresses) {
|
|
96
|
-
if (isPrivateIpAddress(record.address)) {
|
|
98
|
+
if (!options.allowPrivate && isPrivateIpAddress(record.address)) {
|
|
97
99
|
throw factory(
|
|
98
100
|
"private_ip_resolved",
|
|
99
101
|
`${subject} host "${hostname}" resolves to a private or reserved IP address (${record.address})`
|
|
@@ -122,14 +124,15 @@ async function safeOutboundFetch(rawUrl, init = {}, options = {}) {
|
|
|
122
124
|
}
|
|
123
125
|
});
|
|
124
126
|
try {
|
|
125
|
-
|
|
127
|
+
const pinnedFetch = options.pinnedFetchImpl ?? undiciFetch;
|
|
128
|
+
return await pinnedFetch(rawUrl, { ...mergedInit, dispatcher });
|
|
126
129
|
} finally {
|
|
127
130
|
dispatcher.close().catch(() => {
|
|
128
131
|
});
|
|
129
132
|
}
|
|
130
133
|
}
|
|
131
134
|
function createPinnedDnsLookup(expectedHostname, pinned) {
|
|
132
|
-
return (host,
|
|
135
|
+
return (host, opts, cb) => {
|
|
133
136
|
if (host !== expectedHostname) {
|
|
134
137
|
const err = new Error(
|
|
135
138
|
`Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`
|
|
@@ -138,6 +141,10 @@ function createPinnedDnsLookup(expectedHostname, pinned) {
|
|
|
138
141
|
cb(err, "", 0);
|
|
139
142
|
return;
|
|
140
143
|
}
|
|
144
|
+
if (opts.all) {
|
|
145
|
+
cb(null, [pinned]);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
141
148
|
cb(null, pinned.address, pinned.family);
|
|
142
149
|
};
|
|
143
150
|
}
|
|
@@ -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 { 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
|
|
5
|
-
"mappings": "AAAA,SAAS,cAAc;AACvB,SAAS,YAAY;AACrB,SAAS,
|
|
4
|
+
"sourcesContent": ["import { lookup } from 'node:dns/promises'\nimport { isIP } from 'node:net'\nimport { Agent, fetch as undiciFetch, 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 /** Resolve and return DNS answers even when private addresses are explicitly allowed. */\n pinAllowedPrivate?: boolean\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 without\n * `pinAllowedPrivate`.\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 && !options.pinAllowedPrivate) {\n return { url, hostname, addresses: null }\n }\n\n if (!options.allowPrivate && isBlockedHostname(hostname)) {\n throw factory('blocked_hostname', `${subject} host \"${hostname}\" is not allowed`)\n }\n\n if (isIP(hostname)) {\n if (!options.allowPrivate && 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 (!options.allowPrivate && 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 /** Test seam for the dispatcher-compatible fetch used by DNS-pinned requests. */\n pinnedFetchImpl?: PinnedFetch\n}\n\nexport type PinnedFetch = (\n rawUrl: string,\n init: RequestInit & { dispatcher: Dispatcher },\n) => Promise<Response>\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 no DNS pinning is performed because there is no DNS lookup to defeat.\n * Callers that allow private hostnames can set `pinAllowedPrivate` to retain DNS pinning.\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 const pinnedFetch = options.pinnedFetchImpl ?? (undiciFetch as unknown as PinnedFetch)\n return await pinnedFetch(rawUrl, { ...mergedInit, dispatcher })\n } finally {\n dispatcher.close().catch(() => {})\n }\n}\n\nexport type PinnedDnsLookup = (\n host: string,\n opts: { all?: boolean },\n cb: (\n err: NodeJS.ErrnoException | null,\n address: string | ResolvedHostAddress[],\n family?: number,\n ) => 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 if (opts.all) {\n cb(null, [pinned])\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.6.6-develop.
|
|
4
|
+
"sourcesContent": ["// Build-time generated version\nexport const APP_VERSION = '0.6.6-develop.6551.1.1511bfa890'\nexport const appVersion = APP_VERSION\n"],
|
|
5
5
|
"mappings": "AACO,MAAM,cAAc;AACpB,MAAM,aAAa;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/payment_gateways/types.ts"],
|
|
4
|
-
"sourcesContent": ["// \u2500\u2500 Unified Payment Status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport type UnifiedPaymentStatus =\n | 'pending'\n | 'authorized'\n | 'captured'\n | 'partially_captured'\n | 'refunded'\n | 'partially_refunded'\n | 'cancelled'\n | 'failed'\n | 'expired'\n | 'unknown'\n\n// \u2500\u2500 GatewayAdapter Interface \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface GatewayAdapter {\n readonly providerKey: string\n\n /** Create a payment session / payment intent */\n createSession(input: CreateSessionInput): Promise<CreateSessionResult>\n\n /** Capture an authorized payment */\n capture(input: CaptureInput): Promise<CaptureResult>\n\n /** Refund a captured payment (full or partial) */\n refund(input: RefundInput): Promise<RefundResult>\n\n /** Cancel / void an authorized payment before capture */\n cancel(input: CancelInput): Promise<CancelResult>\n\n /** Get current payment status from provider */\n getStatus(input: GetStatusInput): Promise<GatewayPaymentStatus>\n\n /** Verify and parse an inbound webhook event */\n verifyWebhook(input: VerifyWebhookInput): Promise<WebhookEvent>\n\n /** Map provider status to unified status */\n mapStatus(providerStatus: string, eventType?: string): UnifiedPaymentStatus\n}\n\nexport type PaymentGatewayDescriptorFieldType =\n | 'text'\n | 'number'\n | 'select'\n | 'multiselect'\n | 'boolean'\n | 'textarea'\n | 'secret'\n | 'url'\n\nexport interface PaymentGatewayDescriptorField {\n key: string\n label: string\n type: PaymentGatewayDescriptorFieldType\n description?: string\n required?: boolean\n options?: Array<{ value: string; label: string }>\n}\n\nexport type PaymentGatewayRendererType = 'embedded' | 'redirect'\n\nexport interface PaymentGatewayRendererDescriptor {\n key: string\n label: string\n type: PaymentGatewayRendererType\n description?: string\n supportedPaymentTypes?: '*' | string[]\n settingsFields?: PaymentGatewayDescriptorField[]\n}\n\nexport interface PaymentGatewayPresentationRequest {\n mode?: 'auto' | PaymentGatewayRendererType\n rendererKey?: string\n rendererSettings?: Record<string, unknown>\n}\n\nexport interface PaymentGatewayDescriptor {\n providerKey: string\n label: string\n sessionConfig?: {\n fields?: PaymentGatewayDescriptorField[]\n supportedCurrencies?: '*' | string[]\n supportedPaymentTypes?: Array<{ value: string; label: string }>\n defaultRendererKey?: string\n renderers?: PaymentGatewayRendererDescriptor[]\n /**\n * @deprecated Use `renderers` and `defaultRendererKey`.\n */\n presentation?: 'embedded' | 'redirect' | 'either'\n /**\n * @deprecated Use `renderers`.\n */\n embeddedRenderers?: string[]\n }\n}\n\nexport interface EmbeddedPaymentGatewayClientSession {\n type: 'embedded'\n rendererKey: string\n payload?: Record<string, unknown>\n settings?: Record<string, unknown>\n}\n\nexport interface RedirectPaymentGatewayClientSession {\n type: 'redirect'\n redirectUrl: string\n target?: 'self' | 'top'\n}\n\nexport type PaymentGatewayClientSession =\n | EmbeddedPaymentGatewayClientSession\n | RedirectPaymentGatewayClientSession\n\n// \u2500\u2500 Input / Output Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface CreateSessionInput {\n orderId?: string\n paymentId: string\n tenantId: string\n organizationId: string\n amount: number\n currencyCode: string\n captureMethod?: 'automatic' | 'manual'\n paymentTypes?: string[]\n description?: string\n successUrl?: string\n cancelUrl?: string\n metadata?: Record<string, unknown>\n credentials: Record<string, unknown>\n lineItems?: SessionLineItem[]\n presentation?: PaymentGatewayPresentationRequest\n}\n\nexport interface SessionLineItem {\n name: string\n quantity: number\n unitAmount: number\n currencyCode: string\n}\n\nexport interface CreateSessionResult {\n sessionId: string\n clientSecret?: string\n redirectUrl?: string\n status: UnifiedPaymentStatus\n providerData?: Record<string, unknown>\n clientSession?: PaymentGatewayClientSession\n}\n\nexport interface CaptureInput {\n sessionId: string\n amount?: number\n credentials: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface CaptureResult {\n status: UnifiedPaymentStatus\n capturedAmount: number\n providerData?: Record<string, unknown>\n}\n\nexport interface RefundInput {\n sessionId: string\n amount?: number\n reason?: string\n credentials: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface RefundResult {\n refundId: string\n status: UnifiedPaymentStatus\n refundedAmount: number\n providerData?: Record<string, unknown>\n}\n\nexport interface CancelInput {\n sessionId: string\n reason?: string\n credentials: Record<string, unknown>\n}\n\nexport interface CancelResult {\n status: UnifiedPaymentStatus\n providerData?: Record<string, unknown>\n}\n\nexport interface GetStatusInput {\n sessionId: string\n credentials: Record<string, unknown>\n}\n\nexport interface GatewayPaymentStatus {\n status: UnifiedPaymentStatus\n amount: number\n amountReceived: number\n currencyCode: string\n providerData?: Record<string, unknown>\n}\n\nexport interface VerifyWebhookInput {\n rawBody: string | Buffer\n headers: Record<string, string | string[] | undefined>\n credentials: Record<string, unknown>\n}\n\nexport interface WebhookEvent {\n eventType: string\n eventId: string\n data: Record<string, unknown>\n idempotencyKey: string\n timestamp: Date\n}\n\n// \u2500\u2500 Webhook Handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface WebhookHandlerRegistration {\n handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>\n queue?: string\n readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null\n}\n\n// \u2500\u2500 Adapter Registry Options \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface RegisterAdapterOptions {\n version?: string\n}\n\n// \u2500\u2500 Registries \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst ADAPTER_REGISTRY_KEY = '__openMercatoPaymentGatewayAdapters__'\nconst WEBHOOK_REGISTRY_KEY = '__openMercatoPaymentGatewayWebhookHandlers__'\nconst DESCRIPTOR_REGISTRY_KEY = '__openMercatoPaymentGatewayDescriptors__'\n\nfunction getAdapterRegistry(): Map<string, GatewayAdapter> {\n const globalState = globalThis as typeof globalThis & {\n [ADAPTER_REGISTRY_KEY]?: Map<string, GatewayAdapter>\n }\n if (!globalState[ADAPTER_REGISTRY_KEY]) {\n globalState[ADAPTER_REGISTRY_KEY] = new Map<string, GatewayAdapter>()\n }\n return globalState[ADAPTER_REGISTRY_KEY]\n}\n\nfunction getWebhookHandlerRegistry(): Map<string, WebhookHandlerRegistration> {\n const globalState = globalThis as typeof globalThis & {\n [WEBHOOK_REGISTRY_KEY]?: Map<string, WebhookHandlerRegistration>\n }\n if (!globalState[WEBHOOK_REGISTRY_KEY]) {\n globalState[WEBHOOK_REGISTRY_KEY] = new Map<string, WebhookHandlerRegistration>()\n }\n return globalState[WEBHOOK_REGISTRY_KEY]\n}\n\nfunction getDescriptorRegistry(): Map<string, PaymentGatewayDescriptor> {\n const globalState = globalThis as typeof globalThis & {\n [DESCRIPTOR_REGISTRY_KEY]?: Map<string, PaymentGatewayDescriptor>\n }\n if (!globalState[DESCRIPTOR_REGISTRY_KEY]) {\n globalState[DESCRIPTOR_REGISTRY_KEY] = new Map<string, PaymentGatewayDescriptor>()\n }\n return globalState[DESCRIPTOR_REGISTRY_KEY]\n}\n\nfunction adapterKey(providerKey: string, version?: string): string {\n return version ? `${providerKey}:${version}` : providerKey\n}\n\nexport function registerGatewayAdapter(adapter: GatewayAdapter, options?: RegisterAdapterOptions): () => void {\n const adapterRegistry = getAdapterRegistry()\n const key = adapterKey(adapter.providerKey, options?.version)\n adapterRegistry.set(key, adapter)\n if (options?.version) {\n // Also register as default if no default exists\n if (!adapterRegistry.has(adapter.providerKey)) {\n adapterRegistry.set(adapter.providerKey, adapter)\n }\n }\n return () => {\n adapterRegistry.delete(key)\n }\n}\n\nexport function getGatewayAdapter(providerKey: string, version?: string): GatewayAdapter | undefined {\n const adapterRegistry = getAdapterRegistry()\n if (version) {\n return adapterRegistry.get(adapterKey(providerKey, version)) ?? adapterRegistry.get(providerKey)\n }\n return adapterRegistry.get(providerKey)\n}\n\nexport function listGatewayAdapters(): GatewayAdapter[] {\n const adapterRegistry = getAdapterRegistry()\n const seen = new Set<string>()\n const result: GatewayAdapter[] = []\n for (const [key, adapter] of adapterRegistry) {\n if (!key.includes(':') && !seen.has(adapter.providerKey)) {\n seen.add(adapter.providerKey)\n result.push(adapter)\n }\n }\n return result\n}\n\nexport function clearGatewayAdapters(): void {\n getAdapterRegistry().clear()\n}\n\nexport function registerWebhookHandler(\n providerKey: string,\n handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>,\n options?: {\n queue?: string\n readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null\n },\n): () => void {\n const webhookHandlerRegistry = getWebhookHandlerRegistry()\n webhookHandlerRegistry.set(providerKey, {\n handler,\n queue: options?.queue,\n readSessionIdHint: options?.readSessionIdHint,\n })\n return () => {\n webhookHandlerRegistry.delete(providerKey)\n }\n}\n\nexport function getWebhookHandler(providerKey: string): WebhookHandlerRegistration | undefined {\n return getWebhookHandlerRegistry().get(providerKey)\n}\n\nexport function clearWebhookHandlers(): void {\n getWebhookHandlerRegistry().clear()\n}\n\nexport function registerPaymentGatewayDescriptor(descriptor: PaymentGatewayDescriptor): () => void {\n const descriptorRegistry = getDescriptorRegistry()\n descriptorRegistry.set(descriptor.providerKey, descriptor)\n return () => {\n descriptorRegistry.delete(descriptor.providerKey)\n }\n}\n\nexport function getPaymentGatewayDescriptor(providerKey: string): PaymentGatewayDescriptor | undefined {\n return getDescriptorRegistry().get(providerKey)\n}\n\nexport function listPaymentGatewayDescriptors(): PaymentGatewayDescriptor[] {\n return Array.from(getDescriptorRegistry().values()).sort((left, right) => left.label.localeCompare(right.label))\n}\n\nexport function clearPaymentGatewayDescriptors(): void {\n getDescriptorRegistry().clear()\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["// \u2500\u2500 Unified Payment Status \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport type UnifiedPaymentStatus =\n | 'pending'\n | 'authorized'\n | 'captured'\n | 'partially_captured'\n | 'refunded'\n | 'partially_refunded'\n | 'cancelled'\n | 'failed'\n | 'expired'\n | 'unknown'\n\n// \u2500\u2500 GatewayAdapter Interface \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface GatewayAdapter {\n readonly providerKey: string\n\n /** Create a payment session / payment intent */\n createSession(input: CreateSessionInput): Promise<CreateSessionResult>\n\n /** Capture an authorized payment */\n capture(input: CaptureInput): Promise<CaptureResult>\n\n /** Refund a captured payment (full or partial) */\n refund(input: RefundInput): Promise<RefundResult>\n\n /** Cancel / void an authorized payment before capture */\n cancel(input: CancelInput): Promise<CancelResult>\n\n /** Get current payment status from provider */\n getStatus(input: GetStatusInput): Promise<GatewayPaymentStatus>\n\n /** Verify and parse an inbound webhook event */\n verifyWebhook(input: VerifyWebhookInput): Promise<WebhookEvent>\n\n /** Map provider status to unified status */\n mapStatus(providerStatus: string, eventType?: string): UnifiedPaymentStatus\n}\n\nexport type PaymentGatewayDescriptorFieldType =\n | 'text'\n | 'number'\n | 'select'\n | 'multiselect'\n | 'boolean'\n | 'textarea'\n | 'secret'\n | 'url'\n\nexport interface PaymentGatewayDescriptorField {\n key: string\n label: string\n type: PaymentGatewayDescriptorFieldType\n description?: string\n required?: boolean\n options?: Array<{ value: string; label: string }>\n}\n\nexport type PaymentGatewayRendererType = 'embedded' | 'redirect'\n\nexport interface PaymentGatewayRendererDescriptor {\n key: string\n label: string\n type: PaymentGatewayRendererType\n description?: string\n supportedPaymentTypes?: '*' | string[]\n settingsFields?: PaymentGatewayDescriptorField[]\n}\n\nexport interface PaymentGatewayPresentationRequest {\n mode?: 'auto' | PaymentGatewayRendererType\n rendererKey?: string\n rendererSettings?: Record<string, unknown>\n}\n\nexport interface PaymentGatewayDescriptor {\n providerKey: string\n label: string\n sessionConfig?: {\n fields?: PaymentGatewayDescriptorField[]\n supportedCurrencies?: '*' | string[]\n supportedPaymentTypes?: Array<{ value: string; label: string }>\n defaultRendererKey?: string\n renderers?: PaymentGatewayRendererDescriptor[]\n /**\n * @deprecated Use `renderers` and `defaultRendererKey`.\n */\n presentation?: 'embedded' | 'redirect' | 'either'\n /**\n * @deprecated Use `renderers`.\n */\n embeddedRenderers?: string[]\n }\n}\n\nexport interface EmbeddedPaymentGatewayClientSession {\n type: 'embedded'\n rendererKey: string\n payload?: Record<string, unknown>\n settings?: Record<string, unknown>\n}\n\nexport interface RedirectPaymentGatewayClientSession {\n type: 'redirect'\n redirectUrl: string\n target?: 'self' | 'top'\n}\n\nexport type PaymentGatewayClientSession =\n | EmbeddedPaymentGatewayClientSession\n | RedirectPaymentGatewayClientSession\n\n// \u2500\u2500 Input / Output Types \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface CreateSessionInput {\n orderId?: string\n paymentId: string\n /** Stable operation key for provider-native session creation idempotency. */\n idempotencyKey?: string\n tenantId: string\n organizationId: string\n amount: number\n currencyCode: string\n captureMethod?: 'automatic' | 'manual'\n paymentTypes?: string[]\n description?: string\n successUrl?: string\n cancelUrl?: string\n metadata?: Record<string, unknown>\n credentials: Record<string, unknown>\n lineItems?: SessionLineItem[]\n presentation?: PaymentGatewayPresentationRequest\n}\n\nexport interface SessionLineItem {\n name: string\n quantity: number\n unitAmount: number\n currencyCode: string\n}\n\nexport interface CreateSessionResult {\n sessionId: string\n clientSecret?: string\n redirectUrl?: string\n status: UnifiedPaymentStatus\n providerData?: Record<string, unknown>\n clientSession?: PaymentGatewayClientSession\n}\n\nexport interface CaptureInput {\n sessionId: string\n amount?: number\n credentials: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface CaptureResult {\n status: UnifiedPaymentStatus\n capturedAmount: number\n providerData?: Record<string, unknown>\n}\n\nexport interface RefundInput {\n sessionId: string\n amount?: number\n reason?: string\n credentials: Record<string, unknown>\n metadata?: Record<string, unknown>\n}\n\nexport interface RefundResult {\n refundId: string\n status: UnifiedPaymentStatus\n refundedAmount: number\n providerData?: Record<string, unknown>\n}\n\nexport interface CancelInput {\n sessionId: string\n reason?: string\n credentials: Record<string, unknown>\n}\n\nexport interface CancelResult {\n status: UnifiedPaymentStatus\n providerData?: Record<string, unknown>\n}\n\nexport interface GetStatusInput {\n sessionId: string\n credentials: Record<string, unknown>\n}\n\nexport interface GatewayPaymentStatus {\n status: UnifiedPaymentStatus\n amount: number\n amountReceived: number\n currencyCode: string\n providerData?: Record<string, unknown>\n}\n\nexport interface VerifyWebhookInput {\n rawBody: string | Buffer\n headers: Record<string, string | string[] | undefined>\n credentials: Record<string, unknown>\n}\n\nexport interface WebhookEvent {\n eventType: string\n eventId: string\n data: Record<string, unknown>\n idempotencyKey: string\n timestamp: Date\n}\n\n// \u2500\u2500 Webhook Handler \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface WebhookHandlerRegistration {\n handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>\n queue?: string\n readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null\n}\n\n// \u2500\u2500 Adapter Registry Options \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nexport interface RegisterAdapterOptions {\n version?: string\n}\n\n// \u2500\u2500 Registries \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst ADAPTER_REGISTRY_KEY = '__openMercatoPaymentGatewayAdapters__'\nconst WEBHOOK_REGISTRY_KEY = '__openMercatoPaymentGatewayWebhookHandlers__'\nconst DESCRIPTOR_REGISTRY_KEY = '__openMercatoPaymentGatewayDescriptors__'\n\nfunction getAdapterRegistry(): Map<string, GatewayAdapter> {\n const globalState = globalThis as typeof globalThis & {\n [ADAPTER_REGISTRY_KEY]?: Map<string, GatewayAdapter>\n }\n if (!globalState[ADAPTER_REGISTRY_KEY]) {\n globalState[ADAPTER_REGISTRY_KEY] = new Map<string, GatewayAdapter>()\n }\n return globalState[ADAPTER_REGISTRY_KEY]\n}\n\nfunction getWebhookHandlerRegistry(): Map<string, WebhookHandlerRegistration> {\n const globalState = globalThis as typeof globalThis & {\n [WEBHOOK_REGISTRY_KEY]?: Map<string, WebhookHandlerRegistration>\n }\n if (!globalState[WEBHOOK_REGISTRY_KEY]) {\n globalState[WEBHOOK_REGISTRY_KEY] = new Map<string, WebhookHandlerRegistration>()\n }\n return globalState[WEBHOOK_REGISTRY_KEY]\n}\n\nfunction getDescriptorRegistry(): Map<string, PaymentGatewayDescriptor> {\n const globalState = globalThis as typeof globalThis & {\n [DESCRIPTOR_REGISTRY_KEY]?: Map<string, PaymentGatewayDescriptor>\n }\n if (!globalState[DESCRIPTOR_REGISTRY_KEY]) {\n globalState[DESCRIPTOR_REGISTRY_KEY] = new Map<string, PaymentGatewayDescriptor>()\n }\n return globalState[DESCRIPTOR_REGISTRY_KEY]\n}\n\nfunction adapterKey(providerKey: string, version?: string): string {\n return version ? `${providerKey}:${version}` : providerKey\n}\n\nexport function registerGatewayAdapter(adapter: GatewayAdapter, options?: RegisterAdapterOptions): () => void {\n const adapterRegistry = getAdapterRegistry()\n const key = adapterKey(adapter.providerKey, options?.version)\n adapterRegistry.set(key, adapter)\n if (options?.version) {\n // Also register as default if no default exists\n if (!adapterRegistry.has(adapter.providerKey)) {\n adapterRegistry.set(adapter.providerKey, adapter)\n }\n }\n return () => {\n adapterRegistry.delete(key)\n }\n}\n\nexport function getGatewayAdapter(providerKey: string, version?: string): GatewayAdapter | undefined {\n const adapterRegistry = getAdapterRegistry()\n if (version) {\n return adapterRegistry.get(adapterKey(providerKey, version)) ?? adapterRegistry.get(providerKey)\n }\n return adapterRegistry.get(providerKey)\n}\n\nexport function listGatewayAdapters(): GatewayAdapter[] {\n const adapterRegistry = getAdapterRegistry()\n const seen = new Set<string>()\n const result: GatewayAdapter[] = []\n for (const [key, adapter] of adapterRegistry) {\n if (!key.includes(':') && !seen.has(adapter.providerKey)) {\n seen.add(adapter.providerKey)\n result.push(adapter)\n }\n }\n return result\n}\n\nexport function clearGatewayAdapters(): void {\n getAdapterRegistry().clear()\n}\n\nexport function registerWebhookHandler(\n providerKey: string,\n handler: (input: VerifyWebhookInput) => Promise<WebhookEvent>,\n options?: {\n queue?: string\n readSessionIdHint?: (payload: Record<string, unknown> | null) => string | null\n },\n): () => void {\n const webhookHandlerRegistry = getWebhookHandlerRegistry()\n webhookHandlerRegistry.set(providerKey, {\n handler,\n queue: options?.queue,\n readSessionIdHint: options?.readSessionIdHint,\n })\n return () => {\n webhookHandlerRegistry.delete(providerKey)\n }\n}\n\nexport function getWebhookHandler(providerKey: string): WebhookHandlerRegistration | undefined {\n return getWebhookHandlerRegistry().get(providerKey)\n}\n\nexport function clearWebhookHandlers(): void {\n getWebhookHandlerRegistry().clear()\n}\n\nexport function registerPaymentGatewayDescriptor(descriptor: PaymentGatewayDescriptor): () => void {\n const descriptorRegistry = getDescriptorRegistry()\n descriptorRegistry.set(descriptor.providerKey, descriptor)\n return () => {\n descriptorRegistry.delete(descriptor.providerKey)\n }\n}\n\nexport function getPaymentGatewayDescriptor(providerKey: string): PaymentGatewayDescriptor | undefined {\n return getDescriptorRegistry().get(providerKey)\n}\n\nexport function listPaymentGatewayDescriptors(): PaymentGatewayDescriptor[] {\n return Array.from(getDescriptorRegistry().values()).sort((left, right) => left.label.localeCompare(right.label))\n}\n\nexport function clearPaymentGatewayDescriptors(): void {\n getDescriptorRegistry().clear()\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/shared",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.6551.1.1511bfa890",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"@mikro-orm/core": "^7.1.5",
|
|
98
98
|
"@mikro-orm/decorators": "^7.1.5",
|
|
99
99
|
"@mikro-orm/postgresql": "^7.1.5",
|
|
100
|
-
"@open-mercato/cache": "0.6.6-develop.
|
|
100
|
+
"@open-mercato/cache": "0.6.6-develop.6551.1.1511bfa890",
|
|
101
101
|
"@types/sanitize-html": "^2.16.1",
|
|
102
102
|
"dotenv": "^17.4.2",
|
|
103
103
|
"pino": "^10.3.1",
|
|
@@ -228,6 +228,16 @@ describe('url-safety — createPinnedDnsLookup', () => {
|
|
|
228
228
|
expect(cb).toHaveBeenCalledWith(null, '93.184.216.34', 4)
|
|
229
229
|
})
|
|
230
230
|
|
|
231
|
+
it('returns an address array when Node requests all lookup results', () => {
|
|
232
|
+
const pinned = { address: '93.184.216.34', family: 4 }
|
|
233
|
+
const lookup = createPinnedDnsLookup('good.example', pinned)
|
|
234
|
+
const cb = jest.fn()
|
|
235
|
+
|
|
236
|
+
lookup('good.example', { all: true }, cb)
|
|
237
|
+
|
|
238
|
+
expect(cb).toHaveBeenCalledWith(null, [pinned])
|
|
239
|
+
})
|
|
240
|
+
|
|
231
241
|
it('refuses to resolve a different hostname (defeats redirect to private host via Host header)', () => {
|
|
232
242
|
const lookup = createPinnedDnsLookup('good.example', { address: '93.184.216.34', family: 4 })
|
|
233
243
|
const cb = jest.fn()
|
|
@@ -305,4 +315,22 @@ describe('url-safety — safeOutboundFetch', () => {
|
|
|
305
315
|
)
|
|
306
316
|
expect(lookupHost).toHaveBeenCalledTimes(1)
|
|
307
317
|
})
|
|
318
|
+
|
|
319
|
+
it('uses the dispatcher-compatible fetch implementation for pinned requests', async () => {
|
|
320
|
+
const pinnedFetchImpl = jest.fn(async () => new Response('ok', { status: 200 }))
|
|
321
|
+
const lookupHost = jest.fn(async () => [{ address: '93.184.216.34', family: 4 }])
|
|
322
|
+
|
|
323
|
+
const response = await safeOutboundFetch(
|
|
324
|
+
'https://good.example/hook',
|
|
325
|
+
{},
|
|
326
|
+
{ lookupHost, pinnedFetchImpl },
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
expect(response.status).toBe(200)
|
|
330
|
+
expect(pinnedFetchImpl).toHaveBeenCalledTimes(1)
|
|
331
|
+
expect(pinnedFetchImpl).toHaveBeenCalledWith(
|
|
332
|
+
'https://good.example/hook',
|
|
333
|
+
expect.objectContaining({ dispatcher: expect.any(Object), redirect: 'manual' }),
|
|
334
|
+
)
|
|
335
|
+
})
|
|
308
336
|
})
|
package/src/lib/network.ts
CHANGED
|
@@ -80,12 +80,39 @@ export function isPrivateIPv6(address: string): boolean {
|
|
|
80
80
|
if (isIPv4CompatibleIPv6(segments)) return true
|
|
81
81
|
|
|
82
82
|
const embedded = embeddedIPv4FromIPv6(segments)
|
|
83
|
-
if (embedded
|
|
83
|
+
if (embedded) return isPrivateIPv4(embedded)
|
|
84
84
|
|
|
85
85
|
const [a] = segments
|
|
86
86
|
if ((a & 0xfe00) === 0xfc00) return true
|
|
87
87
|
if ((a & 0xffc0) === 0xfe80) return true
|
|
88
88
|
if ((a & 0xff00) === 0xff00) return true
|
|
89
|
+
if (
|
|
90
|
+
a === 0x0100 &&
|
|
91
|
+
segments[1] === 0 &&
|
|
92
|
+
segments[2] === 0 &&
|
|
93
|
+
segments[3] === 0
|
|
94
|
+
) {
|
|
95
|
+
return true
|
|
96
|
+
}
|
|
97
|
+
if (a === 0x2001 && (segments[1] & 0xfe00) === 0) {
|
|
98
|
+
return !isGloballyReachableIetfProtocolAssignment(segments)
|
|
99
|
+
}
|
|
100
|
+
if (a === 0x2001 && segments[1] === 0x0db8) return true
|
|
101
|
+
if ((a & 0xfff0) === 0x3ff0) return true
|
|
102
|
+
// Except for the explicitly handled IPv4 translation ranges above, globally
|
|
103
|
+
// routable unicast IPv6 addresses are allocated from 2000::/3.
|
|
104
|
+
if ((a & 0xe000) !== 0x2000) return true
|
|
105
|
+
return false
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isGloballyReachableIetfProtocolAssignment(segments: number[]): boolean {
|
|
109
|
+
const [, b, c, d, e, f, g, h] = segments
|
|
110
|
+
const trailingZero = c === 0 && d === 0 && e === 0 && f === 0 && g === 0
|
|
111
|
+
if (b === 0x0001 && trailingZero && h >= 1 && h <= 3) return true
|
|
112
|
+
if (b === 0x0003) return true
|
|
113
|
+
if (b === 0x0004 && c === 0x0112) return true
|
|
114
|
+
if ((b & 0xfff0) === 0x0020) return true
|
|
115
|
+
if ((b & 0xfff0) === 0x0030) return true
|
|
89
116
|
return false
|
|
90
117
|
}
|
|
91
118
|
|
package/src/lib/url-safety.ts
CHANGED
|
@@ -1,6 +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
|
+
import { Agent, fetch as undiciFetch, type Dispatcher } from 'undici'
|
|
4
4
|
import { isBlockedHostname, isPrivateIpAddress } from './network'
|
|
5
5
|
|
|
6
6
|
export type UrlSafetyReason =
|
|
@@ -102,6 +102,8 @@ export function assertStaticallySafeOutboundUrl(
|
|
|
102
102
|
|
|
103
103
|
export type AssertSafeOutboundUrlOptions = AssertStaticUrlOptions & {
|
|
104
104
|
lookupHost?: HostLookup
|
|
105
|
+
/** Resolve and return DNS answers even when private addresses are explicitly allowed. */
|
|
106
|
+
pinAllowedPrivate?: boolean
|
|
105
107
|
}
|
|
106
108
|
|
|
107
109
|
export async function assertSafeOutboundUrl(
|
|
@@ -118,7 +120,8 @@ export type ResolveSafeOutboundUrlResult = {
|
|
|
118
120
|
hostname: string
|
|
119
121
|
/**
|
|
120
122
|
* 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
|
|
123
|
+
* (no DNS lookup performed) or when `allowPrivate` short-circuited validation without
|
|
124
|
+
* `pinAllowedPrivate`.
|
|
122
125
|
*/
|
|
123
126
|
addresses: ReadonlyArray<ResolvedHostAddress> | null
|
|
124
127
|
}
|
|
@@ -136,14 +139,16 @@ export async function resolveSafeOutboundUrl(
|
|
|
136
139
|
const subject = options.subject ?? 'URL'
|
|
137
140
|
const factory = options.errorFactory ?? defaultErrorFactory
|
|
138
141
|
const { url, hostname } = parseOutboundUrl(rawUrl, options)
|
|
139
|
-
if (options.allowPrivate)
|
|
142
|
+
if (options.allowPrivate && !options.pinAllowedPrivate) {
|
|
143
|
+
return { url, hostname, addresses: null }
|
|
144
|
+
}
|
|
140
145
|
|
|
141
|
-
if (isBlockedHostname(hostname)) {
|
|
146
|
+
if (!options.allowPrivate && isBlockedHostname(hostname)) {
|
|
142
147
|
throw factory('blocked_hostname', `${subject} host "${hostname}" is not allowed`)
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
if (isIP(hostname)) {
|
|
146
|
-
if (isPrivateIpAddress(hostname)) {
|
|
151
|
+
if (!options.allowPrivate && isPrivateIpAddress(hostname)) {
|
|
147
152
|
throw factory(
|
|
148
153
|
'private_ip_literal',
|
|
149
154
|
`${subject} host "${hostname}" resolves to a private or reserved IP range`,
|
|
@@ -179,7 +184,7 @@ export async function resolveSafeOutboundUrl(
|
|
|
179
184
|
}
|
|
180
185
|
|
|
181
186
|
for (const record of addresses) {
|
|
182
|
-
if (isPrivateIpAddress(record.address)) {
|
|
187
|
+
if (!options.allowPrivate && isPrivateIpAddress(record.address)) {
|
|
183
188
|
throw factory(
|
|
184
189
|
'private_ip_resolved',
|
|
185
190
|
`${subject} host "${hostname}" resolves to a private or reserved IP address (${record.address})`,
|
|
@@ -197,16 +202,23 @@ export type SafeOutboundFetchOptions = AssertSafeOutboundUrlOptions & {
|
|
|
197
202
|
* not actually open sockets, so DNS pinning is unnecessary and would just complicate mocking.
|
|
198
203
|
*/
|
|
199
204
|
fetchImpl?: typeof fetch
|
|
205
|
+
/** Test seam for the dispatcher-compatible fetch used by DNS-pinned requests. */
|
|
206
|
+
pinnedFetchImpl?: PinnedFetch
|
|
200
207
|
}
|
|
201
208
|
|
|
209
|
+
export type PinnedFetch = (
|
|
210
|
+
rawUrl: string,
|
|
211
|
+
init: RequestInit & { dispatcher: Dispatcher },
|
|
212
|
+
) => Promise<Response>
|
|
213
|
+
|
|
202
214
|
/**
|
|
203
215
|
* Validates an outbound URL and performs `fetch()` with the connection pinned to a
|
|
204
216
|
* pre-validated IP address, so DNS cannot be re-resolved between validation and connect
|
|
205
217
|
* (DNS rebinding). Always defaults to `redirect: 'manual'` — callers MUST decide what to
|
|
206
218
|
* do with 3xx responses (re-validate the redirect target before following).
|
|
207
219
|
*
|
|
208
|
-
* For IP literal hosts
|
|
209
|
-
*
|
|
220
|
+
* For IP literal hosts no DNS pinning is performed because there is no DNS lookup to defeat.
|
|
221
|
+
* Callers that allow private hostnames can set `pinAllowedPrivate` to retain DNS pinning.
|
|
210
222
|
*/
|
|
211
223
|
export async function safeOutboundFetch(
|
|
212
224
|
rawUrl: string,
|
|
@@ -237,9 +249,8 @@ export async function safeOutboundFetch(
|
|
|
237
249
|
})
|
|
238
250
|
|
|
239
251
|
try {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
})
|
|
252
|
+
const pinnedFetch = options.pinnedFetchImpl ?? (undiciFetch as unknown as PinnedFetch)
|
|
253
|
+
return await pinnedFetch(rawUrl, { ...mergedInit, dispatcher })
|
|
243
254
|
} finally {
|
|
244
255
|
dispatcher.close().catch(() => {})
|
|
245
256
|
}
|
|
@@ -247,8 +258,12 @@ export async function safeOutboundFetch(
|
|
|
247
258
|
|
|
248
259
|
export type PinnedDnsLookup = (
|
|
249
260
|
host: string,
|
|
250
|
-
opts:
|
|
251
|
-
cb: (
|
|
261
|
+
opts: { all?: boolean },
|
|
262
|
+
cb: (
|
|
263
|
+
err: NodeJS.ErrnoException | null,
|
|
264
|
+
address: string | ResolvedHostAddress[],
|
|
265
|
+
family?: number,
|
|
266
|
+
) => void,
|
|
252
267
|
) => void
|
|
253
268
|
|
|
254
269
|
/**
|
|
@@ -261,7 +276,7 @@ export function createPinnedDnsLookup(
|
|
|
261
276
|
expectedHostname: string,
|
|
262
277
|
pinned: ResolvedHostAddress,
|
|
263
278
|
): PinnedDnsLookup {
|
|
264
|
-
return (host,
|
|
279
|
+
return (host, opts, cb) => {
|
|
265
280
|
if (host !== expectedHostname) {
|
|
266
281
|
const err: NodeJS.ErrnoException = new Error(
|
|
267
282
|
`Refusing DNS lookup for unexpected host "${host}" (expected "${expectedHostname}")`,
|
|
@@ -270,6 +285,10 @@ export function createPinnedDnsLookup(
|
|
|
270
285
|
cb(err, '', 0)
|
|
271
286
|
return
|
|
272
287
|
}
|
|
288
|
+
if (opts.all) {
|
|
289
|
+
cb(null, [pinned])
|
|
290
|
+
return
|
|
291
|
+
}
|
|
273
292
|
cb(null, pinned.address, pinned.family)
|
|
274
293
|
}
|
|
275
294
|
}
|
|
@@ -117,6 +117,8 @@ export type PaymentGatewayClientSession =
|
|
|
117
117
|
export interface CreateSessionInput {
|
|
118
118
|
orderId?: string
|
|
119
119
|
paymentId: string
|
|
120
|
+
/** Stable operation key for provider-native session creation idempotency. */
|
|
121
|
+
idempotencyKey?: string
|
|
120
122
|
tenantId: string
|
|
121
123
|
organizationId: string
|
|
122
124
|
amount: number
|