@webpieces/http-client-node 0.4.699 → 0.4.701
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/README.md +105 -0
- package/package.json +5 -5
- package/src/AddressResolver.d.ts +33 -0
- package/src/AddressResolver.js +39 -0
- package/src/AddressResolver.js.map +1 -0
- package/src/ClientConfig.d.ts +32 -8
- package/src/ClientConfig.js +22 -5
- package/src/ClientConfig.js.map +1 -1
- package/src/ClientHttpFactory.d.ts +31 -2
- package/src/ClientHttpFactory.js +31 -3
- package/src/ClientHttpFactory.js.map +1 -1
- package/src/ContextBaseUrlFilter.d.ts +80 -0
- package/src/ContextBaseUrlFilter.js +95 -0
- package/src/ContextBaseUrlFilter.js.map +1 -0
- package/src/CreateRpcClientCompileAssertions.d.ts +17 -0
- package/src/CreateRpcClientCompileAssertions.js +50 -0
- package/src/CreateRpcClientCompileAssertions.js.map +1 -0
- package/src/InternalAddressRules.d.ts +37 -0
- package/src/InternalAddressRules.js +132 -0
- package/src/InternalAddressRules.js.map +1 -0
- package/src/MissingRuntimeBaseUrlError.d.ts +20 -0
- package/src/MissingRuntimeBaseUrlError.js +28 -0
- package/src/MissingRuntimeBaseUrlError.js.map +1 -0
- package/src/NodeProxyClient.d.ts +33 -16
- package/src/NodeProxyClient.js +67 -32
- package/src/NodeProxyClient.js.map +1 -1
- package/src/OutboundAuthErrors.d.ts +42 -0
- package/src/OutboundAuthErrors.js +54 -0
- package/src/OutboundAuthErrors.js.map +1 -0
- package/src/OutboundAuthFilter.d.ts +56 -0
- package/src/OutboundAuthFilter.js +103 -0
- package/src/OutboundAuthFilter.js.map +1 -0
- package/src/SsrfGuardFilter.d.ts +61 -0
- package/src/SsrfGuardFilter.js +165 -0
- package/src/SsrfGuardFilter.js.map +1 -0
- package/src/SsrfPolicy.d.ts +58 -0
- package/src/SsrfPolicy.js +67 -0
- package/src/SsrfPolicy.js.map +1 -0
- package/src/SsrfRefusedError.d.ts +20 -0
- package/src/SsrfRefusedError.js +28 -0
- package/src/SsrfRefusedError.js.map +1 -0
- package/src/WebhookSignerCallback.d.ts +114 -0
- package/src/WebhookSignerCallback.js +106 -0
- package/src/WebhookSignerCallback.js.map +1 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +43 -1
- package/src/index.js.map +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ContextBaseUrlFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ContextBaseUrlFilter.ts"],"names":[],"mappings":";;;AAAA,oDAA6E;AAC7E,0DAAyD;AAEzD,6EAA0E;AAC1E,6CAA0C;AAE1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwDG;AACH,MAAa,oBAAqB,SAAQ,kBAA+B;IASxD;IARb;IACI;;;;;;OAMG;IACM,aAAyB,IAAI,uBAAU,EAAE;QAElD,KAAK,EAAE,CAAC;QAFC,eAAU,GAAV,UAAU,CAA+B;IAGtD,CAAC;IAEQ,KAAK,CAAC,MAAM,CAAC,OAAsB,EAAE,UAA4C;QACtF,MAAM,QAAQ,GAAG,6BAAc,CAAC,YAAY,CAAC,gCAAoB,CAAC,iBAAiB,CAAC,CAAC;QACrF,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,KAAK,EAAE,EAAE,CAAC;YAC5C,MAAM,IAAI,uDAA0B,CAChC,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,0CAA0C;gBACzF,oDAAoD;gBACpD,wFAAwF;gBACxF,aAAa;gBACb,yFAAyF;gBACzF,+EAA+E;gBAC/E,0FAA0F,EAC9F,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,EAAE,CACxD,CAAC;QACN,CAAC;QACD,OAAO,CAAC,cAAc,CAAC,QAAQ,CAAC,CAAC;QACjC,OAAO,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACtC,CAAC;CACJ;AA/BD,oDA+BC","sourcesContent":["import { Filter, Service, WebpiecesCoreHeaders } from '@webpieces/core-util';\nimport { RequestContext } from '@webpieces/core-context';\nimport { ClientRequest } from '@webpieces/http-client-core';\nimport { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';\nimport { SsrfPolicy } from './SsrfPolicy';\n\n/**\n * Reads {@link WebpiecesCoreHeaders.OVERRIDE_BASE_URL} out of the ambient RequestContext and points\n * THIS ONE CALL at it. Ships in the box, and is the whole of the runtime-base-URL feature:\n *\n * ```ts\n * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [\n * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n * ]);\n *\n * for (const webhook of webhooks) {\n * await RequestContext.run(() => {\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);\n * return partner.deliver(envelope);\n * });\n * }\n * ```\n *\n * ## INSTALLING IT IS THE OPT-IN\n *\n * There is no client-level setting saying \"this one may be re-pointed\", because installing this\n * filter IS that statement, written at the one place a reader looks. A client with no\n * `ContextBaseUrlFilter` ignores an ambient `OVERRIDE_BASE_URL` entirely — which is what stops a\n * value set for a partner delivery from silently re-pointing every other client in the same\n * request at the partner's server. `grep -rn ContextBaseUrlFilter` enumerates every client in a\n * codebase that can be re-pointed at all, which is the question a security review actually asks.\n *\n * ## PER-ENDPOINT, if a contract mixes them\n *\n * `request.route` carries `methodName`, `path`, `httpMethod` and `authMeta`, so a subclass can\n * re-point some endpoints of a contract and leave the rest on the configured host, with no API\n * change:\n *\n * ```ts\n * class DeliverOnlyBaseUrlFilter extends ContextBaseUrlFilter {\n * override async filter(request: ClientRequest, next: Service<ClientRequest, Response>) {\n * if (request.route.methodName !== 'deliver') return next.invoke(request);\n * return super.filter(request, next);\n * }\n * }\n * ```\n *\n * ## Scope, and why it cannot leak\n *\n * It mutates the per-call {@link ClientRequest} and nothing else. The client is untouched, so the\n * next call through the same client starts from its configured host again; and the context entry is\n * scoped to whatever `RequestContext.run(...)` the caller established, so a fan-out loop that sets\n * a different URL per partner gets exactly the URL it set, per iteration.\n *\n * ## The SSRF guard is NOT registered here\n *\n * Re-pointing arms it by itself — `ClientRequest.pointAtBaseUrl` flips\n * `destinationCameFromData`, and the framework's own guard sits beneath every app filter and reads\n * that. So this filter cannot forget to bring the guard along, and an app cannot install this one\n * without it. The only thing this class carries is WHICH policy the guard applies, and only\n * because the single legitimate relaxation ({@link SsrfTestingPolicy}) belongs at the same\n * construction site as the decision to be re-pointable at all.\n */\nexport class ContextBaseUrlFilter extends Filter<ClientRequest, Response> {\n constructor(\n /**\n * What the framework's SSRF guard holds this client's re-pointed URLs to.\n *\n * Defaulted to {@link SsrfPolicy} (the strict one), and that default is the SAFE branch, so the\n * omitted argument can never be the permissive one — the widening has to be typed out, with\n * a reason, as `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>'))`.\n */\n readonly ssrfPolicy: SsrfPolicy = new SsrfPolicy(),\n ) {\n super();\n }\n\n override async filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response> {\n const override = RequestContext.getUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL);\n if (override === undefined || override === '') {\n throw new MissingRuntimeBaseUrlError(\n `${request.contractName}.${request.route.methodName} runs behind a ContextBaseUrlFilter, so ` +\n `its destination must be supplied per call, but no ` +\n `WebpiecesCoreHeaders.OVERRIDE_BASE_URL was found in the RequestContext. Set it around ` +\n `the call:\\n` +\n ` RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);\\n` +\n `Refusing rather than falling back to this client's configured service URL is ` +\n `deliberate: a silent fallback would send a partner's payload to one of our own services.`,\n `${request.contractName}.${request.route.methodName}`,\n );\n }\n request.pointAtBaseUrl(override);\n return nextFilter.invoke(request);\n }\n}\n"]}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COMPILE-TIME assertions pinning `createRpcClient`'s filters argument to ONE spelling per decision.
|
|
3
|
+
*
|
|
4
|
+
* A CLASS is one shape and cannot express "absent, or at least one" — so the argument is a NON-EMPTY
|
|
5
|
+
* tuple, and the empty array that used to mean the same thing as omitting it is a COMPILE error. See
|
|
6
|
+
* `.claude/review/backwards-compatibility.md` shim shape #1: the fix for two spellings is to make the
|
|
7
|
+
* type unsatisfiable in the bad case, not to document a preference.
|
|
8
|
+
*
|
|
9
|
+
* WHY THIS FILE AND NOT A SPEC. `tsconfig.lib.json` EXCLUDES `*.spec.ts`, and vitest strips types
|
|
10
|
+
* with esbuild rather than checking them — so a `@ts-expect-error` in a spec is inert and the suite
|
|
11
|
+
* passes whether or not the bad case still compiles. Here it is compiled by the build: if any line
|
|
12
|
+
* below ever starts compiling, tsc fails with TS2578 ("Unused '@ts-expect-error' directive"). That
|
|
13
|
+
* failure IS the test. Mirrors `core-util/src/http/AuthJwtCompileAssertions.ts`.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here runs. The class is never constructed and never exported from the barrel.
|
|
16
|
+
*/
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* COMPILE-TIME assertions pinning `createRpcClient`'s filters argument to ONE spelling per decision.
|
|
4
|
+
*
|
|
5
|
+
* A CLASS is one shape and cannot express "absent, or at least one" — so the argument is a NON-EMPTY
|
|
6
|
+
* tuple, and the empty array that used to mean the same thing as omitting it is a COMPILE error. See
|
|
7
|
+
* `.claude/review/backwards-compatibility.md` shim shape #1: the fix for two spellings is to make the
|
|
8
|
+
* type unsatisfiable in the bad case, not to document a preference.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS FILE AND NOT A SPEC. `tsconfig.lib.json` EXCLUDES `*.spec.ts`, and vitest strips types
|
|
11
|
+
* with esbuild rather than checking them — so a `@ts-expect-error` in a spec is inert and the suite
|
|
12
|
+
* passes whether or not the bad case still compiles. Here it is compiled by the build: if any line
|
|
13
|
+
* below ever starts compiling, tsc fails with TS2578 ("Unused '@ts-expect-error' directive"). That
|
|
14
|
+
* failure IS the test. Mirrors `core-util/src/http/AuthJwtCompileAssertions.ts`.
|
|
15
|
+
*
|
|
16
|
+
* Nothing here runs. The class is never constructed and never exported from the barrel.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
const http_client_core_1 = require("@webpieces/http-client-core");
|
|
20
|
+
const ClientConfig_1 = require("./ClientConfig");
|
|
21
|
+
const ContextBaseUrlFilter_1 = require("./ContextBaseUrlFilter");
|
|
22
|
+
class CreateRpcClientCompileAssertions {
|
|
23
|
+
/** ✅ No app filters — the ONE spelling for it is omitting the argument. */
|
|
24
|
+
noFilters() {
|
|
25
|
+
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('server2'));
|
|
26
|
+
}
|
|
27
|
+
/** ✅ One app filter. */
|
|
28
|
+
oneFilter() {
|
|
29
|
+
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('partner-webhooks'), [
|
|
30
|
+
new http_client_core_1.ClientFilterDefinition(1000, new ContextBaseUrlFilter_1.ContextBaseUrlFilter()),
|
|
31
|
+
]);
|
|
32
|
+
}
|
|
33
|
+
/** ✅ Several app filters. */
|
|
34
|
+
severalFilters() {
|
|
35
|
+
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('partner-webhooks'), [
|
|
36
|
+
new http_client_core_1.ClientFilterDefinition(1000, new ContextBaseUrlFilter_1.ContextBaseUrlFilter()),
|
|
37
|
+
new http_client_core_1.ClientFilterDefinition(500, new ContextBaseUrlFilter_1.ContextBaseUrlFilter()),
|
|
38
|
+
]);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* ❌ `[]` is a SECOND way to say what omitting the argument already says. Deleting it by type is
|
|
42
|
+
* what keeps "this client has no app filters" to one spelling.
|
|
43
|
+
*/
|
|
44
|
+
emptyArrayDoesNotCompile() {
|
|
45
|
+
// @ts-expect-error - pass no third argument instead; [] is a second spelling of that
|
|
46
|
+
factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('server2'), []);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
void CreateRpcClientCompileAssertions;
|
|
50
|
+
//# sourceMappingURL=CreateRpcClientCompileAssertions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"CreateRpcClientCompileAssertions.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/CreateRpcClientCompileAssertions.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;GAeG;;AAEH,kEAAqE;AACrE,iDAA8C;AAE9C,iEAA8D;AAM9D,MAAM,gCAAgC;IAClC,2EAA2E;IAC3E,SAAS;QACL,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,SAAS,CAAC,CAAC,CAAC;IAClE,CAAC;IAED,wBAAwB;IACxB,SAAS;QACL,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,kBAAkB,CAAC,EAAE;YACnE,IAAI,yCAAsB,CAAC,IAAI,EAAE,IAAI,2CAAoB,EAAE,CAAC;SAC/D,CAAC,CAAC;IACP,CAAC;IAED,6BAA6B;IAC7B,cAAc;QACV,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,kBAAkB,CAAC,EAAE;YACnE,IAAI,yCAAsB,CAAC,IAAI,EAAE,IAAI,2CAAoB,EAAE,CAAC;YAC5D,IAAI,yCAAsB,CAAC,GAAG,EAAE,IAAI,2CAAoB,EAAE,CAAC;SAC9D,CAAC,CAAC;IACP,CAAC;IAED;;;OAGG;IACH,wBAAwB;QACpB,qFAAqF;QACrF,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC,CAAC;IACtE,CAAC;CACJ;AAED,KAAK,gCAAgC,CAAC","sourcesContent":["/**\n * COMPILE-TIME assertions pinning `createRpcClient`'s filters argument to ONE spelling per decision.\n *\n * A CLASS is one shape and cannot express \"absent, or at least one\" — so the argument is a NON-EMPTY\n * tuple, and the empty array that used to mean the same thing as omitting it is a COMPILE error. See\n * `.claude/review/backwards-compatibility.md` shim shape #1: the fix for two spellings is to make the\n * type unsatisfiable in the bad case, not to document a preference.\n *\n * WHY THIS FILE AND NOT A SPEC. `tsconfig.lib.json` EXCLUDES `*.spec.ts`, and vitest strips types\n * with esbuild rather than checking them — so a `@ts-expect-error` in a spec is inert and the suite\n * passes whether or not the bad case still compiles. Here it is compiled by the build: if any line\n * below ever starts compiling, tsc fails with TS2578 (\"Unused '@ts-expect-error' directive\"). That\n * failure IS the test. Mirrors `core-util/src/http/AuthJwtCompileAssertions.ts`.\n *\n * Nothing here runs. The class is never constructed and never exported from the barrel.\n */\n\nimport { ClientFilterDefinition } from '@webpieces/http-client-core';\nimport { ClientConfig } from './ClientConfig';\nimport { ClientHttpFactory } from './ClientHttpFactory';\nimport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\n\n/** Stand-in for a real contract; only its TYPE is used, and only by tsc. */\ndeclare const someApi: Parameters<ClientHttpFactory['createRpcClient']>[0];\ndeclare const factory: ClientHttpFactory;\n\nclass CreateRpcClientCompileAssertions {\n /** ✅ No app filters — the ONE spelling for it is omitting the argument. */\n noFilters(): void {\n factory.createRpcClient(someApi, new ClientConfig('server2'));\n }\n\n /** ✅ One app filter. */\n oneFilter(): void {\n factory.createRpcClient(someApi, new ClientConfig('partner-webhooks'), [\n new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n ]);\n }\n\n /** ✅ Several app filters. */\n severalFilters(): void {\n factory.createRpcClient(someApi, new ClientConfig('partner-webhooks'), [\n new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n new ClientFilterDefinition(500, new ContextBaseUrlFilter()),\n ]);\n }\n\n /**\n * ❌ `[]` is a SECOND way to say what omitting the argument already says. Deleting it by type is\n * what keeps \"this client has no app filters\" to one spelling.\n */\n emptyArrayDoesNotCompile(): void {\n // @ts-expect-error - pass no third argument instead; [] is a second spelling of that\n factory.createRpcClient(someApi, new ClientConfig('server2'), []);\n }\n}\n\nvoid CreateRpcClientCompileAssertions;\n"]}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which IP addresses count as INTERNAL — i.e. reachable only because of where our process happens
|
|
3
|
+
* to sit, and therefore never a legitimate destination for a URL a partner supplied.
|
|
4
|
+
*
|
|
5
|
+
* Pure and dependency-free (it judges an address string, it does not resolve one), so it is unit
|
|
6
|
+
* testable without DNS and is shared by the URL check and the redirect check.
|
|
7
|
+
*
|
|
8
|
+
* The list is deliberately WIDER than "RFC1918". A consumer running on a VPC connector can reach a
|
|
9
|
+
* great deal more than 10/8, and the address that actually gets stolen in practice is
|
|
10
|
+
* `169.254.169.254` — the cloud metadata service, which hands out the runtime service account's
|
|
11
|
+
* tokens to anything that asks.
|
|
12
|
+
*/
|
|
13
|
+
export declare class InternalAddressRules {
|
|
14
|
+
/** IPv4 CIDRs that are internal, as [network, prefix length]. */
|
|
15
|
+
private static readonly V4_BLOCKS;
|
|
16
|
+
/**
|
|
17
|
+
* Hostnames that are internal by NAME, independent of what they resolve to. Checked before DNS
|
|
18
|
+
* because the metadata service answers to its name inside every GCP VM and the name is what
|
|
19
|
+
* appears in a copy-pasted URL.
|
|
20
|
+
*/
|
|
21
|
+
private static readonly INTERNAL_HOSTNAMES;
|
|
22
|
+
/** True when `hostname` is internal by name alone (no DNS needed). */
|
|
23
|
+
isInternalHostname(hostname: string): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* True when `address` is an internal IP. Accepts IPv4 dotted-quad and IPv6 (including the
|
|
26
|
+
* IPv4-mapped `::ffff:a.b.c.d` form, which is how a dual-stack resolver reports an IPv4 answer
|
|
27
|
+
* and therefore the obvious way to smuggle 127.0.0.1 past an IPv4-only check).
|
|
28
|
+
*
|
|
29
|
+
* An address it cannot parse is treated as INTERNAL. Unparseable means "we do not know what this
|
|
30
|
+
* is", and the safe answer to that on the SSRF path is refusal, not delivery.
|
|
31
|
+
*/
|
|
32
|
+
isInternalAddress(address: string): boolean;
|
|
33
|
+
private isInternalV4;
|
|
34
|
+
private isInternalV6;
|
|
35
|
+
/** The dotted quad as a 32-bit number, or undefined when it is not a dotted quad at all. */
|
|
36
|
+
private toV4Number;
|
|
37
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InternalAddressRules = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Which IP addresses count as INTERNAL — i.e. reachable only because of where our process happens
|
|
6
|
+
* to sit, and therefore never a legitimate destination for a URL a partner supplied.
|
|
7
|
+
*
|
|
8
|
+
* Pure and dependency-free (it judges an address string, it does not resolve one), so it is unit
|
|
9
|
+
* testable without DNS and is shared by the URL check and the redirect check.
|
|
10
|
+
*
|
|
11
|
+
* The list is deliberately WIDER than "RFC1918". A consumer running on a VPC connector can reach a
|
|
12
|
+
* great deal more than 10/8, and the address that actually gets stolen in practice is
|
|
13
|
+
* `169.254.169.254` — the cloud metadata service, which hands out the runtime service account's
|
|
14
|
+
* tokens to anything that asks.
|
|
15
|
+
*/
|
|
16
|
+
class InternalAddressRules {
|
|
17
|
+
/** IPv4 CIDRs that are internal, as [network, prefix length]. */
|
|
18
|
+
static V4_BLOCKS = [
|
|
19
|
+
['0.0.0.0', 8], // "this network" — 0.x is routed to localhost by some stacks
|
|
20
|
+
['10.0.0.0', 8], // RFC1918 private
|
|
21
|
+
['100.64.0.0', 10], // RFC6598 carrier-grade NAT — a shared-tenant range, never ours to trust
|
|
22
|
+
['127.0.0.0', 8], // loopback
|
|
23
|
+
['169.254.0.0', 16], // link-local, and with it 169.254.169.254 CLOUD METADATA
|
|
24
|
+
['172.16.0.0', 12], // RFC1918 private
|
|
25
|
+
['192.0.0.0', 24], // IETF protocol assignments
|
|
26
|
+
['192.0.2.0', 24], // TEST-NET-1
|
|
27
|
+
['192.168.0.0', 16], // RFC1918 private
|
|
28
|
+
['198.18.0.0', 15], // benchmarking
|
|
29
|
+
['198.51.100.0', 24], // TEST-NET-2
|
|
30
|
+
['203.0.113.0', 24], // TEST-NET-3
|
|
31
|
+
['224.0.0.0', 4], // multicast
|
|
32
|
+
['240.0.0.0', 4], // reserved, incl. 255.255.255.255 broadcast
|
|
33
|
+
];
|
|
34
|
+
/**
|
|
35
|
+
* Hostnames that are internal by NAME, independent of what they resolve to. Checked before DNS
|
|
36
|
+
* because the metadata service answers to its name inside every GCP VM and the name is what
|
|
37
|
+
* appears in a copy-pasted URL.
|
|
38
|
+
*/
|
|
39
|
+
static INTERNAL_HOSTNAMES = new Set([
|
|
40
|
+
'localhost',
|
|
41
|
+
'metadata',
|
|
42
|
+
'metadata.google.internal',
|
|
43
|
+
'metadata.goog',
|
|
44
|
+
'instance-data',
|
|
45
|
+
]);
|
|
46
|
+
/** True when `hostname` is internal by name alone (no DNS needed). */
|
|
47
|
+
isInternalHostname(hostname) {
|
|
48
|
+
const lower = hostname.toLowerCase().replace(/\.$/, '');
|
|
49
|
+
if (InternalAddressRules.INTERNAL_HOSTNAMES.has(lower)) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
// Any *.internal / *.localhost name is infrastructure-local by convention.
|
|
53
|
+
return lower.endsWith('.internal') || lower.endsWith('.localhost');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* True when `address` is an internal IP. Accepts IPv4 dotted-quad and IPv6 (including the
|
|
57
|
+
* IPv4-mapped `::ffff:a.b.c.d` form, which is how a dual-stack resolver reports an IPv4 answer
|
|
58
|
+
* and therefore the obvious way to smuggle 127.0.0.1 past an IPv4-only check).
|
|
59
|
+
*
|
|
60
|
+
* An address it cannot parse is treated as INTERNAL. Unparseable means "we do not know what this
|
|
61
|
+
* is", and the safe answer to that on the SSRF path is refusal, not delivery.
|
|
62
|
+
*/
|
|
63
|
+
isInternalAddress(address) {
|
|
64
|
+
const bare = address.replace(/^\[|\]$/g, '').split('%')[0];
|
|
65
|
+
if (bare.includes(':')) {
|
|
66
|
+
return this.isInternalV6(bare);
|
|
67
|
+
}
|
|
68
|
+
return this.isInternalV4(bare);
|
|
69
|
+
}
|
|
70
|
+
isInternalV4(address) {
|
|
71
|
+
const value = this.toV4Number(address);
|
|
72
|
+
if (value === undefined) {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
for (const block of InternalAddressRules.V4_BLOCKS) {
|
|
76
|
+
const network = this.toV4Number(block[0]);
|
|
77
|
+
if (network === undefined) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
// A /0 mask would shift by 32, which is a no-op in JS — no block here uses one.
|
|
81
|
+
const mask = (0xffffffff << (32 - block[1])) >>> 0;
|
|
82
|
+
if ((value & mask) >>> 0 === (network & mask) >>> 0) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
isInternalV6(address) {
|
|
89
|
+
const lower = address.toLowerCase();
|
|
90
|
+
// A dual-stack resolver reports IPv4 as ::ffff:a.b.c.d — judge it as the IPv4 it is.
|
|
91
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
|
|
92
|
+
if (mapped) {
|
|
93
|
+
return this.isInternalV4(mapped[1]);
|
|
94
|
+
}
|
|
95
|
+
// …and the URL parser NORMALIZES that same address to its hex form, `::ffff:7f00:1`. Both
|
|
96
|
+
// spellings name 127.0.0.1, so both have to be judged as it — checking only the dotted form
|
|
97
|
+
// would let `https://[::ffff:127.0.0.1]` through the moment it went through `new URL()`.
|
|
98
|
+
const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower);
|
|
99
|
+
if (mappedHex) {
|
|
100
|
+
const high = parseInt(mappedHex[1], 16);
|
|
101
|
+
const low = parseInt(mappedHex[2], 16);
|
|
102
|
+
const dotted = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;
|
|
103
|
+
return this.isInternalV4(dotted);
|
|
104
|
+
}
|
|
105
|
+
if (lower === '::1' || lower === '::') {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
// fc00::/7 unique-local, fe80::/10 link-local, ff00::/8 multicast.
|
|
109
|
+
return /^f[cd]/.test(lower) || /^fe[89ab]/.test(lower) || lower.startsWith('ff');
|
|
110
|
+
}
|
|
111
|
+
/** The dotted quad as a 32-bit number, or undefined when it is not a dotted quad at all. */
|
|
112
|
+
toV4Number(address) {
|
|
113
|
+
const parts = address.split('.');
|
|
114
|
+
if (parts.length !== 4) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
let value = 0;
|
|
118
|
+
for (const part of parts) {
|
|
119
|
+
if (!/^\d{1,3}$/.test(part)) {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
const octet = Number(part);
|
|
123
|
+
if (octet > 255) {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
value = (value * 256 + octet) >>> 0;
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
exports.InternalAddressRules = InternalAddressRules;
|
|
132
|
+
//# sourceMappingURL=InternalAddressRules.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"InternalAddressRules.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/InternalAddressRules.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;GAWG;AACH,MAAa,oBAAoB;IAC7B,iEAAiE;IACzD,MAAM,CAAU,SAAS,GAA6C;QAC1E,CAAC,SAAS,EAAE,CAAC,CAAC,EAAE,oEAAoE;QACpF,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,wBAAwB;QACzC,CAAC,YAAY,EAAE,EAAE,CAAC,EAAE,4EAA4E;QAChG,CAAC,WAAW,EAAE,CAAC,CAAC,EAAE,gBAAgB;QAClC,CAAC,aAAa,EAAE,EAAE,CAAC,EAAE,2DAA2D;QAChF,CAAC,YAAY,EAAE,EAAE,CAAC,EAAE,qBAAqB;QACzC,CAAC,WAAW,EAAE,EAAE,CAAC,EAAE,gCAAgC;QACnD,CAAC,WAAW,EAAE,EAAE,CAAC,EAAE,iBAAiB;QACpC,CAAC,aAAa,EAAE,EAAE,CAAC,EAAE,oBAAoB;QACzC,CAAC,YAAY,EAAE,EAAE,CAAC,EAAE,kBAAkB;QACtC,CAAC,cAAc,EAAE,EAAE,CAAC,EAAE,cAAc;QACpC,CAAC,aAAa,EAAE,EAAE,CAAC,EAAE,eAAe;QACpC,CAAC,WAAW,EAAE,CAAC,CAAC,EAAE,iBAAiB;QACnC,CAAC,WAAW,EAAE,CAAC,CAAC,EAAE,iDAAiD;KACtE,CAAC;IAEF;;;;OAIG;IACK,MAAM,CAAU,kBAAkB,GAAwB,IAAI,GAAG,CAAC;QACtE,WAAW;QACX,UAAU;QACV,0BAA0B;QAC1B,eAAe;QACf,eAAe;KAClB,CAAC,CAAC;IAEH,sEAAsE;IACtE,kBAAkB,CAAC,QAAgB;QAC/B,MAAM,KAAK,GAAG,QAAQ,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;QACxD,IAAI,oBAAoB,CAAC,kBAAkB,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YACrD,OAAO,IAAI,CAAC;QAChB,CAAC;QACD,2EAA2E;QAC3E,OAAO,KAAK,CAAC,QAAQ,CAAC,WAAW,CAAC,IAAI,KAAK,CAAC,QAAQ,CAAC,YAAY,CAAC,CAAC;IACvE,CAAC;IAED;;;;;;;OAOG;IACH,iBAAiB,CAAC,OAAe;QAC7B,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC;QAC3D,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;YACrB,OAAO,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;QACnC,CAAC;QACD,OAAO,IAAI,CAAC,YAAY,CAAC,IAAI,CAAC,CAAC;IACnC,CAAC;IAEO,YAAY,CAAC,OAAe;QAChC,MAAM,KAAK,GAAG,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,CAAC;QACvC,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;YACtB,OAAO,IAAI,CAAC;QAChB,CAAC;QACD,KAAK,MAAM,KAAK,IAAI,oBAAoB,CAAC,SAAS,EAAE,CAAC;YACjD,MAAM,OAAO,GAAG,IAAI,CAAC,UAAU,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YAC1C,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;gBACxB,SAAS;YACb,CAAC;YACD,gFAAgF;YAChF,MAAM,IAAI,GAAG,CAAC,UAAU,IAAI,CAAC,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YACnD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,OAAO,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;gBAClD,OAAO,IAAI,CAAC;YAChB,CAAC;QACL,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;IAEO,YAAY,CAAC,OAAe;QAChC,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC;QACpC,qFAAqF;QACrF,MAAM,MAAM,GAAG,+BAA+B,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC3D,IAAI,MAAM,EAAE,CAAC;YACT,OAAO,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QACxC,CAAC;QACD,0FAA0F;QAC1F,4FAA4F;QAC5F,yFAAyF;QACzF,MAAM,SAAS,GAAG,0CAA0C,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QACzE,IAAI,SAAS,EAAE,CAAC;YACZ,MAAM,IAAI,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACxC,MAAM,GAAG,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YACvC,MAAM,MAAM,GAAG,GAAG,IAAI,IAAI,CAAC,IAAI,IAAI,GAAG,IAAI,IAAI,GAAG,IAAI,CAAC,IAAI,GAAG,GAAG,IAAI,EAAE,CAAC;YACvE,OAAO,IAAI,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC;QACrC,CAAC;QACD,IAAI,KAAK,KAAK,KAAK,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;YACpC,OAAO,IAAI,CAAC;QAChB,CAAC;QACD,mEAAmE;QACnE,OAAO,QAAQ,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,WAAW,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC;IACrF,CAAC;IAED,4FAA4F;IACpF,UAAU,CAAC,OAAe;QAC9B,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QACjC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YACrB,OAAO,SAAS,CAAC;QACrB,CAAC;QACD,IAAI,KAAK,GAAG,CAAC,CAAC;QACd,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACvB,IAAI,CAAC,WAAW,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;gBAC1B,OAAO,SAAS,CAAC;YACrB,CAAC;YACD,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;YAC3B,IAAI,KAAK,GAAG,GAAG,EAAE,CAAC;gBACd,OAAO,SAAS,CAAC;YACrB,CAAC;YACD,KAAK,GAAG,CAAC,KAAK,GAAG,GAAG,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC;QACxC,CAAC;QACD,OAAO,KAAK,CAAC;IACjB,CAAC;;AAvHL,oDAwHC","sourcesContent":["/**\n * Which IP addresses count as INTERNAL — i.e. reachable only because of where our process happens\n * to sit, and therefore never a legitimate destination for a URL a partner supplied.\n *\n * Pure and dependency-free (it judges an address string, it does not resolve one), so it is unit\n * testable without DNS and is shared by the URL check and the redirect check.\n *\n * The list is deliberately WIDER than \"RFC1918\". A consumer running on a VPC connector can reach a\n * great deal more than 10/8, and the address that actually gets stolen in practice is\n * `169.254.169.254` — the cloud metadata service, which hands out the runtime service account's\n * tokens to anything that asks.\n */\nexport class InternalAddressRules {\n /** IPv4 CIDRs that are internal, as [network, prefix length]. */\n private static readonly V4_BLOCKS: ReadonlyArray<readonly [string, number]> = [\n ['0.0.0.0', 8], // \"this network\" — 0.x is routed to localhost by some stacks\n ['10.0.0.0', 8], // RFC1918 private\n ['100.64.0.0', 10], // RFC6598 carrier-grade NAT — a shared-tenant range, never ours to trust\n ['127.0.0.0', 8], // loopback\n ['169.254.0.0', 16], // link-local, and with it 169.254.169.254 CLOUD METADATA\n ['172.16.0.0', 12], // RFC1918 private\n ['192.0.0.0', 24], // IETF protocol assignments\n ['192.0.2.0', 24], // TEST-NET-1\n ['192.168.0.0', 16], // RFC1918 private\n ['198.18.0.0', 15], // benchmarking\n ['198.51.100.0', 24], // TEST-NET-2\n ['203.0.113.0', 24], // TEST-NET-3\n ['224.0.0.0', 4], // multicast\n ['240.0.0.0', 4], // reserved, incl. 255.255.255.255 broadcast\n ];\n\n /**\n * Hostnames that are internal by NAME, independent of what they resolve to. Checked before DNS\n * because the metadata service answers to its name inside every GCP VM and the name is what\n * appears in a copy-pasted URL.\n */\n private static readonly INTERNAL_HOSTNAMES: ReadonlySet<string> = new Set([\n 'localhost',\n 'metadata',\n 'metadata.google.internal',\n 'metadata.goog',\n 'instance-data',\n ]);\n\n /** True when `hostname` is internal by name alone (no DNS needed). */\n isInternalHostname(hostname: string): boolean {\n const lower = hostname.toLowerCase().replace(/\\.$/, '');\n if (InternalAddressRules.INTERNAL_HOSTNAMES.has(lower)) {\n return true;\n }\n // Any *.internal / *.localhost name is infrastructure-local by convention.\n return lower.endsWith('.internal') || lower.endsWith('.localhost');\n }\n\n /**\n * True when `address` is an internal IP. Accepts IPv4 dotted-quad and IPv6 (including the\n * IPv4-mapped `::ffff:a.b.c.d` form, which is how a dual-stack resolver reports an IPv4 answer\n * and therefore the obvious way to smuggle 127.0.0.1 past an IPv4-only check).\n *\n * An address it cannot parse is treated as INTERNAL. Unparseable means \"we do not know what this\n * is\", and the safe answer to that on the SSRF path is refusal, not delivery.\n */\n isInternalAddress(address: string): boolean {\n const bare = address.replace(/^\\[|\\]$/g, '').split('%')[0];\n if (bare.includes(':')) {\n return this.isInternalV6(bare);\n }\n return this.isInternalV4(bare);\n }\n\n private isInternalV4(address: string): boolean {\n const value = this.toV4Number(address);\n if (value === undefined) {\n return true;\n }\n for (const block of InternalAddressRules.V4_BLOCKS) {\n const network = this.toV4Number(block[0]);\n if (network === undefined) {\n continue;\n }\n // A /0 mask would shift by 32, which is a no-op in JS — no block here uses one.\n const mask = (0xffffffff << (32 - block[1])) >>> 0;\n if ((value & mask) >>> 0 === (network & mask) >>> 0) {\n return true;\n }\n }\n return false;\n }\n\n private isInternalV6(address: string): boolean {\n const lower = address.toLowerCase();\n // A dual-stack resolver reports IPv4 as ::ffff:a.b.c.d — judge it as the IPv4 it is.\n const mapped = /^::ffff:(\\d+\\.\\d+\\.\\d+\\.\\d+)$/.exec(lower);\n if (mapped) {\n return this.isInternalV4(mapped[1]);\n }\n // …and the URL parser NORMALIZES that same address to its hex form, `::ffff:7f00:1`. Both\n // spellings name 127.0.0.1, so both have to be judged as it — checking only the dotted form\n // would let `https://[::ffff:127.0.0.1]` through the moment it went through `new URL()`.\n const mappedHex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(lower);\n if (mappedHex) {\n const high = parseInt(mappedHex[1], 16);\n const low = parseInt(mappedHex[2], 16);\n const dotted = `${high >> 8}.${high & 0xff}.${low >> 8}.${low & 0xff}`;\n return this.isInternalV4(dotted);\n }\n if (lower === '::1' || lower === '::') {\n return true;\n }\n // fc00::/7 unique-local, fe80::/10 link-local, ff00::/8 multicast.\n return /^f[cd]/.test(lower) || /^fe[89ab]/.test(lower) || lower.startsWith('ff');\n }\n\n /** The dotted quad as a 32-bit number, or undefined when it is not a dotted quad at all. */\n private toV4Number(address: string): number | undefined {\n const parts = address.split('.');\n if (parts.length !== 4) {\n return undefined;\n }\n let value = 0;\n for (const part of parts) {\n if (!/^\\d{1,3}$/.test(part)) {\n return undefined;\n }\n const octet = Number(part);\n if (octet > 255) {\n return undefined;\n }\n value = (value * 256 + octet) >>> 0;\n }\n return value;\n }\n}\n"]}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A client carrying a {@link ContextBaseUrlFilter} was called with no
|
|
3
|
+
* `WebpiecesCoreHeaders.OVERRIDE_BASE_URL` in the ambient RequestContext, so this call has nowhere
|
|
4
|
+
* to go.
|
|
5
|
+
*
|
|
6
|
+
* It is a THROW rather than a fallback to the client's configured service URL, deliberately: a
|
|
7
|
+
* silent fallback would send a partner's payload to one of our own services, which is a worse
|
|
8
|
+
* outcome than a loud failure by every measure.
|
|
9
|
+
*
|
|
10
|
+
* Its OWN type, not a bare Error and NOT an {@link SsrfRefusedError}: a delivery worker has to tell
|
|
11
|
+
* "we were misconfigured" (page somebody; retrying is pointless) from "the partner registered
|
|
12
|
+
* something hostile" (dead-letter it). Different owners, different cures.
|
|
13
|
+
*/
|
|
14
|
+
export declare class MissingRuntimeBaseUrlError extends Error {
|
|
15
|
+
/** `Contract.method`, so the log line names the call that had nowhere to go. */
|
|
16
|
+
readonly endpoint: string;
|
|
17
|
+
constructor(message: string,
|
|
18
|
+
/** `Contract.method`, so the log line names the call that had nowhere to go. */
|
|
19
|
+
endpoint: string);
|
|
20
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MissingRuntimeBaseUrlError = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A client carrying a {@link ContextBaseUrlFilter} was called with no
|
|
6
|
+
* `WebpiecesCoreHeaders.OVERRIDE_BASE_URL` in the ambient RequestContext, so this call has nowhere
|
|
7
|
+
* to go.
|
|
8
|
+
*
|
|
9
|
+
* It is a THROW rather than a fallback to the client's configured service URL, deliberately: a
|
|
10
|
+
* silent fallback would send a partner's payload to one of our own services, which is a worse
|
|
11
|
+
* outcome than a loud failure by every measure.
|
|
12
|
+
*
|
|
13
|
+
* Its OWN type, not a bare Error and NOT an {@link SsrfRefusedError}: a delivery worker has to tell
|
|
14
|
+
* "we were misconfigured" (page somebody; retrying is pointless) from "the partner registered
|
|
15
|
+
* something hostile" (dead-letter it). Different owners, different cures.
|
|
16
|
+
*/
|
|
17
|
+
class MissingRuntimeBaseUrlError extends Error {
|
|
18
|
+
endpoint;
|
|
19
|
+
constructor(message,
|
|
20
|
+
/** `Contract.method`, so the log line names the call that had nowhere to go. */
|
|
21
|
+
endpoint) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.endpoint = endpoint;
|
|
24
|
+
this.name = 'MissingRuntimeBaseUrlError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
exports.MissingRuntimeBaseUrlError = MissingRuntimeBaseUrlError;
|
|
28
|
+
//# sourceMappingURL=MissingRuntimeBaseUrlError.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"MissingRuntimeBaseUrlError.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/MissingRuntimeBaseUrlError.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;GAYG;AACH,MAAa,0BAA2B,SAAQ,KAAK;IAI7B;IAHpB,YACI,OAAe;IACf,gFAAgF;IAChE,QAAgB;QAEhC,KAAK,CAAC,OAAO,CAAC,CAAC;QAFC,aAAQ,GAAR,QAAQ,CAAQ;QAGhC,IAAI,CAAC,IAAI,GAAG,4BAA4B,CAAC;IAC7C,CAAC;CACJ;AATD,gEASC","sourcesContent":["/**\n * A client carrying a {@link ContextBaseUrlFilter} was called with no\n * `WebpiecesCoreHeaders.OVERRIDE_BASE_URL` in the ambient RequestContext, so this call has nowhere\n * to go.\n *\n * It is a THROW rather than a fallback to the client's configured service URL, deliberately: a\n * silent fallback would send a partner's payload to one of our own services, which is a worse\n * outcome than a loud failure by every measure.\n *\n * Its OWN type, not a bare Error and NOT an {@link SsrfRefusedError}: a delivery worker has to tell\n * \"we were misconfigured\" (page somebody; retrying is pointless) from \"the partner registered\n * something hostile\" (dead-letter it). Different owners, different cures.\n */\nexport class MissingRuntimeBaseUrlError extends Error {\n constructor(\n message: string,\n /** `Contract.method`, so the log line names the call that had nowhere to go. */\n public readonly endpoint: string,\n ) {\n super(message);\n this.name = 'MissingRuntimeBaseUrlError';\n }\n}\n"]}
|
package/src/NodeProxyClient.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DestinationTrust, RouteMetadata, Secrets } from '@webpieces/core-util';
|
|
2
2
|
import { RequestContextHeaders } from '@webpieces/core-context';
|
|
3
3
|
import { GcpOidc } from '@webpieces/gcp-identity';
|
|
4
|
-
import { ApiPrototype, ProxyClient, TranslatedFailure } from '@webpieces/http-client-core';
|
|
4
|
+
import { ApiPrototype, ClientFilterDefinition, ProxyClient, TranslatedFailure } from '@webpieces/http-client-core';
|
|
5
|
+
import { AddressResolver } from './AddressResolver';
|
|
5
6
|
import { ClientConfig } from './ClientConfig';
|
|
7
|
+
import { WebhookSignerCallback } from './WebhookSignerCallback';
|
|
6
8
|
/**
|
|
7
9
|
* The server-side {@link ProxyClient}. Everything a browser cannot do lives here: reading the
|
|
8
10
|
* ambient RequestContext, minting OIDC tokens, holding shared secrets, and recording test cases.
|
|
@@ -14,11 +16,16 @@ import { ClientConfig } from './ClientConfig';
|
|
|
14
16
|
export declare class NodeProxyClient extends ProxyClient {
|
|
15
17
|
private readonly headers;
|
|
16
18
|
private readonly gcpOidc;
|
|
19
|
+
private readonly addressResolver;
|
|
17
20
|
private readonly secrets?;
|
|
21
|
+
private readonly webhookSigner?;
|
|
18
22
|
private config;
|
|
19
|
-
constructor(headers: RequestContextHeaders, gcpOidc: GcpOidc, secrets?: Secrets | undefined);
|
|
20
|
-
/**
|
|
21
|
-
|
|
23
|
+
constructor(headers: RequestContextHeaders, gcpOidc: GcpOidc, addressResolver: AddressResolver, secrets?: Secrets | undefined, webhookSigner?: WebhookSignerCallback | undefined);
|
|
24
|
+
/**
|
|
25
|
+
* Bind this client to one API contract + target, with the app's outbound filters (url
|
|
26
|
+
* rewriting, header editing, logging, per-call re-pointing).
|
|
27
|
+
*/
|
|
28
|
+
init(apiPrototype: ApiPrototype<object>, config: ClientConfig, appFilters: ClientFilterDefinition[]): void;
|
|
22
29
|
/**
|
|
23
30
|
* The same chain every client runs — a ClientRegistry mapping, else the installed deriver — but
|
|
24
31
|
* with NODE's fallback: THROW. A server has no "own origin" to fall back to the way a browser
|
|
@@ -28,6 +35,27 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
28
35
|
* read beneath a deriver is memoized process-wide, so only the first call pays.
|
|
29
36
|
*/
|
|
30
37
|
protected resolveBaseUrl(): Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* The two framework built-ins, installed on EVERY client this package builds and ordered
|
|
40
|
+
* beneath every app filter by {@link ProxyClient.initRoutes}.
|
|
41
|
+
*
|
|
42
|
+
* They are unconditional rather than opt-in because neither costs anything on the path that
|
|
43
|
+
* does not need it: the SSRF guard steps aside when nothing re-pointed the request, and the
|
|
44
|
+
* auth filter does nothing for a `@Public` endpoint. An app therefore cannot forget to install
|
|
45
|
+
* the guard on the one client that takes runtime URLs — the ACT of re-pointing is what arms it.
|
|
46
|
+
*/
|
|
47
|
+
protected clientFilters(): ClientFilterDefinition[];
|
|
48
|
+
/**
|
|
49
|
+
* WHICH policy the guard applies when something does re-point this client.
|
|
50
|
+
*
|
|
51
|
+
* Read off an installed {@link ContextBaseUrlFilter}, because that filter is where an app says
|
|
52
|
+
* "this client may be re-pointed", and the single legitimate relaxation
|
|
53
|
+
* ({@link SsrfTestingPolicy}) belongs at the same construction site as that decision rather
|
|
54
|
+
* than in a second place a reader has to correlate. No such filter — or one built with the
|
|
55
|
+
* default — means {@link SsrfPolicy} (the strict one), so the safe answer is what an app gets by saying
|
|
56
|
+
* nothing.
|
|
57
|
+
*/
|
|
58
|
+
private ssrfPolicy;
|
|
31
59
|
/**
|
|
32
60
|
* Straight from the RequestContext. Throws when there is no active request scope.
|
|
33
61
|
*
|
|
@@ -37,15 +65,6 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
37
65
|
* friends instead of shipping headers that endpoint's AuthFilter is obliged to reject.
|
|
38
66
|
*/
|
|
39
67
|
protected outboundContextHeaders(destination: DestinationTrust): Map<string, string>;
|
|
40
|
-
/**
|
|
41
|
-
* Attach the outbound credential for the endpoint's AuthMode: an @AuthOidc bearer minted as
|
|
42
|
-
* this caller's runtime SA (audience = the callee base URL — the server verifies the signature
|
|
43
|
-
* + caller allow-list), or the @AuthSharedSecret(key) value THIS client sends from its bound
|
|
44
|
-
* {@link Secrets}. Both ride in the ONE `Authorization` header under their own scheme —
|
|
45
|
-
* `Bearer <oidc>` / `Webpieces <secret>` — which is never a context key, so it cannot leak onto
|
|
46
|
-
* the next hop. Never reads process.env.
|
|
47
|
-
*/
|
|
48
|
-
protected attachOutboundAuth(route: RouteMetadata, baseUrl: string, httpHeaders: Record<string, string>): Promise<void>;
|
|
49
68
|
/**
|
|
50
69
|
* Test-case recording hook (mirror of Java HttpsJsonClientInvokeHandler): if a recorder is
|
|
51
70
|
* travelling in the magic context, capture this outbound call + its result so it becomes a mock
|
|
@@ -59,8 +78,6 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
59
78
|
* from the call path — a logging backend stamps its own fields and never sees this.
|
|
60
79
|
*/
|
|
61
80
|
private recordCall;
|
|
62
|
-
/** A server can satisfy every auth mode, so nothing is rejected at bind time. */
|
|
63
|
-
protected assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string): void;
|
|
64
81
|
/**
|
|
65
82
|
* SERVER-TO-SERVER: a 4xx received from a dependency becomes THIS server's own 500.
|
|
66
83
|
*
|
package/src/NodeProxyClient.js
CHANGED
|
@@ -7,6 +7,21 @@ const core_util_1 = require("@webpieces/core-util");
|
|
|
7
7
|
const core_context_1 = require("@webpieces/core-context");
|
|
8
8
|
const gcp_identity_1 = require("@webpieces/gcp-identity");
|
|
9
9
|
const http_client_core_1 = require("@webpieces/http-client-core");
|
|
10
|
+
const AddressResolver_1 = require("./AddressResolver");
|
|
11
|
+
const ContextBaseUrlFilter_1 = require("./ContextBaseUrlFilter");
|
|
12
|
+
const OutboundAuthFilter_1 = require("./OutboundAuthFilter");
|
|
13
|
+
const SsrfGuardFilter_1 = require("./SsrfGuardFilter");
|
|
14
|
+
const SsrfPolicy_1 = require("./SsrfPolicy");
|
|
15
|
+
const WebhookSignerCallback_1 = require("./WebhookSignerCallback");
|
|
16
|
+
/**
|
|
17
|
+
* The two framework built-ins' priorities, RELATIVE TO EACH OTHER and to nothing else — they are
|
|
18
|
+
* ordered beneath every app filter structurally, not by number (see `ProxyClient.initRoutes`).
|
|
19
|
+
*
|
|
20
|
+
* The guard is OUTSIDE the minter deliberately: a destination that is going to be refused must be
|
|
21
|
+
* refused BEFORE a credential is minted for it, so a hostile URL never causes a token to exist.
|
|
22
|
+
*/
|
|
23
|
+
const SSRF_GUARD_PRIORITY = 900;
|
|
24
|
+
const OUTBOUND_AUTH_PRIORITY = 800;
|
|
10
25
|
/**
|
|
11
26
|
* The server-side {@link ProxyClient}. Everything a browser cannot do lives here: reading the
|
|
12
27
|
* ambient RequestContext, minting OIDC tokens, holding shared secrets, and recording test cases.
|
|
@@ -18,18 +33,25 @@ const http_client_core_1 = require("@webpieces/http-client-core");
|
|
|
18
33
|
let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClient {
|
|
19
34
|
headers;
|
|
20
35
|
gcpOidc;
|
|
36
|
+
addressResolver;
|
|
21
37
|
secrets;
|
|
38
|
+
webhookSigner;
|
|
22
39
|
config;
|
|
23
|
-
constructor(headers, gcpOidc, secrets) {
|
|
40
|
+
constructor(headers, gcpOidc, addressResolver, secrets, webhookSigner) {
|
|
24
41
|
super();
|
|
25
42
|
this.headers = headers;
|
|
26
43
|
this.gcpOidc = gcpOidc;
|
|
44
|
+
this.addressResolver = addressResolver;
|
|
27
45
|
this.secrets = secrets;
|
|
46
|
+
this.webhookSigner = webhookSigner;
|
|
28
47
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Bind this client to one API contract + target, with the app's outbound filters (url
|
|
50
|
+
* rewriting, header editing, logging, per-call re-pointing).
|
|
51
|
+
*/
|
|
52
|
+
init(apiPrototype, config, appFilters) {
|
|
31
53
|
this.config = config;
|
|
32
|
-
this.initRoutes(apiPrototype);
|
|
54
|
+
this.initRoutes(apiPrototype, appFilters);
|
|
33
55
|
}
|
|
34
56
|
/**
|
|
35
57
|
* The same chain every client runs — a ClientRegistry mapping, else the installed deriver — but
|
|
@@ -42,6 +64,39 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
42
64
|
resolveBaseUrl() {
|
|
43
65
|
return core_util_1.ClientRegistry.resolve(this.config.svcName);
|
|
44
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* The two framework built-ins, installed on EVERY client this package builds and ordered
|
|
69
|
+
* beneath every app filter by {@link ProxyClient.initRoutes}.
|
|
70
|
+
*
|
|
71
|
+
* They are unconditional rather than opt-in because neither costs anything on the path that
|
|
72
|
+
* does not need it: the SSRF guard steps aside when nothing re-pointed the request, and the
|
|
73
|
+
* auth filter does nothing for a `@Public` endpoint. An app therefore cannot forget to install
|
|
74
|
+
* the guard on the one client that takes runtime URLs — the ACT of re-pointing is what arms it.
|
|
75
|
+
*/
|
|
76
|
+
clientFilters() {
|
|
77
|
+
return [
|
|
78
|
+
new http_client_core_1.ClientFilterDefinition(SSRF_GUARD_PRIORITY, new SsrfGuardFilter_1.SsrfGuardFilter(this.ssrfPolicy(), this.addressResolver)),
|
|
79
|
+
new http_client_core_1.ClientFilterDefinition(OUTBOUND_AUTH_PRIORITY, new OutboundAuthFilter_1.OutboundAuthFilter(this.gcpOidc, this.secrets, this.webhookSigner)),
|
|
80
|
+
];
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* WHICH policy the guard applies when something does re-point this client.
|
|
84
|
+
*
|
|
85
|
+
* Read off an installed {@link ContextBaseUrlFilter}, because that filter is where an app says
|
|
86
|
+
* "this client may be re-pointed", and the single legitimate relaxation
|
|
87
|
+
* ({@link SsrfTestingPolicy}) belongs at the same construction site as that decision rather
|
|
88
|
+
* than in a second place a reader has to correlate. No such filter — or one built with the
|
|
89
|
+
* default — means {@link SsrfPolicy} (the strict one), so the safe answer is what an app gets by saying
|
|
90
|
+
* nothing.
|
|
91
|
+
*/
|
|
92
|
+
ssrfPolicy() {
|
|
93
|
+
for (const definition of this.appFilters) {
|
|
94
|
+
const filter = definition.filter;
|
|
95
|
+
if (filter instanceof ContextBaseUrlFilter_1.ContextBaseUrlFilter)
|
|
96
|
+
return filter.ssrfPolicy;
|
|
97
|
+
}
|
|
98
|
+
return new SsrfPolicy_1.SsrfPolicy();
|
|
99
|
+
}
|
|
45
100
|
/**
|
|
46
101
|
* Straight from the RequestContext. Throws when there is no active request scope.
|
|
47
102
|
*
|
|
@@ -53,29 +108,6 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
53
108
|
outboundContextHeaders(destination) {
|
|
54
109
|
return this.headers.buildOutboundHeaders(destination);
|
|
55
110
|
}
|
|
56
|
-
/**
|
|
57
|
-
* Attach the outbound credential for the endpoint's AuthMode: an @AuthOidc bearer minted as
|
|
58
|
-
* this caller's runtime SA (audience = the callee base URL — the server verifies the signature
|
|
59
|
-
* + caller allow-list), or the @AuthSharedSecret(key) value THIS client sends from its bound
|
|
60
|
-
* {@link Secrets}. Both ride in the ONE `Authorization` header under their own scheme —
|
|
61
|
-
* `Bearer <oidc>` / `Webpieces <secret>` — which is never a context key, so it cannot leak onto
|
|
62
|
-
* the next hop. Never reads process.env.
|
|
63
|
-
*/
|
|
64
|
-
async attachOutboundAuth(route, baseUrl, httpHeaders) {
|
|
65
|
-
const mode = route.authMeta?.mode;
|
|
66
|
-
if (mode?.kind === 'oidc') {
|
|
67
|
-
httpHeaders['Authorization'] = `Bearer ${await this.gcpOidc.mintIdToken(baseUrl)}`;
|
|
68
|
-
}
|
|
69
|
-
else if (mode?.kind === 'shared-secret') {
|
|
70
|
-
const secret = this.secrets?.get(mode.secretKey);
|
|
71
|
-
if (!secret) {
|
|
72
|
-
throw new Error(`No shared secret configured for @AuthSharedSecret('${mode.secretKey}') endpoint ${route.methodName}`);
|
|
73
|
-
}
|
|
74
|
-
// Same header as a JWT/OIDC token, but its OWN scheme, so a secret can never be
|
|
75
|
-
// mistaken for a token nor accepted where one was expected.
|
|
76
|
-
httpHeaders['Authorization'] = `Webpieces ${secret}`;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
111
|
/**
|
|
80
112
|
* Test-case recording hook (mirror of Java HttpsJsonClientInvokeHandler): if a recorder is
|
|
81
113
|
* travelling in the magic context, capture this outbound call + its result so it becomes a mock
|
|
@@ -119,8 +151,6 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
119
151
|
throw err;
|
|
120
152
|
}
|
|
121
153
|
}
|
|
122
|
-
/** A server can satisfy every auth mode, so nothing is rejected at bind time. */
|
|
123
|
-
assertEndpointSupported(_authMeta, _methodName) { }
|
|
124
154
|
/**
|
|
125
155
|
* SERVER-TO-SERVER: a 4xx received from a dependency becomes THIS server's own 500.
|
|
126
156
|
*
|
|
@@ -186,11 +216,16 @@ exports.NodeProxyClient = NodeProxyClient = tslib_1.__decorate([
|
|
|
186
216
|
(0, core_context_1.provideFrameworkTransient)(),
|
|
187
217
|
tslib_1.__param(0, (0, inversify_1.inject)(core_context_1.RequestContextHeaders)),
|
|
188
218
|
tslib_1.__param(1, (0, inversify_1.inject)(gcp_identity_1.GcpOidc)),
|
|
189
|
-
tslib_1.__param(2, (0, inversify_1.
|
|
190
|
-
tslib_1.__param(
|
|
219
|
+
tslib_1.__param(2, (0, inversify_1.inject)(AddressResolver_1.AddressResolver)),
|
|
220
|
+
tslib_1.__param(3, (0, inversify_1.optional)()),
|
|
221
|
+
tslib_1.__param(3, (0, inversify_1.inject)(core_util_1.SECRETS)),
|
|
222
|
+
tslib_1.__param(4, (0, inversify_1.optional)()),
|
|
223
|
+
tslib_1.__param(4, (0, inversify_1.inject)(WebhookSignerCallback_1.WEBHOOK_SIGNER_CALLBACK)),
|
|
191
224
|
tslib_1.__metadata("design:paramtypes", [core_context_1.RequestContextHeaders,
|
|
192
225
|
gcp_identity_1.GcpOidc,
|
|
193
|
-
|
|
226
|
+
AddressResolver_1.AddressResolver,
|
|
227
|
+
core_util_1.Secrets,
|
|
228
|
+
WebhookSignerCallback_1.WebhookSignerCallback])
|
|
194
229
|
], NodeProxyClient);
|
|
195
230
|
/**
|
|
196
231
|
* DI token for the `Provider<NodeProxyClient>` that hands out RPC clients — one per API contract.
|