@webpieces/http-client-node 0.4.700 → 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 +82 -51
- package/package.json +5 -5
- package/src/AddressResolver.d.ts +11 -1
- package/src/AddressResolver.js +12 -2
- package/src/AddressResolver.js.map +1 -1
- package/src/ClientConfig.d.ts +30 -36
- package/src/ClientConfig.js +21 -21
- package/src/ClientConfig.js.map +1 -1
- package/src/ClientHttpFactory.d.ts +30 -18
- package/src/ClientHttpFactory.js +29 -17
- 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/MissingRuntimeBaseUrlError.d.ts +20 -0
- package/src/MissingRuntimeBaseUrlError.js +28 -0
- package/src/MissingRuntimeBaseUrlError.js.map +1 -0
- package/src/NodeProxyClient.d.ts +26 -27
- package/src/NodeProxyClient.js +60 -47
- 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 +11 -0
- package/src/SsrfGuardFilter.js +23 -4
- package/src/SsrfGuardFilter.js.map +1 -1
- package/src/SsrfPolicy.d.ts +39 -45
- package/src/SsrfPolicy.js +49 -34
- package/src/SsrfPolicy.js.map +1 -1
- 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 +13 -10
- package/src/index.js +30 -24
- package/src/index.js.map +1 -1
- package/src/ContextBaseUrlOverrideFilter.d.ts +0 -38
- package/src/ContextBaseUrlOverrideFilter.js +0 -57
- package/src/ContextBaseUrlOverrideFilter.js.map +0 -1
- package/src/HostPolicy.d.ts +0 -118
- package/src/HostPolicy.js +0 -169
- package/src/HostPolicy.js.map +0 -1
- package/src/RuntimeHostErrors.d.ts +0 -42
- package/src/RuntimeHostErrors.js +0 -54
- package/src/RuntimeHostErrors.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ClientHttpFactory.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientHttpFactory.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,oDAAsD;AACtD,0DAAqG;AAErG,
|
|
1
|
+
{"version":3,"file":"ClientHttpFactory.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ClientHttpFactory.ts"],"names":[],"mappings":";;;;AAAA,yCAAmC;AACnC,oDAAsD;AACtD,0DAAqG;AAErG,kEAA+D;AAG/D,uDAAgF;AAEhF,iGAAiG;AACjG,6EAA6E;AAC7E,IAAA,oCAAqB,EAAC,4CAA0B,EAAE,iCAAe,CAAC,CAAC;AAEnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAGI,IAAM,iBAAiB,GAAvB,MAAM,iBAAiB;IAE+B;IADzD,YACyD,mBAA8C;QAA9C,wBAAmB,GAAnB,mBAAmB,CAA2B;IACpG,CAAC;IAEJ;;;;;;;;;;;;;;;;;;;;;OAqBG;IACH,eAAe,CACX,YAA6B,EAC7B,MAAoB,EACpB,OAAuB;QAEvB,sFAAsF;QACtF,wEAAwE;QACxE,MAAM,WAAW,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,EAAE,CAAC;QACnD,WAAW,CAAC,IAAI,CAAC,YAAY,EAAE,MAAM,EAAE,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;QAClF,OAAO,IAAA,mCAAgB,EAAC,YAAY,EAAE,WAAW,CAAC,CAAC;IACvD,CAAC;CACJ,CAAA;AAtCY,8CAAiB;4BAAjB,iBAAiB;IAF7B,IAAA,0BAAc,GAAE;IAChB,IAAA,wCAAyB,GAAE;IAGnB,mBAAA,IAAA,kBAAM,EAAC,4CAA0B,CAAC,CAAA;6CAAuC,uBAAQ;GAF7E,iBAAiB,CAsC7B","sourcesContent":["import { inject } from 'inversify';\nimport { DocumentDesign } from '@webpieces/core-util';\nimport { Provider, bindFrameworkProvider, provideFrameworkSingleton } from '@webpieces/core-context';\nimport type { ApiPrototype } from '@webpieces/http-client-core';\nimport { buildClientProxy } from '@webpieces/http-client-core';\nimport type { ClientFilters } from '@webpieces/http-client-core';\nimport { ClientConfig } from './ClientConfig';\nimport { NODE_PROXY_CLIENT_PROVIDER, NodeProxyClient } from './NodeProxyClient';\n\n// Teach the container how to hand out fresh NodeProxyClients. NodeProxyClient is bound TRANSIENT\n// (@provideFrameworkTransient), so each provider.get() constructs a new one.\nbindFrameworkProvider(NODE_PROXY_CLIENT_PROVIDER, NodeProxyClient);\n\n/**\n * ClientHttpFactory - builds type-safe HTTP clients from API prototypes carrying\n * @ApiPath/@Endpoint decorators. The SERVER-side factory.\n *\n * This is the client-side equivalent of ApiRoutingFactory:\n * - Server routing: ApiRoutingFactory reads decorators -> routes HTTP requests to controllers\n * - Server client: ClientHttpFactory reads decorators -> generates HTTP requests from method calls\n *\n * Inject it and ask for a typed client per contract:\n * ```typescript\n * // same project + region as this container; the URL is derived, you maintain nothing\n * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));\n *\n * // to reach somewhere derivation cannot describe (other region/project, non-Cloud-Run, localhost),\n * // register it once at startup — the client still carries only the svcName:\n * // ClientRegistry.addUrlMapping('legacy', 'https://legacy.corp');\n * const legacy = factory.createRpcClient(LegacyApi, new ClientConfig('legacy'));\n *\n * const response = await server2.fetchValue(req); // inside a RequestContext\n * ```\n *\n * A destination that is DATA rather than deployment — a URL a partner registered — is a FILTER, not\n * a different kind of client. Install `ContextBaseUrlFilter` on the one client that may be\n * re-pointed, and set the URL per call:\n * ```typescript\n * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [\n * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n * ]);\n * ```\n * The SSRF guard arms itself the moment that filter re-points a request, and the contract's\n * `@AuthWebhook(name)` selects the app's bound `WebhookSignerCallback` to sign the exact bytes —\n * neither is something the app registers, orders, or can displace.\n *\n * Every client it builds shares one {@link NodeProxyClient} *shape* but never one instance: the\n * injected `Provider<NodeProxyClient>` hands out a fresh one per contract, which `createRpcClient`\n * then `init`s. Their collaborators (RequestContextHeaders, Secrets) come from the container, so\n * the whole dependency graph is visible in this package's design.html.\n *\n * Unlike @webpieces/http-client-browser this package is Node-only, so the factory IS the inversify\n * entry point and the magic context is read straight from the RequestContext. A call made outside\n * `RequestContext.run(...)` throws rather than silently dropping the trace.\n */\n@DocumentDesign()\n@provideFrameworkSingleton()\nexport class ClientHttpFactory {\n constructor(\n @inject(NODE_PROXY_CLIENT_PROVIDER) private readonly proxyClientProvider: Provider<NodeProxyClient>,\n ) {}\n\n /**\n * Create a type-safe RPC (HTTP) client for one API contract.\n *\n * @param apiPrototype - The API prototype class with @ApiPath/@Endpoint decorators\n * @param config - This client's state: its svcName, which is what `ClientRegistry` resolves\n * @param filters - This client's own OUTBOUND filters, each with the priority it runs at\n * (highest OUTERMOST). They wrap the send, so a filter may rewrite the URL, add or remove\n * headers, log, or replace `ClientRequest.body` — the exact bytes transmitted. What goes\n * here is APP behaviour: url rewriting, headers, logging, and `ContextBaseUrlFilter` when\n * this client's destination arrives per call.\n *\n * OPTIONAL, and omitting it is not a statement about security: the framework's own SSRF\n * guard and credential minter are installed on every client regardless, BENEATH anything\n * passed here, so there is nothing an app can decline by writing nothing.\n *\n * ONE SPELLING PER DECISION. It is a NON-EMPTY tuple, so `createRpcClient(Api, cfg, [])`\n * does not compile: \"this client has no app filters\" is said by omitting the argument, and\n * `[]` would be a second way to say the identical thing. That is the same device\n * {@link JwtRoles}'s `roles` uses, for the same reason — the bad case is deleted by the\n * TYPE rather than left available and discouraged in a docstring. Pinned in\n * {@link CreateRpcClientCompileAssertions}.\n */\n createRpcClient<T extends object>(\n apiPrototype: ApiPrototype<T>,\n config: ClientConfig,\n filters?: ClientFilters,\n ): T {\n // Fresh instance per contract — NodeProxyClient is transient. init() binds it to this\n // contract + target; the collaborators already came from the container.\n const proxyClient = this.proxyClientProvider.get();\n proxyClient.init(apiPrototype, config, filters === undefined ? [] : [...filters]);\n return buildClientProxy(apiPrototype, proxyClient);\n }\n}\n"]}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Filter, Service } from '@webpieces/core-util';
|
|
2
|
+
import { ClientRequest } from '@webpieces/http-client-core';
|
|
3
|
+
import { SsrfPolicy } from './SsrfPolicy';
|
|
4
|
+
/**
|
|
5
|
+
* Reads {@link WebpiecesCoreHeaders.OVERRIDE_BASE_URL} out of the ambient RequestContext and points
|
|
6
|
+
* THIS ONE CALL at it. Ships in the box, and is the whole of the runtime-base-URL feature:
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
|
|
10
|
+
* new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
|
|
11
|
+
* ]);
|
|
12
|
+
*
|
|
13
|
+
* for (const webhook of webhooks) {
|
|
14
|
+
* await RequestContext.run(() => {
|
|
15
|
+
* RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);
|
|
16
|
+
* return partner.deliver(envelope);
|
|
17
|
+
* });
|
|
18
|
+
* }
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* ## INSTALLING IT IS THE OPT-IN
|
|
22
|
+
*
|
|
23
|
+
* There is no client-level setting saying "this one may be re-pointed", because installing this
|
|
24
|
+
* filter IS that statement, written at the one place a reader looks. A client with no
|
|
25
|
+
* `ContextBaseUrlFilter` ignores an ambient `OVERRIDE_BASE_URL` entirely — which is what stops a
|
|
26
|
+
* value set for a partner delivery from silently re-pointing every other client in the same
|
|
27
|
+
* request at the partner's server. `grep -rn ContextBaseUrlFilter` enumerates every client in a
|
|
28
|
+
* codebase that can be re-pointed at all, which is the question a security review actually asks.
|
|
29
|
+
*
|
|
30
|
+
* ## PER-ENDPOINT, if a contract mixes them
|
|
31
|
+
*
|
|
32
|
+
* `request.route` carries `methodName`, `path`, `httpMethod` and `authMeta`, so a subclass can
|
|
33
|
+
* re-point some endpoints of a contract and leave the rest on the configured host, with no API
|
|
34
|
+
* change:
|
|
35
|
+
*
|
|
36
|
+
* ```ts
|
|
37
|
+
* class DeliverOnlyBaseUrlFilter extends ContextBaseUrlFilter {
|
|
38
|
+
* override async filter(request: ClientRequest, next: Service<ClientRequest, Response>) {
|
|
39
|
+
* if (request.route.methodName !== 'deliver') return next.invoke(request);
|
|
40
|
+
* return super.filter(request, next);
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
* ```
|
|
44
|
+
*
|
|
45
|
+
* ## Scope, and why it cannot leak
|
|
46
|
+
*
|
|
47
|
+
* It mutates the per-call {@link ClientRequest} and nothing else. The client is untouched, so the
|
|
48
|
+
* next call through the same client starts from its configured host again; and the context entry is
|
|
49
|
+
* scoped to whatever `RequestContext.run(...)` the caller established, so a fan-out loop that sets
|
|
50
|
+
* a different URL per partner gets exactly the URL it set, per iteration.
|
|
51
|
+
*
|
|
52
|
+
* ## The SSRF guard is NOT registered here
|
|
53
|
+
*
|
|
54
|
+
* Re-pointing arms it by itself — `ClientRequest.pointAtBaseUrl` flips
|
|
55
|
+
* `destinationCameFromData`, and the framework's own guard sits beneath every app filter and reads
|
|
56
|
+
* that. So this filter cannot forget to bring the guard along, and an app cannot install this one
|
|
57
|
+
* without it. The only thing this class carries is WHICH policy the guard applies, and only
|
|
58
|
+
* because the single legitimate relaxation ({@link SsrfTestingPolicy}) belongs at the same
|
|
59
|
+
* construction site as the decision to be re-pointable at all.
|
|
60
|
+
*/
|
|
61
|
+
export declare class ContextBaseUrlFilter extends Filter<ClientRequest, Response> {
|
|
62
|
+
/**
|
|
63
|
+
* What the framework's SSRF guard holds this client's re-pointed URLs to.
|
|
64
|
+
*
|
|
65
|
+
* Defaulted to {@link SsrfPolicy} (the strict one), and that default is the SAFE branch, so the
|
|
66
|
+
* omitted argument can never be the permissive one — the widening has to be typed out, with
|
|
67
|
+
* a reason, as `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>'))`.
|
|
68
|
+
*/
|
|
69
|
+
readonly ssrfPolicy: SsrfPolicy;
|
|
70
|
+
constructor(
|
|
71
|
+
/**
|
|
72
|
+
* What the framework's SSRF guard holds this client's re-pointed URLs to.
|
|
73
|
+
*
|
|
74
|
+
* Defaulted to {@link SsrfPolicy} (the strict one), and that default is the SAFE branch, so the
|
|
75
|
+
* omitted argument can never be the permissive one — the widening has to be typed out, with
|
|
76
|
+
* a reason, as `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>'))`.
|
|
77
|
+
*/
|
|
78
|
+
ssrfPolicy?: SsrfPolicy);
|
|
79
|
+
filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response>;
|
|
80
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ContextBaseUrlFilter = void 0;
|
|
4
|
+
const core_util_1 = require("@webpieces/core-util");
|
|
5
|
+
const core_context_1 = require("@webpieces/core-context");
|
|
6
|
+
const MissingRuntimeBaseUrlError_1 = require("./MissingRuntimeBaseUrlError");
|
|
7
|
+
const SsrfPolicy_1 = require("./SsrfPolicy");
|
|
8
|
+
/**
|
|
9
|
+
* Reads {@link WebpiecesCoreHeaders.OVERRIDE_BASE_URL} out of the ambient RequestContext and points
|
|
10
|
+
* THIS ONE CALL at it. Ships in the box, and is the whole of the runtime-base-URL feature:
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
|
|
14
|
+
* new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
|
|
15
|
+
* ]);
|
|
16
|
+
*
|
|
17
|
+
* for (const webhook of webhooks) {
|
|
18
|
+
* await RequestContext.run(() => {
|
|
19
|
+
* RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);
|
|
20
|
+
* return partner.deliver(envelope);
|
|
21
|
+
* });
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* ## INSTALLING IT IS THE OPT-IN
|
|
26
|
+
*
|
|
27
|
+
* There is no client-level setting saying "this one may be re-pointed", because installing this
|
|
28
|
+
* filter IS that statement, written at the one place a reader looks. A client with no
|
|
29
|
+
* `ContextBaseUrlFilter` ignores an ambient `OVERRIDE_BASE_URL` entirely — which is what stops a
|
|
30
|
+
* value set for a partner delivery from silently re-pointing every other client in the same
|
|
31
|
+
* request at the partner's server. `grep -rn ContextBaseUrlFilter` enumerates every client in a
|
|
32
|
+
* codebase that can be re-pointed at all, which is the question a security review actually asks.
|
|
33
|
+
*
|
|
34
|
+
* ## PER-ENDPOINT, if a contract mixes them
|
|
35
|
+
*
|
|
36
|
+
* `request.route` carries `methodName`, `path`, `httpMethod` and `authMeta`, so a subclass can
|
|
37
|
+
* re-point some endpoints of a contract and leave the rest on the configured host, with no API
|
|
38
|
+
* change:
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* class DeliverOnlyBaseUrlFilter extends ContextBaseUrlFilter {
|
|
42
|
+
* override async filter(request: ClientRequest, next: Service<ClientRequest, Response>) {
|
|
43
|
+
* if (request.route.methodName !== 'deliver') return next.invoke(request);
|
|
44
|
+
* return super.filter(request, next);
|
|
45
|
+
* }
|
|
46
|
+
* }
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* ## Scope, and why it cannot leak
|
|
50
|
+
*
|
|
51
|
+
* It mutates the per-call {@link ClientRequest} and nothing else. The client is untouched, so the
|
|
52
|
+
* next call through the same client starts from its configured host again; and the context entry is
|
|
53
|
+
* scoped to whatever `RequestContext.run(...)` the caller established, so a fan-out loop that sets
|
|
54
|
+
* a different URL per partner gets exactly the URL it set, per iteration.
|
|
55
|
+
*
|
|
56
|
+
* ## The SSRF guard is NOT registered here
|
|
57
|
+
*
|
|
58
|
+
* Re-pointing arms it by itself — `ClientRequest.pointAtBaseUrl` flips
|
|
59
|
+
* `destinationCameFromData`, and the framework's own guard sits beneath every app filter and reads
|
|
60
|
+
* that. So this filter cannot forget to bring the guard along, and an app cannot install this one
|
|
61
|
+
* without it. The only thing this class carries is WHICH policy the guard applies, and only
|
|
62
|
+
* because the single legitimate relaxation ({@link SsrfTestingPolicy}) belongs at the same
|
|
63
|
+
* construction site as the decision to be re-pointable at all.
|
|
64
|
+
*/
|
|
65
|
+
class ContextBaseUrlFilter extends core_util_1.Filter {
|
|
66
|
+
ssrfPolicy;
|
|
67
|
+
constructor(
|
|
68
|
+
/**
|
|
69
|
+
* What the framework's SSRF guard holds this client's re-pointed URLs to.
|
|
70
|
+
*
|
|
71
|
+
* Defaulted to {@link SsrfPolicy} (the strict one), and that default is the SAFE branch, so the
|
|
72
|
+
* omitted argument can never be the permissive one — the widening has to be typed out, with
|
|
73
|
+
* a reason, as `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>'))`.
|
|
74
|
+
*/
|
|
75
|
+
ssrfPolicy = new SsrfPolicy_1.SsrfPolicy()) {
|
|
76
|
+
super();
|
|
77
|
+
this.ssrfPolicy = ssrfPolicy;
|
|
78
|
+
}
|
|
79
|
+
async filter(request, nextFilter) {
|
|
80
|
+
const override = core_context_1.RequestContext.getUntrusted(core_util_1.WebpiecesCoreHeaders.OVERRIDE_BASE_URL);
|
|
81
|
+
if (override === undefined || override === '') {
|
|
82
|
+
throw new MissingRuntimeBaseUrlError_1.MissingRuntimeBaseUrlError(`${request.contractName}.${request.route.methodName} runs behind a ContextBaseUrlFilter, so ` +
|
|
83
|
+
`its destination must be supplied per call, but no ` +
|
|
84
|
+
`WebpiecesCoreHeaders.OVERRIDE_BASE_URL was found in the RequestContext. Set it around ` +
|
|
85
|
+
`the call:\n` +
|
|
86
|
+
` RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);\n` +
|
|
87
|
+
`Refusing rather than falling back to this client's configured service URL is ` +
|
|
88
|
+
`deliberate: a silent fallback would send a partner's payload to one of our own services.`, `${request.contractName}.${request.route.methodName}`);
|
|
89
|
+
}
|
|
90
|
+
request.pointAtBaseUrl(override);
|
|
91
|
+
return nextFilter.invoke(request);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
exports.ContextBaseUrlFilter = ContextBaseUrlFilter;
|
|
95
|
+
//# sourceMappingURL=ContextBaseUrlFilter.js.map
|
|
@@ -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,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
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,15 +16,14 @@ 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);
|
|
23
|
+
constructor(headers: RequestContextHeaders, gcpOidc: GcpOidc, addressResolver: AddressResolver, secrets?: Secrets | undefined, webhookSigner?: WebhookSignerCallback | undefined);
|
|
20
24
|
/**
|
|
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.
|
|
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).
|
|
26
27
|
*/
|
|
27
28
|
init(apiPrototype: ApiPrototype<object>, config: ClientConfig, appFilters: ClientFilterDefinition[]): void;
|
|
28
29
|
/**
|
|
@@ -35,12 +36,26 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
35
36
|
*/
|
|
36
37
|
protected resolveBaseUrl(): Promise<string>;
|
|
37
38
|
/**
|
|
38
|
-
* The framework
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
42
46
|
*/
|
|
43
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;
|
|
44
59
|
/**
|
|
45
60
|
* Straight from the RequestContext. Throws when there is no active request scope.
|
|
46
61
|
*
|
|
@@ -50,15 +65,6 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
50
65
|
* friends instead of shipping headers that endpoint's AuthFilter is obliged to reject.
|
|
51
66
|
*/
|
|
52
67
|
protected outboundContextHeaders(destination: DestinationTrust): Map<string, string>;
|
|
53
|
-
/**
|
|
54
|
-
* Attach the outbound credential for the endpoint's AuthMode: an @AuthOidc bearer minted as
|
|
55
|
-
* this caller's runtime SA (audience = the callee base URL — the server verifies the signature
|
|
56
|
-
* + caller allow-list), or the @AuthSharedSecret(key) value THIS client sends from its bound
|
|
57
|
-
* {@link Secrets}. Both ride in the ONE `Authorization` header under their own scheme —
|
|
58
|
-
* `Bearer <oidc>` / `Webpieces <secret>` — which is never a context key, so it cannot leak onto
|
|
59
|
-
* the next hop. Never reads process.env.
|
|
60
|
-
*/
|
|
61
|
-
protected attachOutboundAuth(route: RouteMetadata, baseUrl: string, httpHeaders: Map<string, string>): Promise<void>;
|
|
62
68
|
/**
|
|
63
69
|
* Test-case recording hook (mirror of Java HttpsJsonClientInvokeHandler): if a recorder is
|
|
64
70
|
* travelling in the magic context, capture this outbound call + its result so it becomes a mock
|
|
@@ -72,13 +78,6 @@ export declare class NodeProxyClient extends ProxyClient {
|
|
|
72
78
|
* from the call path — a logging backend stamps its own fields and never sees this.
|
|
73
79
|
*/
|
|
74
80
|
private recordCall;
|
|
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;
|
|
82
81
|
/**
|
|
83
82
|
* SERVER-TO-SERVER: a 4xx received from a dependency becomes THIS server's own 500.
|
|
84
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,20 +33,21 @@ 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
48
|
/**
|
|
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.
|
|
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).
|
|
35
51
|
*/
|
|
36
52
|
init(apiPrototype, config, appFilters) {
|
|
37
53
|
this.config = config;
|
|
@@ -46,16 +62,40 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
46
62
|
* read beneath a deriver is memoized process-wide, so only the first call pays.
|
|
47
63
|
*/
|
|
48
64
|
resolveBaseUrl() {
|
|
49
|
-
return
|
|
65
|
+
return core_util_1.ClientRegistry.resolve(this.config.svcName);
|
|
50
66
|
}
|
|
51
67
|
/**
|
|
52
|
-
* The framework
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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.
|
|
56
75
|
*/
|
|
57
76
|
clientFilters() {
|
|
58
|
-
return
|
|
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();
|
|
59
99
|
}
|
|
60
100
|
/**
|
|
61
101
|
* Straight from the RequestContext. Throws when there is no active request scope.
|
|
@@ -68,29 +108,6 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
68
108
|
outboundContextHeaders(destination) {
|
|
69
109
|
return this.headers.buildOutboundHeaders(destination);
|
|
70
110
|
}
|
|
71
|
-
/**
|
|
72
|
-
* Attach the outbound credential for the endpoint's AuthMode: an @AuthOidc bearer minted as
|
|
73
|
-
* this caller's runtime SA (audience = the callee base URL — the server verifies the signature
|
|
74
|
-
* + caller allow-list), or the @AuthSharedSecret(key) value THIS client sends from its bound
|
|
75
|
-
* {@link Secrets}. Both ride in the ONE `Authorization` header under their own scheme —
|
|
76
|
-
* `Bearer <oidc>` / `Webpieces <secret>` — which is never a context key, so it cannot leak onto
|
|
77
|
-
* the next hop. Never reads process.env.
|
|
78
|
-
*/
|
|
79
|
-
async attachOutboundAuth(route, baseUrl, httpHeaders) {
|
|
80
|
-
const mode = route.authMeta?.mode;
|
|
81
|
-
if (mode?.kind === 'oidc') {
|
|
82
|
-
httpHeaders.set('Authorization', `Bearer ${await this.gcpOidc.mintIdToken(baseUrl)}`);
|
|
83
|
-
}
|
|
84
|
-
else if (mode?.kind === 'shared-secret') {
|
|
85
|
-
const secret = this.secrets?.get(mode.secretKey);
|
|
86
|
-
if (!secret) {
|
|
87
|
-
throw new Error(`No shared secret configured for @AuthSharedSecret('${mode.secretKey}') endpoint ${route.methodName}`);
|
|
88
|
-
}
|
|
89
|
-
// Same header as a JWT/OIDC token, but its OWN scheme, so a secret can never be
|
|
90
|
-
// mistaken for a token nor accepted where one was expected.
|
|
91
|
-
httpHeaders.set('Authorization', `Webpieces ${secret}`);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
111
|
/**
|
|
95
112
|
* Test-case recording hook (mirror of Java HttpsJsonClientInvokeHandler): if a recorder is
|
|
96
113
|
* travelling in the magic context, capture this outbound call + its result so it becomes a mock
|
|
@@ -134,15 +151,6 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
|
|
|
134
151
|
throw err;
|
|
135
152
|
}
|
|
136
153
|
}
|
|
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
|
-
}
|
|
146
154
|
/**
|
|
147
155
|
* SERVER-TO-SERVER: a 4xx received from a dependency becomes THIS server's own 500.
|
|
148
156
|
*
|
|
@@ -208,11 +216,16 @@ exports.NodeProxyClient = NodeProxyClient = tslib_1.__decorate([
|
|
|
208
216
|
(0, core_context_1.provideFrameworkTransient)(),
|
|
209
217
|
tslib_1.__param(0, (0, inversify_1.inject)(core_context_1.RequestContextHeaders)),
|
|
210
218
|
tslib_1.__param(1, (0, inversify_1.inject)(gcp_identity_1.GcpOidc)),
|
|
211
|
-
tslib_1.__param(2, (0, inversify_1.
|
|
212
|
-
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)),
|
|
213
224
|
tslib_1.__metadata("design:paramtypes", [core_context_1.RequestContextHeaders,
|
|
214
225
|
gcp_identity_1.GcpOidc,
|
|
215
|
-
|
|
226
|
+
AddressResolver_1.AddressResolver,
|
|
227
|
+
core_util_1.Secrets,
|
|
228
|
+
WebhookSignerCallback_1.WebhookSignerCallback])
|
|
216
229
|
], NodeProxyClient);
|
|
217
230
|
/**
|
|
218
231
|
* DI token for the `Provider<NodeProxyClient>` that hands out RPC clients — one per API contract.
|