@webpieces/http-client-node 0.4.699 → 0.4.700
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 +75 -1
- package/package.json +5 -5
- package/src/AddressResolver.d.ts +23 -0
- package/src/AddressResolver.js +29 -0
- package/src/AddressResolver.js.map +1 -0
- package/src/ClientConfig.d.ts +41 -11
- package/src/ClientConfig.js +24 -7
- package/src/ClientConfig.js.map +1 -1
- package/src/ClientHttpFactory.d.ts +21 -4
- package/src/ClientHttpFactory.js +21 -5
- package/src/ClientHttpFactory.js.map +1 -1
- package/src/ContextBaseUrlOverrideFilter.d.ts +38 -0
- package/src/ContextBaseUrlOverrideFilter.js +57 -0
- package/src/ContextBaseUrlOverrideFilter.js.map +1 -0
- package/src/HostPolicy.d.ts +118 -0
- package/src/HostPolicy.js +169 -0
- package/src/HostPolicy.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/NodeProxyClient.d.ts +24 -6
- package/src/NodeProxyClient.js +30 -8
- package/src/NodeProxyClient.js.map +1 -1
- package/src/RuntimeHostErrors.d.ts +42 -0
- package/src/RuntimeHostErrors.js +54 -0
- package/src/RuntimeHostErrors.js.map +1 -0
- package/src/SsrfGuardFilter.d.ts +50 -0
- package/src/SsrfGuardFilter.js +146 -0
- package/src/SsrfGuardFilter.js.map +1 -0
- package/src/SsrfPolicy.d.ts +64 -0
- package/src/SsrfPolicy.js +52 -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/index.d.ts +15 -1
- package/src/index.js +38 -2
- package/src/index.js.map +1 -1
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { AuthMeta } from '@webpieces/core-util';
|
|
2
|
+
import { ClientFilterDefinition } from '@webpieces/http-client-core';
|
|
3
|
+
import { AddressResolver } from './AddressResolver';
|
|
4
|
+
/**
|
|
5
|
+
* WHERE a client's requests go — the second half of a {@link ClientConfig}, and a REQUIRED one.
|
|
6
|
+
*
|
|
7
|
+
* There are exactly two kinds of destination, and conflating them is what made outbound partner
|
|
8
|
+
* webhooks fall out of the typed world entirely:
|
|
9
|
+
*
|
|
10
|
+
* - a service WE DEPLOY, named by `svcName` and resolved through {@link ClientRegistry}
|
|
11
|
+
* → {@link DeployedServiceHost}
|
|
12
|
+
* - a host that is DATA — a URL a partner registered, an OAuth callback, a per-tenant or
|
|
13
|
+
* self-hosted instance — supplied per call through the RequestContext
|
|
14
|
+
* → {@link RuntimeHostFromContext}
|
|
15
|
+
*
|
|
16
|
+
* Naming one is not optional, because the two carry completely different risk. A deployed peer is
|
|
17
|
+
* an address we chose; a runtime host is attacker-influenced data, and the framework owes it an
|
|
18
|
+
* SSRF policy. Making the choice a class the caller writes down means
|
|
19
|
+
* `grep -rn RuntimeHostFromContext` enumerates every client in the codebase that can be re-pointed
|
|
20
|
+
* at all — which is the question a security review actually asks.
|
|
21
|
+
*/
|
|
22
|
+
export declare abstract class HostPolicy {
|
|
23
|
+
/**
|
|
24
|
+
* The base URL this call STARTS from, before any filter re-points it.
|
|
25
|
+
*
|
|
26
|
+
* For a deployed service that is the whole answer. For a runtime host it is the empty string:
|
|
27
|
+
* there is nothing to resolve at this point, and {@link ContextBaseUrlOverrideFilter} — which
|
|
28
|
+
* this policy also installs — supplies the real destination inside the chain, or refuses. The
|
|
29
|
+
* empty seed is never sent: the override filter throws when the context carries no URL, and the
|
|
30
|
+
* SSRF guard refuses anything that is not an absolute https URL.
|
|
31
|
+
*/
|
|
32
|
+
abstract resolveBaseUrl(svcName: string): Promise<string>;
|
|
33
|
+
/** The framework filters this policy installs, in addition to whatever the app passed. */
|
|
34
|
+
abstract builtInFilters(): ClientFilterDefinition[];
|
|
35
|
+
/**
|
|
36
|
+
* Reject, at BIND time, an endpoint this policy cannot honestly satisfy.
|
|
37
|
+
*
|
|
38
|
+
* @throws Error naming the endpoint and why. The default accepts everything.
|
|
39
|
+
*/
|
|
40
|
+
assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string, _contractName: string): void;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* TODAY'S BEHAVIOUR, unchanged and byte for byte: the destination is a service we deploy, and its
|
|
44
|
+
* URL comes from {@link ClientRegistry} — a registered mapping, else the installed deriver, else a
|
|
45
|
+
* throw.
|
|
46
|
+
*
|
|
47
|
+
* It installs NO filters, reads no context key, and performs no DNS lookups, so a client written
|
|
48
|
+
* this way runs the exact code path it ran before the outbound chain existed. This is the one to
|
|
49
|
+
* reach for; the runtime ones are for the case where the address genuinely is not knowable at
|
|
50
|
+
* build time.
|
|
51
|
+
*/
|
|
52
|
+
export declare class DeployedServiceHost extends HostPolicy {
|
|
53
|
+
resolveBaseUrl(svcName: string): Promise<string>;
|
|
54
|
+
builtInFilters(): ClientFilterDefinition[];
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The destination is supplied PER CALL, through
|
|
58
|
+
* `RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, url)`, and the framework's
|
|
59
|
+
* SSRF policy is ON.
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* const partner = factory.createRpcClient(
|
|
63
|
+
* PartnerWebhookApi,
|
|
64
|
+
* new ClientConfig('partner-webhooks', new RuntimeHostFromContext(new DnsAddressResolver())),
|
|
65
|
+
* [new ClientFilterDefinition(500, new HmacSigningFilter(secret))],
|
|
66
|
+
* );
|
|
67
|
+
*
|
|
68
|
+
* for (const webhook of webhooks) {
|
|
69
|
+
* await RequestContext.run(() => {
|
|
70
|
+
* RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);
|
|
71
|
+
* return partner.deliver(envelope);
|
|
72
|
+
* });
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* `svcName` is still required, and still means something: it is the IDENTITY this client gets as a
|
|
77
|
+
* node on the runtime architecture graph. That is the whole point of routing partner deliveries
|
|
78
|
+
* back through a generated client — the most security-sensitive hop in the system stops being
|
|
79
|
+
* invisible.
|
|
80
|
+
*
|
|
81
|
+
* Endpoints whose auth mode is `@AuthOidc` or `@AuthSharedSecret` are REFUSED at bind time. Both
|
|
82
|
+
* mint a credential for a specific audience/peer that we chose, and there is no honest audience for
|
|
83
|
+
* a host we will not know until the call happens — minting one for a partner's URL would hand them
|
|
84
|
+
* a token. Authenticate a runtime-host call the way a webhook is actually authenticated: a signing
|
|
85
|
+
* filter over the exact bytes.
|
|
86
|
+
*/
|
|
87
|
+
export declare class RuntimeHostFromContext extends HostPolicy {
|
|
88
|
+
private readonly addressResolver;
|
|
89
|
+
constructor(addressResolver: AddressResolver);
|
|
90
|
+
resolveBaseUrl(_svcName: string): Promise<string>;
|
|
91
|
+
builtInFilters(): ClientFilterDefinition[];
|
|
92
|
+
assertEndpointSupported(authMeta: AuthMeta | undefined, methodName: string, contractName: string): void;
|
|
93
|
+
/** HTTPS only, no internal addresses, at most one redirect — each hop re-judged. */
|
|
94
|
+
private static readonly STRICT;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* {@link RuntimeHostFromContext}, with the internal-address refusals SWITCHED OFF and plaintext
|
|
98
|
+
* http allowed — for a local emulator, an on-cluster service, or a test harness whose "partner" is
|
|
99
|
+
* `http://127.0.0.1:9123`.
|
|
100
|
+
*
|
|
101
|
+
* The long name is the feature. This is the permissive branch, so it is a NOUN a reviewer can grep
|
|
102
|
+
* (`grep -rn AllowingInternalAddresses` lists every client that can reach inside the network with a
|
|
103
|
+
* runtime-supplied URL) rather than a boolean, an omitted argument, or an empty allow-list — a
|
|
104
|
+
* widening that reads as an ABSENCE is invisible exactly where it matters most.
|
|
105
|
+
*
|
|
106
|
+
* The `reason` is required and is quoted back in this client's refusal messages, so the
|
|
107
|
+
* justification travels with the decision instead of living in a commit message.
|
|
108
|
+
*/
|
|
109
|
+
export declare class RuntimeHostFromContextAllowingInternalAddresses extends HostPolicy {
|
|
110
|
+
private readonly addressResolver;
|
|
111
|
+
private readonly policy;
|
|
112
|
+
constructor(
|
|
113
|
+
/** WHY this client may reach internal addresses, in prose. Required. */
|
|
114
|
+
reason: string, addressResolver: AddressResolver);
|
|
115
|
+
resolveBaseUrl(_svcName: string): Promise<string>;
|
|
116
|
+
builtInFilters(): ClientFilterDefinition[];
|
|
117
|
+
assertEndpointSupported(authMeta: AuthMeta | undefined, methodName: string, contractName: string): void;
|
|
118
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.RuntimeHostFromContextAllowingInternalAddresses = exports.RuntimeHostFromContext = exports.DeployedServiceHost = exports.HostPolicy = void 0;
|
|
4
|
+
const core_util_1 = require("@webpieces/core-util");
|
|
5
|
+
const http_client_core_1 = require("@webpieces/http-client-core");
|
|
6
|
+
const ContextBaseUrlOverrideFilter_1 = require("./ContextBaseUrlOverrideFilter");
|
|
7
|
+
const SsrfGuardFilter_1 = require("./SsrfGuardFilter");
|
|
8
|
+
const SsrfPolicy_1 = require("./SsrfPolicy");
|
|
9
|
+
const RuntimeHostErrors_1 = require("./RuntimeHostErrors");
|
|
10
|
+
/**
|
|
11
|
+
* WHERE a client's requests go — the second half of a {@link ClientConfig}, and a REQUIRED one.
|
|
12
|
+
*
|
|
13
|
+
* There are exactly two kinds of destination, and conflating them is what made outbound partner
|
|
14
|
+
* webhooks fall out of the typed world entirely:
|
|
15
|
+
*
|
|
16
|
+
* - a service WE DEPLOY, named by `svcName` and resolved through {@link ClientRegistry}
|
|
17
|
+
* → {@link DeployedServiceHost}
|
|
18
|
+
* - a host that is DATA — a URL a partner registered, an OAuth callback, a per-tenant or
|
|
19
|
+
* self-hosted instance — supplied per call through the RequestContext
|
|
20
|
+
* → {@link RuntimeHostFromContext}
|
|
21
|
+
*
|
|
22
|
+
* Naming one is not optional, because the two carry completely different risk. A deployed peer is
|
|
23
|
+
* an address we chose; a runtime host is attacker-influenced data, and the framework owes it an
|
|
24
|
+
* SSRF policy. Making the choice a class the caller writes down means
|
|
25
|
+
* `grep -rn RuntimeHostFromContext` enumerates every client in the codebase that can be re-pointed
|
|
26
|
+
* at all — which is the question a security review actually asks.
|
|
27
|
+
*/
|
|
28
|
+
class HostPolicy {
|
|
29
|
+
/**
|
|
30
|
+
* Reject, at BIND time, an endpoint this policy cannot honestly satisfy.
|
|
31
|
+
*
|
|
32
|
+
* @throws Error naming the endpoint and why. The default accepts everything.
|
|
33
|
+
*/
|
|
34
|
+
assertEndpointSupported(_authMeta, _methodName, _contractName) { }
|
|
35
|
+
}
|
|
36
|
+
exports.HostPolicy = HostPolicy;
|
|
37
|
+
/**
|
|
38
|
+
* TODAY'S BEHAVIOUR, unchanged and byte for byte: the destination is a service we deploy, and its
|
|
39
|
+
* URL comes from {@link ClientRegistry} — a registered mapping, else the installed deriver, else a
|
|
40
|
+
* throw.
|
|
41
|
+
*
|
|
42
|
+
* It installs NO filters, reads no context key, and performs no DNS lookups, so a client written
|
|
43
|
+
* this way runs the exact code path it ran before the outbound chain existed. This is the one to
|
|
44
|
+
* reach for; the runtime ones are for the case where the address genuinely is not knowable at
|
|
45
|
+
* build time.
|
|
46
|
+
*/
|
|
47
|
+
class DeployedServiceHost extends HostPolicy {
|
|
48
|
+
resolveBaseUrl(svcName) {
|
|
49
|
+
return core_util_1.ClientRegistry.resolve(svcName);
|
|
50
|
+
}
|
|
51
|
+
builtInFilters() {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
exports.DeployedServiceHost = DeployedServiceHost;
|
|
56
|
+
/**
|
|
57
|
+
* The destination is supplied PER CALL, through
|
|
58
|
+
* `RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, url)`, and the framework's
|
|
59
|
+
* SSRF policy is ON.
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* const partner = factory.createRpcClient(
|
|
63
|
+
* PartnerWebhookApi,
|
|
64
|
+
* new ClientConfig('partner-webhooks', new RuntimeHostFromContext(new DnsAddressResolver())),
|
|
65
|
+
* [new ClientFilterDefinition(500, new HmacSigningFilter(secret))],
|
|
66
|
+
* );
|
|
67
|
+
*
|
|
68
|
+
* for (const webhook of webhooks) {
|
|
69
|
+
* await RequestContext.run(() => {
|
|
70
|
+
* RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);
|
|
71
|
+
* return partner.deliver(envelope);
|
|
72
|
+
* });
|
|
73
|
+
* }
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* `svcName` is still required, and still means something: it is the IDENTITY this client gets as a
|
|
77
|
+
* node on the runtime architecture graph. That is the whole point of routing partner deliveries
|
|
78
|
+
* back through a generated client — the most security-sensitive hop in the system stops being
|
|
79
|
+
* invisible.
|
|
80
|
+
*
|
|
81
|
+
* Endpoints whose auth mode is `@AuthOidc` or `@AuthSharedSecret` are REFUSED at bind time. Both
|
|
82
|
+
* mint a credential for a specific audience/peer that we chose, and there is no honest audience for
|
|
83
|
+
* a host we will not know until the call happens — minting one for a partner's URL would hand them
|
|
84
|
+
* a token. Authenticate a runtime-host call the way a webhook is actually authenticated: a signing
|
|
85
|
+
* filter over the exact bytes.
|
|
86
|
+
*/
|
|
87
|
+
class RuntimeHostFromContext extends HostPolicy {
|
|
88
|
+
addressResolver;
|
|
89
|
+
constructor(addressResolver) {
|
|
90
|
+
super();
|
|
91
|
+
this.addressResolver = addressResolver;
|
|
92
|
+
}
|
|
93
|
+
async resolveBaseUrl(_svcName) {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
builtInFilters() {
|
|
97
|
+
return runtimeHostFilters(this.addressResolver, RuntimeHostFromContext.STRICT);
|
|
98
|
+
}
|
|
99
|
+
assertEndpointSupported(authMeta, methodName, contractName) {
|
|
100
|
+
assertNoServiceCredential(authMeta, methodName, contractName);
|
|
101
|
+
}
|
|
102
|
+
/** HTTPS only, no internal addresses, at most one redirect — each hop re-judged. */
|
|
103
|
+
static STRICT = new SsrfPolicy_1.SsrfPolicy(new Set(['https:']), false, 1, undefined);
|
|
104
|
+
}
|
|
105
|
+
exports.RuntimeHostFromContext = RuntimeHostFromContext;
|
|
106
|
+
/**
|
|
107
|
+
* {@link RuntimeHostFromContext}, with the internal-address refusals SWITCHED OFF and plaintext
|
|
108
|
+
* http allowed — for a local emulator, an on-cluster service, or a test harness whose "partner" is
|
|
109
|
+
* `http://127.0.0.1:9123`.
|
|
110
|
+
*
|
|
111
|
+
* The long name is the feature. This is the permissive branch, so it is a NOUN a reviewer can grep
|
|
112
|
+
* (`grep -rn AllowingInternalAddresses` lists every client that can reach inside the network with a
|
|
113
|
+
* runtime-supplied URL) rather than a boolean, an omitted argument, or an empty allow-list — a
|
|
114
|
+
* widening that reads as an ABSENCE is invisible exactly where it matters most.
|
|
115
|
+
*
|
|
116
|
+
* The `reason` is required and is quoted back in this client's refusal messages, so the
|
|
117
|
+
* justification travels with the decision instead of living in a commit message.
|
|
118
|
+
*/
|
|
119
|
+
class RuntimeHostFromContextAllowingInternalAddresses extends HostPolicy {
|
|
120
|
+
addressResolver;
|
|
121
|
+
policy;
|
|
122
|
+
constructor(
|
|
123
|
+
/** WHY this client may reach internal addresses, in prose. Required. */
|
|
124
|
+
reason, addressResolver) {
|
|
125
|
+
super();
|
|
126
|
+
this.addressResolver = addressResolver;
|
|
127
|
+
this.policy = new SsrfPolicy_1.SsrfPolicy(new Set(['https:', 'http:']), true, 1, reason);
|
|
128
|
+
}
|
|
129
|
+
async resolveBaseUrl(_svcName) {
|
|
130
|
+
return '';
|
|
131
|
+
}
|
|
132
|
+
builtInFilters() {
|
|
133
|
+
return runtimeHostFilters(this.addressResolver, this.policy);
|
|
134
|
+
}
|
|
135
|
+
assertEndpointSupported(authMeta, methodName, contractName) {
|
|
136
|
+
assertNoServiceCredential(authMeta, methodName, contractName);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
exports.RuntimeHostFromContextAllowingInternalAddresses = RuntimeHostFromContextAllowingInternalAddresses;
|
|
140
|
+
/**
|
|
141
|
+
* The two built-ins every runtime-host client gets: settle the destination from the context, then
|
|
142
|
+
* judge it. Shared by both runtime policies so the ORDER cannot drift between them — a guard that
|
|
143
|
+
* ran before the override would be judging the wrong URL.
|
|
144
|
+
*/
|
|
145
|
+
// webpieces-disable no-function-outside-class -- shared construction of two filter definitions; it holds no state a class could own
|
|
146
|
+
function runtimeHostFilters(addressResolver, policy) {
|
|
147
|
+
return [
|
|
148
|
+
new http_client_core_1.ClientFilterDefinition(ContextBaseUrlOverrideFilter_1.BASE_URL_OVERRIDE_PRIORITY, new ContextBaseUrlOverrideFilter_1.ContextBaseUrlOverrideFilter()),
|
|
149
|
+
new http_client_core_1.ClientFilterDefinition(ContextBaseUrlOverrideFilter_1.SSRF_GUARD_PRIORITY, new SsrfGuardFilter_1.SsrfGuardFilter(policy, addressResolver)),
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* @throws Error when the endpoint expects a credential minted for an audience we choose. Shared by
|
|
154
|
+
* both runtime policies for the same reason {@link runtimeHostFilters} is.
|
|
155
|
+
*/
|
|
156
|
+
// webpieces-disable no-function-outside-class -- shared bind-time assertion; it holds no state a class could own
|
|
157
|
+
function assertNoServiceCredential(authMeta, methodName, contractName) {
|
|
158
|
+
const kind = authMeta?.mode?.kind;
|
|
159
|
+
if (kind !== 'oidc' && kind !== 'shared-secret') {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
throw new RuntimeHostErrors_1.RuntimeHostEndpointUnsupportedError(`${contractName}.${methodName} is authenticated with '${kind}', which cannot be used by a ` +
|
|
163
|
+
`runtime-host client. Both mint a credential for a peer WE chose — an OIDC token's audience is ` +
|
|
164
|
+
`the callee's base URL, and a shared secret is one we agreed with a named service — and the ` +
|
|
165
|
+
`destination here is not known until the call happens, so minting either one would hand our ` +
|
|
166
|
+
`credential to whoever registered the URL. Authenticate this hop the way a webhook actually is ` +
|
|
167
|
+
`authenticated: an app filter that signs the exact serialized bytes (ClientRequest.body).`, `${contractName}.${methodName}`);
|
|
168
|
+
}
|
|
169
|
+
//# sourceMappingURL=HostPolicy.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"HostPolicy.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/HostPolicy.ts"],"names":[],"mappings":"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","sourcesContent":["import { AuthMeta, ClientRegistry } from '@webpieces/core-util';\nimport { ClientFilterDefinition } from '@webpieces/http-client-core';\nimport { AddressResolver } from './AddressResolver';\nimport { BASE_URL_OVERRIDE_PRIORITY, ContextBaseUrlOverrideFilter, SSRF_GUARD_PRIORITY } from './ContextBaseUrlOverrideFilter';\nimport { SsrfGuardFilter } from './SsrfGuardFilter';\nimport { SsrfPolicy } from './SsrfPolicy';\nimport { RuntimeHostEndpointUnsupportedError } from './RuntimeHostErrors';\n\n/**\n * WHERE a client's requests go — the second half of a {@link ClientConfig}, and a REQUIRED one.\n *\n * There are exactly two kinds of destination, and conflating them is what made outbound partner\n * webhooks fall out of the typed world entirely:\n *\n * - a service WE DEPLOY, named by `svcName` and resolved through {@link ClientRegistry}\n * → {@link DeployedServiceHost}\n * - a host that is DATA — a URL a partner registered, an OAuth callback, a per-tenant or\n * self-hosted instance — supplied per call through the RequestContext\n * → {@link RuntimeHostFromContext}\n *\n * Naming one is not optional, because the two carry completely different risk. A deployed peer is\n * an address we chose; a runtime host is attacker-influenced data, and the framework owes it an\n * SSRF policy. Making the choice a class the caller writes down means\n * `grep -rn RuntimeHostFromContext` enumerates every client in the codebase that can be re-pointed\n * at all — which is the question a security review actually asks.\n */\nexport abstract class HostPolicy {\n /**\n * The base URL this call STARTS from, before any filter re-points it.\n *\n * For a deployed service that is the whole answer. For a runtime host it is the empty string:\n * there is nothing to resolve at this point, and {@link ContextBaseUrlOverrideFilter} — which\n * this policy also installs — supplies the real destination inside the chain, or refuses. The\n * empty seed is never sent: the override filter throws when the context carries no URL, and the\n * SSRF guard refuses anything that is not an absolute https URL.\n */\n abstract resolveBaseUrl(svcName: string): Promise<string>;\n\n /** The framework filters this policy installs, in addition to whatever the app passed. */\n abstract builtInFilters(): ClientFilterDefinition[];\n\n /**\n * Reject, at BIND time, an endpoint this policy cannot honestly satisfy.\n *\n * @throws Error naming the endpoint and why. The default accepts everything.\n */\n assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string, _contractName: string): void {}\n}\n\n/**\n * TODAY'S BEHAVIOUR, unchanged and byte for byte: the destination is a service we deploy, and its\n * URL comes from {@link ClientRegistry} — a registered mapping, else the installed deriver, else a\n * throw.\n *\n * It installs NO filters, reads no context key, and performs no DNS lookups, so a client written\n * this way runs the exact code path it ran before the outbound chain existed. This is the one to\n * reach for; the runtime ones are for the case where the address genuinely is not knowable at\n * build time.\n */\nexport class DeployedServiceHost extends HostPolicy {\n override resolveBaseUrl(svcName: string): Promise<string> {\n return ClientRegistry.resolve(svcName);\n }\n\n override builtInFilters(): ClientFilterDefinition[] {\n return [];\n }\n}\n\n/**\n * The destination is supplied PER CALL, through\n * `RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, url)`, and the framework's\n * SSRF policy is ON.\n *\n * ```ts\n * const partner = factory.createRpcClient(\n * PartnerWebhookApi,\n * new ClientConfig('partner-webhooks', new RuntimeHostFromContext(new DnsAddressResolver())),\n * [new ClientFilterDefinition(500, new HmacSigningFilter(secret))],\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 * `svcName` is still required, and still means something: it is the IDENTITY this client gets as a\n * node on the runtime architecture graph. That is the whole point of routing partner deliveries\n * back through a generated client — the most security-sensitive hop in the system stops being\n * invisible.\n *\n * Endpoints whose auth mode is `@AuthOidc` or `@AuthSharedSecret` are REFUSED at bind time. Both\n * mint a credential for a specific audience/peer that we chose, and there is no honest audience for\n * a host we will not know until the call happens — minting one for a partner's URL would hand them\n * a token. Authenticate a runtime-host call the way a webhook is actually authenticated: a signing\n * filter over the exact bytes.\n */\nexport class RuntimeHostFromContext extends HostPolicy {\n constructor(private readonly addressResolver: AddressResolver) {\n super();\n }\n\n override async resolveBaseUrl(_svcName: string): Promise<string> {\n return '';\n }\n\n override builtInFilters(): ClientFilterDefinition[] {\n return runtimeHostFilters(this.addressResolver, RuntimeHostFromContext.STRICT);\n }\n\n override assertEndpointSupported(\n authMeta: AuthMeta | undefined,\n methodName: string,\n contractName: string,\n ): void {\n assertNoServiceCredential(authMeta, methodName, contractName);\n }\n\n /** HTTPS only, no internal addresses, at most one redirect — each hop re-judged. */\n private static readonly STRICT = new SsrfPolicy(new Set(['https:']), false, 1, undefined);\n}\n\n/**\n * {@link RuntimeHostFromContext}, with the internal-address refusals SWITCHED OFF and plaintext\n * http allowed — for a local emulator, an on-cluster service, or a test harness whose \"partner\" is\n * `http://127.0.0.1:9123`.\n *\n * The long name is the feature. This is the permissive branch, so it is a NOUN a reviewer can grep\n * (`grep -rn AllowingInternalAddresses` lists every client that can reach inside the network with a\n * runtime-supplied URL) rather than a boolean, an omitted argument, or an empty allow-list — a\n * widening that reads as an ABSENCE is invisible exactly where it matters most.\n *\n * The `reason` is required and is quoted back in this client's refusal messages, so the\n * justification travels with the decision instead of living in a commit message.\n */\nexport class RuntimeHostFromContextAllowingInternalAddresses extends HostPolicy {\n private readonly policy: SsrfPolicy;\n\n constructor(\n /** WHY this client may reach internal addresses, in prose. Required. */\n reason: string,\n private readonly addressResolver: AddressResolver,\n ) {\n super();\n this.policy = new SsrfPolicy(new Set(['https:', 'http:']), true, 1, reason);\n }\n\n override async resolveBaseUrl(_svcName: string): Promise<string> {\n return '';\n }\n\n override builtInFilters(): ClientFilterDefinition[] {\n return runtimeHostFilters(this.addressResolver, this.policy);\n }\n\n override assertEndpointSupported(\n authMeta: AuthMeta | undefined,\n methodName: string,\n contractName: string,\n ): void {\n assertNoServiceCredential(authMeta, methodName, contractName);\n }\n}\n\n/**\n * The two built-ins every runtime-host client gets: settle the destination from the context, then\n * judge it. Shared by both runtime policies so the ORDER cannot drift between them — a guard that\n * ran before the override would be judging the wrong URL.\n */\n// webpieces-disable no-function-outside-class -- shared construction of two filter definitions; it holds no state a class could own\nfunction runtimeHostFilters(addressResolver: AddressResolver, policy: SsrfPolicy): ClientFilterDefinition[] {\n return [\n new ClientFilterDefinition(BASE_URL_OVERRIDE_PRIORITY, new ContextBaseUrlOverrideFilter()),\n new ClientFilterDefinition(SSRF_GUARD_PRIORITY, new SsrfGuardFilter(policy, addressResolver)),\n ];\n}\n\n/**\n * @throws Error when the endpoint expects a credential minted for an audience we choose. Shared by\n * both runtime policies for the same reason {@link runtimeHostFilters} is.\n */\n// webpieces-disable no-function-outside-class -- shared bind-time assertion; it holds no state a class could own\nfunction assertNoServiceCredential(\n authMeta: AuthMeta | undefined,\n methodName: string,\n contractName: string,\n): void {\n const kind = authMeta?.mode?.kind;\n if (kind !== 'oidc' && kind !== 'shared-secret') {\n return;\n }\n throw new RuntimeHostEndpointUnsupportedError(\n `${contractName}.${methodName} is authenticated with '${kind}', which cannot be used by a ` +\n `runtime-host client. Both mint a credential for a peer WE chose — an OIDC token's audience is ` +\n `the callee's base URL, and a shared secret is one we agreed with a named service — and the ` +\n `destination here is not known until the call happens, so minting either one would hand our ` +\n `credential to whoever registered the URL. Authenticate this hop the way a webhook actually is ` +\n `authenticated: an app filter that signs the exact serialized bytes (ClientRequest.body).`,\n `${contractName}.${methodName}`,\n );\n}\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":"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","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"]}
|
package/src/NodeProxyClient.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AuthMeta, 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
5
|
import { ClientConfig } from './ClientConfig';
|
|
6
6
|
/**
|
|
7
7
|
* The server-side {@link ProxyClient}. Everything a browser cannot do lives here: reading the
|
|
@@ -17,8 +17,14 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
17
17
|
private readonly secrets?;
|
|
18
18
|
private config;
|
|
19
19
|
constructor(headers: RequestContextHeaders, gcpOidc: GcpOidc, secrets?: Secrets | undefined);
|
|
20
|
-
/**
|
|
21
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Bind this client to one API contract + target, with the app's outbound filters.
|
|
22
|
+
*
|
|
23
|
+
* `appFilters` is REQUIRED, not defaulted: an empty array is a statement that this client signs
|
|
24
|
+
* nothing and rewrites nothing, and it should be written down rather than inferred from an
|
|
25
|
+
* omitted argument.
|
|
26
|
+
*/
|
|
27
|
+
init(apiPrototype: ApiPrototype<object>, config: ClientConfig, appFilters: ClientFilterDefinition[]): void;
|
|
22
28
|
/**
|
|
23
29
|
* The same chain every client runs — a ClientRegistry mapping, else the installed deriver — but
|
|
24
30
|
* with NODE's fallback: THROW. A server has no "own origin" to fall back to the way a browser
|
|
@@ -28,6 +34,13 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
28
34
|
* read beneath a deriver is memoized process-wide, so only the first call pays.
|
|
29
35
|
*/
|
|
30
36
|
protected resolveBaseUrl(): Promise<string>;
|
|
37
|
+
/**
|
|
38
|
+
* The framework filters this client's {@link HostPolicy} demands — none for a deployed service,
|
|
39
|
+
* the context override plus the SSRF guard for a runtime host. Delegated rather than decided
|
|
40
|
+
* here so the two halves of "where does this go" (resolution and enforcement) cannot drift
|
|
41
|
+
* apart into different policies.
|
|
42
|
+
*/
|
|
43
|
+
protected clientFilters(): ClientFilterDefinition[];
|
|
31
44
|
/**
|
|
32
45
|
* Straight from the RequestContext. Throws when there is no active request scope.
|
|
33
46
|
*
|
|
@@ -45,7 +58,7 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
45
58
|
* `Bearer <oidc>` / `Webpieces <secret>` — which is never a context key, so it cannot leak onto
|
|
46
59
|
* the next hop. Never reads process.env.
|
|
47
60
|
*/
|
|
48
|
-
protected attachOutboundAuth(route: RouteMetadata, baseUrl: string, httpHeaders:
|
|
61
|
+
protected attachOutboundAuth(route: RouteMetadata, baseUrl: string, httpHeaders: Map<string, string>): Promise<void>;
|
|
49
62
|
/**
|
|
50
63
|
* Test-case recording hook (mirror of Java HttpsJsonClientInvokeHandler): if a recorder is
|
|
51
64
|
* travelling in the magic context, capture this outbound call + its result so it becomes a mock
|
|
@@ -59,8 +72,13 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
59
72
|
* from the call path — a logging backend stamps its own fields and never sees this.
|
|
60
73
|
*/
|
|
61
74
|
private recordCall;
|
|
62
|
-
/**
|
|
63
|
-
|
|
75
|
+
/**
|
|
76
|
+
* A server can satisfy every auth mode when it is talking to a peer it CHOSE, so the deployed
|
|
77
|
+
* policy rejects nothing. A runtime-host policy does reject: an OIDC token's audience and a
|
|
78
|
+
* shared secret both name a peer, and a destination that arrives per call has no honest one —
|
|
79
|
+
* see {@link HostPolicy.assertEndpointSupported}.
|
|
80
|
+
*/
|
|
81
|
+
protected assertEndpointSupported(authMeta: AuthMeta | undefined, methodName: string): void;
|
|
64
82
|
/**
|
|
65
83
|
* SERVER-TO-SERVER: a 4xx received from a dependency becomes THIS server's own 500.
|
|
66
84
|
*
|
package/src/NodeProxyClient.js
CHANGED
|
@@ -26,10 +26,16 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
26
26
|
this.gcpOidc = gcpOidc;
|
|
27
27
|
this.secrets = secrets;
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Bind this client to one API contract + target, with the app's outbound filters.
|
|
31
|
+
*
|
|
32
|
+
* `appFilters` is REQUIRED, not defaulted: an empty array is a statement that this client signs
|
|
33
|
+
* nothing and rewrites nothing, and it should be written down rather than inferred from an
|
|
34
|
+
* omitted argument.
|
|
35
|
+
*/
|
|
36
|
+
init(apiPrototype, config, appFilters) {
|
|
31
37
|
this.config = config;
|
|
32
|
-
this.initRoutes(apiPrototype);
|
|
38
|
+
this.initRoutes(apiPrototype, appFilters);
|
|
33
39
|
}
|
|
34
40
|
/**
|
|
35
41
|
* The same chain every client runs — a ClientRegistry mapping, else the installed deriver — but
|
|
@@ -40,7 +46,16 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
40
46
|
* read beneath a deriver is memoized process-wide, so only the first call pays.
|
|
41
47
|
*/
|
|
42
48
|
resolveBaseUrl() {
|
|
43
|
-
return
|
|
49
|
+
return this.config.hostPolicy.resolveBaseUrl(this.config.svcName);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The framework filters this client's {@link HostPolicy} demands — none for a deployed service,
|
|
53
|
+
* the context override plus the SSRF guard for a runtime host. Delegated rather than decided
|
|
54
|
+
* here so the two halves of "where does this go" (resolution and enforcement) cannot drift
|
|
55
|
+
* apart into different policies.
|
|
56
|
+
*/
|
|
57
|
+
clientFilters() {
|
|
58
|
+
return this.config.hostPolicy.builtInFilters();
|
|
44
59
|
}
|
|
45
60
|
/**
|
|
46
61
|
* Straight from the RequestContext. Throws when there is no active request scope.
|
|
@@ -64,7 +79,7 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
64
79
|
async attachOutboundAuth(route, baseUrl, httpHeaders) {
|
|
65
80
|
const mode = route.authMeta?.mode;
|
|
66
81
|
if (mode?.kind === 'oidc') {
|
|
67
|
-
httpHeaders
|
|
82
|
+
httpHeaders.set('Authorization', `Bearer ${await this.gcpOidc.mintIdToken(baseUrl)}`);
|
|
68
83
|
}
|
|
69
84
|
else if (mode?.kind === 'shared-secret') {
|
|
70
85
|
const secret = this.secrets?.get(mode.secretKey);
|
|
@@ -73,7 +88,7 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
73
88
|
}
|
|
74
89
|
// Same header as a JWT/OIDC token, but its OWN scheme, so a secret can never be
|
|
75
90
|
// mistaken for a token nor accepted where one was expected.
|
|
76
|
-
httpHeaders
|
|
91
|
+
httpHeaders.set('Authorization', `Webpieces ${secret}`);
|
|
77
92
|
}
|
|
78
93
|
}
|
|
79
94
|
/**
|
|
@@ -119,8 +134,15 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
119
134
|
throw err;
|
|
120
135
|
}
|
|
121
136
|
}
|
|
122
|
-
/**
|
|
123
|
-
|
|
137
|
+
/**
|
|
138
|
+
* A server can satisfy every auth mode when it is talking to a peer it CHOSE, so the deployed
|
|
139
|
+
* policy rejects nothing. A runtime-host policy does reject: an OIDC token's audience and a
|
|
140
|
+
* shared secret both name a peer, and a destination that arrives per call has no honest one —
|
|
141
|
+
* see {@link HostPolicy.assertEndpointSupported}.
|
|
142
|
+
*/
|
|
143
|
+
assertEndpointSupported(authMeta, methodName) {
|
|
144
|
+
this.config.hostPolicy.assertEndpointSupported(authMeta, methodName, this.contractName());
|
|
145
|
+
}
|
|
124
146
|
/**
|
|
125
147
|
* SERVER-TO-SERVER: a 4xx received from a dependency becomes THIS server's own 500.
|
|
126
148
|
*
|