@webpieces/http-client-node 0.4.778 → 0.4.779

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 CHANGED
@@ -78,21 +78,29 @@ export class PartnerWebhookApi {
78
78
  }
79
79
 
80
80
  const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
81
- new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
81
+ new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
82
82
  ]);
83
83
 
84
84
  for (const webhook of webhooks) {
85
85
  await RequestContext.run(() => {
86
- RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, webhook.url);
86
+ // the COMPLETE stored url, e.g. 'https://hooks.partner.example/in/abc?token=xyz'
87
+ RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);
87
88
  return partner.deliver(envelope);
88
89
  });
89
90
  }
90
91
  ```
91
92
 
92
- - **Installing the filter IS the opt-in.** A client without a `ContextBaseUrlFilter` ignores an
93
- ambient `OVERRIDE_BASE_URL` entirely, so a URL set for a partner delivery cannot re-point every
94
- other client in the same request. `grep -rn ContextBaseUrlFilter` lists every client that can be
95
- re-pointed at all.
93
+ - **Two filters, two shapes of destination.** `ContextFullUrlFilter` + `OVERRIDE_FULL_URL` sends the
94
+ stored url byte for byte — host, path and query — and does NOT append the contract's path; that is
95
+ the partner-webhook shape. `ContextBaseUrlFilter` + `OVERRIDE_BASE_URL` swaps only the host and
96
+ keeps `@ApiPath` + `@Endpoint`; that is the shape for a tenant running our contract at their own
97
+ base URL. The contract path above is therefore documentation (and architecture-graph identity)
98
+ when the full-URL filter is installed.
99
+ - **Installing the filter IS the opt-in.** A client without one ignores the ambient override key
100
+ entirely, so a URL set for a partner delivery cannot re-point every other client in the same
101
+ request. `grep -rnE 'Context(Base|Full)UrlFilter'` lists every client that can be re-pointed at all.
102
+ - **It refuses rather than falls back.** No override in scope throws `MissingRuntimeBaseUrlError`;
103
+ a partner's payload is never sent to this client's configured service URL.
96
104
  - **It cannot leak.** The override lives on the per-call request, never on the client, so one client
97
105
  fans out across N partner URLs and each call goes exactly where its own scope said.
98
106
  - **SSRF is automatic, and it is the ACT of re-pointing that arms it.** A URL that came out of
@@ -103,7 +111,7 @@ for (const webhook of webhooks) {
103
111
  from the transport and re-judged hop by hop, so a partner URL that 302s at `169.254.169.254` is
104
112
  refused rather than obeyed. The one relaxation — testing the partner path against a local fake —
105
113
  has to be said out loud, with a reason:
106
- `new ContextBaseUrlFilter(new SsrfTestingPolicy('local fake in the delivery e2e'))`.
114
+ `new ContextFullUrlFilter(new SsrfTestingPolicy('local fake in the delivery e2e'))`.
107
115
  - **Every auth mode still works.** `@WpAuthOidc` mints for the FINAL base URL, `@WpAuthSharedSecret`
108
116
  sends the value this client holds (N services implementing one contract behind one agreed secret is
109
117
  a real topology), and `@WpAuthWebhook(name)` calls your bound `WebhookSignerCallback`. The minter runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/http-client-node",
3
- "version": "0.4.778",
3
+ "version": "0.4.779",
4
4
  "description": "Server-side HTTP client for webpieces: inversify-wired, reads RequestContext directly, mints OIDC/shared-secret delivery auth, resolves Cloud Run URLs from a service name",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -22,10 +22,10 @@
22
22
  "access": "public"
23
23
  },
24
24
  "dependencies": {
25
- "@webpieces/core-context": "0.4.778",
26
- "@webpieces/core-util": "0.4.778",
27
- "@webpieces/gcp-identity": "0.4.778",
28
- "@webpieces/http-client-core": "0.4.778",
25
+ "@webpieces/core-context": "0.4.779",
26
+ "@webpieces/core-util": "0.4.779",
27
+ "@webpieces/gcp-identity": "0.4.779",
28
+ "@webpieces/http-client-core": "0.4.779",
29
29
  "inversify": "7.10.4",
30
30
  "reflect-metadata": "0.2.2"
31
31
  }
@@ -3,21 +3,28 @@ import { ClientRequest } from '@webpieces/http-client-core';
3
3
  import { SsrfPolicy } from './SsrfPolicy';
4
4
  /**
5
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:
6
+ * THIS ONE CALL at that HOST, KEEPING this route's path: the url sent is
7
+ * `override + @ApiPath + @Endpoint`. That is the shape for a tenant or self-hosted deployment that
8
+ * implements OUR contract at their own base URL:
7
9
  *
8
10
  * ```ts
9
- * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
11
+ * const tenant = factory.createRpcClient(TenantApi, new ClientConfig('tenant-svc'), [
10
12
  * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
11
13
  * ]);
12
14
  *
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
- * }
15
+ * await RequestContext.run(() => {
16
+ * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, tenantRow.baseUrl);
17
+ * return tenant.sync(request);
18
+ * });
19
19
  * ```
20
20
  *
21
+ * ## A partner webhook url is NOT a base URL — use ContextFullUrlFilter
22
+ *
23
+ * A url a partner registered for webhooks is usually a COMPLETE destination
24
+ * (`https://hooks.partner.example/in/abc?token=xyz`). Appending the contract's path to it produces
25
+ * the wrong url. For that, install `ContextFullUrlFilter` and set
26
+ * `WebpiecesCoreHeaders.OVERRIDE_FULL_URL`: the stored url is sent byte for byte.
27
+ *
21
28
  * ## INSTALLING IT IS THE OPT-IN
22
29
  *
23
30
  * There is no client-level setting saying "this one may be re-pointed", because installing this
@@ -7,21 +7,28 @@ const MissingRuntimeBaseUrlError_1 = require("./MissingRuntimeBaseUrlError");
7
7
  const SsrfPolicy_1 = require("./SsrfPolicy");
8
8
  /**
9
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:
10
+ * THIS ONE CALL at that HOST, KEEPING this route's path: the url sent is
11
+ * `override + @ApiPath + @Endpoint`. That is the shape for a tenant or self-hosted deployment that
12
+ * implements OUR contract at their own base URL:
11
13
  *
12
14
  * ```ts
13
- * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
15
+ * const tenant = factory.createRpcClient(TenantApi, new ClientConfig('tenant-svc'), [
14
16
  * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
15
17
  * ]);
16
18
  *
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
- * }
19
+ * await RequestContext.run(() => {
20
+ * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, tenantRow.baseUrl);
21
+ * return tenant.sync(request);
22
+ * });
23
23
  * ```
24
24
  *
25
+ * ## A partner webhook url is NOT a base URL — use ContextFullUrlFilter
26
+ *
27
+ * A url a partner registered for webhooks is usually a COMPLETE destination
28
+ * (`https://hooks.partner.example/in/abc?token=xyz`). Appending the contract's path to it produces
29
+ * the wrong url. For that, install `ContextFullUrlFilter` and set
30
+ * `WebpiecesCoreHeaders.OVERRIDE_FULL_URL`: the stored url is sent byte for byte.
31
+ *
25
32
  * ## INSTALLING IT IS THE OPT-IN
26
33
  *
27
34
  * There is no client-level setting saying "this one may be re-pointed", because installing this
@@ -1 +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"]}
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;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+DG;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 that HOST, KEEPING this route's path: the url sent is\n * `override + @ApiPath + @Endpoint`. That is the shape for a tenant or self-hosted deployment that\n * implements OUR contract at their own base URL:\n *\n * ```ts\n * const tenant = factory.createRpcClient(TenantApi, new ClientConfig('tenant-svc'), [\n * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n * ]);\n *\n * await RequestContext.run(() => {\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_BASE_URL, tenantRow.baseUrl);\n * return tenant.sync(request);\n * });\n * ```\n *\n * ## A partner webhook url is NOT a base URL — use ContextFullUrlFilter\n *\n * A url a partner registered for webhooks is usually a COMPLETE destination\n * (`https://hooks.partner.example/in/abc?token=xyz`). Appending the contract's path to it produces\n * the wrong url. For that, install `ContextFullUrlFilter` and set\n * `WebpiecesCoreHeaders.OVERRIDE_FULL_URL`: the stored url is sent byte for byte.\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,60 @@
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_FULL_URL} out of the ambient RequestContext and sends
6
+ * THIS ONE CALL to that url, VERBATIM — host, path and query exactly as stored, with the contract's
7
+ * own path NOT appended. This is the filter for the common partner-webhook shape, where the partner
8
+ * gave us one opaque url (a database row) rather than implementing our contract at their host:
9
+ *
10
+ * ```ts
11
+ * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
12
+ * new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
13
+ * ]);
14
+ *
15
+ * for (const webhook of webhooks) {
16
+ * await RequestContext.run(() => {
17
+ * // e.g. 'https://hooks.partner.example/in/abc?token=xyz' — sent byte for byte
18
+ * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);
19
+ * return partner.deliver(envelope);
20
+ * });
21
+ * }
22
+ * ```
23
+ *
24
+ * The contract can therefore declare an honest path (`@ApiPath('/webhook')` + `@Endpoint('/deliver')`)
25
+ * for documentation and the architecture graph, knowing it is replaced per call.
26
+ *
27
+ * ## Why a SEPARATE filter from ContextBaseUrlFilter
28
+ *
29
+ * The two answer different questions. `ContextBaseUrlFilter` swaps the HOST and keeps this route's
30
+ * path (a tenant running our contract at their own server); this one swaps the WHOLE url. A flag on
31
+ * one filter would be two behaviours behind one name, and `grep -rn ContextFullUrlFilter` would no
32
+ * longer list exactly the clients whose destination is a stored url.
33
+ *
34
+ * ## Everything else is ContextBaseUrlFilter's contract, unchanged
35
+ *
36
+ * - **Installing it is the opt-in.** A client without it ignores an ambient `OVERRIDE_FULL_URL`.
37
+ * - **It refuses rather than falls back.** No override in scope throws
38
+ * {@link MissingRuntimeBaseUrlError}; it never sends to the client's configured service URL,
39
+ * which would deliver a partner's payload to one of our own services.
40
+ * - **It cannot leak.** Only the per-call {@link ClientRequest} is mutated.
41
+ * - **The SSRF guard is armed by the act of re-pointing.** `ClientRequest.pointAtFullUrl` flips
42
+ * `destinationCameFromData`; this class carries only WHICH policy applies, so the one relaxation
43
+ * (`SsrfTestingPolicy`) is named at the same construction site as the decision to be re-pointable.
44
+ */
45
+ export declare class ContextFullUrlFilter extends Filter<ClientRequest, Response> {
46
+ /**
47
+ * What the framework's SSRF guard holds this client's re-pointed URLs to. Defaulted to the
48
+ * strict {@link SsrfPolicy}, so the omitted argument is never the permissive one — widening
49
+ * has to be typed out as `new ContextFullUrlFilter(new SsrfTestingPolicy('<why>'))`.
50
+ */
51
+ readonly ssrfPolicy: SsrfPolicy;
52
+ constructor(
53
+ /**
54
+ * What the framework's SSRF guard holds this client's re-pointed URLs to. Defaulted to the
55
+ * strict {@link SsrfPolicy}, so the omitted argument is never the permissive one — widening
56
+ * has to be typed out as `new ContextFullUrlFilter(new SsrfTestingPolicy('<why>'))`.
57
+ */
58
+ ssrfPolicy?: SsrfPolicy);
59
+ filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response>;
60
+ }
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ContextFullUrlFilter = 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_FULL_URL} out of the ambient RequestContext and sends
10
+ * THIS ONE CALL to that url, VERBATIM — host, path and query exactly as stored, with the contract's
11
+ * own path NOT appended. This is the filter for the common partner-webhook shape, where the partner
12
+ * gave us one opaque url (a database row) rather than implementing our contract at their host:
13
+ *
14
+ * ```ts
15
+ * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
16
+ * new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
17
+ * ]);
18
+ *
19
+ * for (const webhook of webhooks) {
20
+ * await RequestContext.run(() => {
21
+ * // e.g. 'https://hooks.partner.example/in/abc?token=xyz' — sent byte for byte
22
+ * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);
23
+ * return partner.deliver(envelope);
24
+ * });
25
+ * }
26
+ * ```
27
+ *
28
+ * The contract can therefore declare an honest path (`@ApiPath('/webhook')` + `@Endpoint('/deliver')`)
29
+ * for documentation and the architecture graph, knowing it is replaced per call.
30
+ *
31
+ * ## Why a SEPARATE filter from ContextBaseUrlFilter
32
+ *
33
+ * The two answer different questions. `ContextBaseUrlFilter` swaps the HOST and keeps this route's
34
+ * path (a tenant running our contract at their own server); this one swaps the WHOLE url. A flag on
35
+ * one filter would be two behaviours behind one name, and `grep -rn ContextFullUrlFilter` would no
36
+ * longer list exactly the clients whose destination is a stored url.
37
+ *
38
+ * ## Everything else is ContextBaseUrlFilter's contract, unchanged
39
+ *
40
+ * - **Installing it is the opt-in.** A client without it ignores an ambient `OVERRIDE_FULL_URL`.
41
+ * - **It refuses rather than falls back.** No override in scope throws
42
+ * {@link MissingRuntimeBaseUrlError}; it never sends to the client's configured service URL,
43
+ * which would deliver a partner's payload to one of our own services.
44
+ * - **It cannot leak.** Only the per-call {@link ClientRequest} is mutated.
45
+ * - **The SSRF guard is armed by the act of re-pointing.** `ClientRequest.pointAtFullUrl` flips
46
+ * `destinationCameFromData`; this class carries only WHICH policy applies, so the one relaxation
47
+ * (`SsrfTestingPolicy`) is named at the same construction site as the decision to be re-pointable.
48
+ */
49
+ class ContextFullUrlFilter extends core_util_1.Filter {
50
+ ssrfPolicy;
51
+ constructor(
52
+ /**
53
+ * What the framework's SSRF guard holds this client's re-pointed URLs to. Defaulted to the
54
+ * strict {@link SsrfPolicy}, so the omitted argument is never the permissive one — widening
55
+ * has to be typed out as `new ContextFullUrlFilter(new SsrfTestingPolicy('<why>'))`.
56
+ */
57
+ ssrfPolicy = new SsrfPolicy_1.SsrfPolicy()) {
58
+ super();
59
+ this.ssrfPolicy = ssrfPolicy;
60
+ }
61
+ async filter(request, nextFilter) {
62
+ const override = core_context_1.RequestContext.getUntrusted(core_util_1.WebpiecesCoreHeaders.OVERRIDE_FULL_URL);
63
+ if (override === undefined || override === '') {
64
+ throw new MissingRuntimeBaseUrlError_1.MissingRuntimeBaseUrlError(`${request.contractName}.${request.route.methodName} runs behind a ContextFullUrlFilter, so ` +
65
+ `its complete destination url must be supplied per call, but no ` +
66
+ `WebpiecesCoreHeaders.OVERRIDE_FULL_URL was found in the RequestContext. Set it around ` +
67
+ `the call:\n` +
68
+ ` RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);\n` +
69
+ `Refusing rather than falling back to this client's configured service URL is ` +
70
+ `deliberate: a silent fallback would send a partner's payload to one of our own services.`, `${request.contractName}.${request.route.methodName}`);
71
+ }
72
+ request.pointAtFullUrl(override);
73
+ return nextFilter.invoke(request);
74
+ }
75
+ }
76
+ exports.ContextFullUrlFilter = ContextFullUrlFilter;
77
+ //# sourceMappingURL=ContextFullUrlFilter.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ContextFullUrlFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/ContextFullUrlFilter.ts"],"names":[],"mappings":";;;AAAA,oDAA6E;AAC7E,0DAAyD;AAEzD,6EAA0E;AAC1E,6CAA0C;AAE1C;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,MAAa,oBAAqB,SAAQ,kBAA+B;IAOxD;IANb;IACI;;;;OAIG;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,iEAAiE;gBACjE,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;AA7BD,oDA6BC","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_FULL_URL} out of the ambient RequestContext and sends\n * THIS ONE CALL to that url, VERBATIM — host, path and query exactly as stored, with the contract's\n * own path NOT appended. This is the filter for the common partner-webhook shape, where the partner\n * gave us one opaque url (a database row) rather than implementing our contract at their host:\n *\n * ```ts\n * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [\n * new ClientFilterDefinition(1000, new ContextFullUrlFilter()),\n * ]);\n *\n * for (const webhook of webhooks) {\n * await RequestContext.run(() => {\n * // e.g. 'https://hooks.partner.example/in/abc?token=xyz' — sent byte for byte\n * RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_URL, webhook.url);\n * return partner.deliver(envelope);\n * });\n * }\n * ```\n *\n * The contract can therefore declare an honest path (`@ApiPath('/webhook')` + `@Endpoint('/deliver')`)\n * for documentation and the architecture graph, knowing it is replaced per call.\n *\n * ## Why a SEPARATE filter from ContextBaseUrlFilter\n *\n * The two answer different questions. `ContextBaseUrlFilter` swaps the HOST and keeps this route's\n * path (a tenant running our contract at their own server); this one swaps the WHOLE url. A flag on\n * one filter would be two behaviours behind one name, and `grep -rn ContextFullUrlFilter` would no\n * longer list exactly the clients whose destination is a stored url.\n *\n * ## Everything else is ContextBaseUrlFilter's contract, unchanged\n *\n * - **Installing it is the opt-in.** A client without it ignores an ambient `OVERRIDE_FULL_URL`.\n * - **It refuses rather than falls back.** No override in scope throws\n * {@link MissingRuntimeBaseUrlError}; it never sends to the client's configured service URL,\n * which would deliver a partner's payload to one of our own services.\n * - **It cannot leak.** Only the per-call {@link ClientRequest} is mutated.\n * - **The SSRF guard is armed by the act of re-pointing.** `ClientRequest.pointAtFullUrl` flips\n * `destinationCameFromData`; this class carries only WHICH policy applies, so the one relaxation\n * (`SsrfTestingPolicy`) is named at the same construction site as the decision to be re-pointable.\n */\nexport class ContextFullUrlFilter extends Filter<ClientRequest, Response> {\n constructor(\n /**\n * What the framework's SSRF guard holds this client's re-pointed URLs to. Defaulted to the\n * strict {@link SsrfPolicy}, so the omitted argument is never the permissive one — widening\n * has to be typed out as `new ContextFullUrlFilter(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_FULL_URL);\n if (override === undefined || override === '') {\n throw new MissingRuntimeBaseUrlError(\n `${request.contractName}.${request.route.methodName} runs behind a ContextFullUrlFilter, so ` +\n `its complete destination url must be supplied per call, but no ` +\n `WebpiecesCoreHeaders.OVERRIDE_FULL_URL was found in the RequestContext. Set it around ` +\n `the call:\\n` +\n ` RequestContext.putUntrusted(WebpiecesCoreHeaders.OVERRIDE_FULL_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.pointAtFullUrl(override);\n return nextFilter.invoke(request);\n }\n}\n"]}
@@ -23,6 +23,7 @@ const core_util_1 = require("@webpieces/core-util");
23
23
  const http_client_core_1 = require("@webpieces/http-client-core");
24
24
  const ClientConfig_1 = require("./ClientConfig");
25
25
  const ContextBaseUrlFilter_1 = require("./ContextBaseUrlFilter");
26
+ const ContextFullUrlFilter_1 = require("./ContextFullUrlFilter");
26
27
  /** A second app filter, so the conditional-build case below has two branches like a real one. */
27
28
  class OutboundLogFilter extends core_util_1.Filter {
28
29
  filter(request, next) {
@@ -48,7 +49,7 @@ class CreateRpcClientCompileAssertions {
48
49
  severalFilters() {
49
50
  factory.createRpcClient(someApi, new ClientConfig_1.ClientConfig('partner-webhooks'), [
50
51
  new http_client_core_1.ClientFilterDefinition(1000, new ContextBaseUrlFilter_1.ContextBaseUrlFilter()),
51
- new http_client_core_1.ClientFilterDefinition(500, new ContextBaseUrlFilter_1.ContextBaseUrlFilter()),
52
+ new http_client_core_1.ClientFilterDefinition(500, new ContextFullUrlFilter_1.ContextFullUrlFilter()),
52
53
  ]);
53
54
  }
54
55
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"CreateRpcClientCompileAssertions.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/CreateRpcClientCompileAssertions.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;GAkBG;;AAEH,oDAAuD;AACvD,kEAAoF;AACpF,iDAA8C;AAE9C,iEAA8D;AAQ9D,iGAAiG;AACjG,MAAM,iBAAkB,SAAQ,kBAA+B;IAClD,MAAM,CAAC,OAAsB,EAAE,IAAsC;QAC1E,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAChC,CAAC;CACJ;AAED,MAAM,gCAAgC;IAClC,qDAAqD;IACrD,SAAS;QACL,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,SAAS,CAAC,CAAC,CAAC;IAClE,CAAC;IAED,+FAA+F;IAC/F,UAAU;QACN,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC,CAAC;IACtE,CAAC;IAED,sBAAsB;IACtB,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,2BAA2B;IAC3B,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;;;;OAIG;IACH,sBAAsB;QAClB,MAAM,OAAO,GAA6B,EAAE,CAAC;QAC7C,IAAI,SAAS;YAAE,OAAO,CAAC,IAAI,CAAC,IAAI,yCAAsB,CAAC,IAAI,EAAE,IAAI,2CAAoB,EAAE,CAAC,CAAC,CAAC;QAC1F,IAAI,OAAO;YAAE,OAAO,CAAC,IAAI,CAAC,IAAI,yCAAsB,CAAC,GAAG,EAAE,IAAI,iBAAiB,EAAE,CAAC,CAAC,CAAC;QACpF,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC,CAAC;IACpF,CAAC;CACJ;AAED,KAAK,gCAAgC,CAAC","sourcesContent":["/**\n * COMPILE-TIME assertions that `createRpcClient`'s filters argument is GENUINELY optional.\n *\n * The argument is `readonly ClientFilterDefinition[] | undefined`, so all three ways an app says\n * \"here are this client's filters\" compile: omit it, pass `[]`, or pass a mutable\n * `ClientFilterDefinition[]` that was declared empty and pushed to under `if`s. That last one is the\n * whole reason this file exists — it is how a real app builds a filter list once the list is not a\n * hardcoded literal, and a non-empty tuple parameter rejects it, because the declared type of a\n * conditionally-populated local cannot be non-empty. The cure would be a cast at every call site,\n * which is worse than the case the tuple was protecting against.\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 type-level assertion in a spec is inert and the\n * suite passes whether or not the signature still admits these calls. Here it is compiled by the\n * build: if the signature is ever re-narrowed, tsc fails on the line below that stops compiling.\n * That 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 { Filter, Service } from '@webpieces/core-util';\nimport { ClientFilterDefinition, ClientRequest } 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;\ndeclare const perTenant: boolean;\ndeclare const verbose: boolean;\n\n/** A second app filter, so the conditional-build case below has two branches like a real one. */\nclass OutboundLogFilter extends Filter<ClientRequest, Response> {\n override filter(request: ClientRequest, next: Service<ClientRequest, Response>): Promise<Response> {\n return next.invoke(request);\n }\n}\n\nclass CreateRpcClientCompileAssertions {\n /** No app filters, said by omitting the argument. */\n noFilters(): void {\n factory.createRpcClient(someApi, new ClientConfig('server2'));\n }\n\n /** No app filters, said with an empty array — the same thing, normalized to the same value. */\n emptyArray(): 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 * THE CASE THIS CHANGE EXISTS FOR: a MUTABLE `ClientFilterDefinition[]`, declared empty and\n * pushed to conditionally. Its declared type is not, and cannot be, non-empty; a mutable array\n * is assignable to the `readonly` parameter, so this compiles with no cast.\n */\n conditionallyBuiltList(): void {\n const filters: ClientFilterDefinition[] = [];\n if (perTenant) filters.push(new ClientFilterDefinition(1000, new ContextBaseUrlFilter()));\n if (verbose) filters.push(new ClientFilterDefinition(500, new OutboundLogFilter()));\n factory.createRpcClient(someApi, new ClientConfig('partner-webhooks'), filters);\n }\n}\n\nvoid CreateRpcClientCompileAssertions;\n"]}
1
+ {"version":3,"file":"CreateRpcClientCompileAssertions.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/CreateRpcClientCompileAssertions.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;GAkBG;;AAEH,oDAAuD;AACvD,kEAAoF;AACpF,iDAA8C;AAE9C,iEAA8D;AAC9D,iEAA8D;AAQ9D,iGAAiG;AACjG,MAAM,iBAAkB,SAAQ,kBAA+B;IAClD,MAAM,CAAC,OAAsB,EAAE,IAAsC;QAC1E,OAAO,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IAChC,CAAC;CACJ;AAED,MAAM,gCAAgC;IAClC,qDAAqD;IACrD,SAAS;QACL,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,SAAS,CAAC,CAAC,CAAC;IAClE,CAAC;IAED,+FAA+F;IAC/F,UAAU;QACN,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC,CAAC;IACtE,CAAC;IAED,sBAAsB;IACtB,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,2BAA2B;IAC3B,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;;;;OAIG;IACH,sBAAsB;QAClB,MAAM,OAAO,GAA6B,EAAE,CAAC;QAC7C,IAAI,SAAS;YAAE,OAAO,CAAC,IAAI,CAAC,IAAI,yCAAsB,CAAC,IAAI,EAAE,IAAI,2CAAoB,EAAE,CAAC,CAAC,CAAC;QAC1F,IAAI,OAAO;YAAE,OAAO,CAAC,IAAI,CAAC,IAAI,yCAAsB,CAAC,GAAG,EAAE,IAAI,iBAAiB,EAAE,CAAC,CAAC,CAAC;QACpF,OAAO,CAAC,eAAe,CAAC,OAAO,EAAE,IAAI,2BAAY,CAAC,kBAAkB,CAAC,EAAE,OAAO,CAAC,CAAC;IACpF,CAAC;CACJ;AAED,KAAK,gCAAgC,CAAC","sourcesContent":["/**\n * COMPILE-TIME assertions that `createRpcClient`'s filters argument is GENUINELY optional.\n *\n * The argument is `readonly ClientFilterDefinition[] | undefined`, so all three ways an app says\n * \"here are this client's filters\" compile: omit it, pass `[]`, or pass a mutable\n * `ClientFilterDefinition[]` that was declared empty and pushed to under `if`s. That last one is the\n * whole reason this file exists — it is how a real app builds a filter list once the list is not a\n * hardcoded literal, and a non-empty tuple parameter rejects it, because the declared type of a\n * conditionally-populated local cannot be non-empty. The cure would be a cast at every call site,\n * which is worse than the case the tuple was protecting against.\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 type-level assertion in a spec is inert and the\n * suite passes whether or not the signature still admits these calls. Here it is compiled by the\n * build: if the signature is ever re-narrowed, tsc fails on the line below that stops compiling.\n * That 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 { Filter, Service } from '@webpieces/core-util';\nimport { ClientFilterDefinition, ClientRequest } from '@webpieces/http-client-core';\nimport { ClientConfig } from './ClientConfig';\nimport { ClientHttpFactory } from './ClientHttpFactory';\nimport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\nimport { ContextFullUrlFilter } from './ContextFullUrlFilter';\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;\ndeclare const perTenant: boolean;\ndeclare const verbose: boolean;\n\n/** A second app filter, so the conditional-build case below has two branches like a real one. */\nclass OutboundLogFilter extends Filter<ClientRequest, Response> {\n override filter(request: ClientRequest, next: Service<ClientRequest, Response>): Promise<Response> {\n return next.invoke(request);\n }\n}\n\nclass CreateRpcClientCompileAssertions {\n /** No app filters, said by omitting the argument. */\n noFilters(): void {\n factory.createRpcClient(someApi, new ClientConfig('server2'));\n }\n\n /** No app filters, said with an empty array — the same thing, normalized to the same value. */\n emptyArray(): 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 ContextFullUrlFilter()),\n ]);\n }\n\n /**\n * THE CASE THIS CHANGE EXISTS FOR: a MUTABLE `ClientFilterDefinition[]`, declared empty and\n * pushed to conditionally. Its declared type is not, and cannot be, non-empty; a mutable array\n * is assignable to the `readonly` parameter, so this compiles with no cast.\n */\n conditionallyBuiltList(): void {\n const filters: ClientFilterDefinition[] = [];\n if (perTenant) filters.push(new ClientFilterDefinition(1000, new ContextBaseUrlFilter()));\n if (verbose) filters.push(new ClientFilterDefinition(500, new OutboundLogFilter()));\n factory.createRpcClient(someApi, new ClientConfig('partner-webhooks'), filters);\n }\n}\n\nvoid CreateRpcClientCompileAssertions;\n"]}
@@ -1,7 +1,8 @@
1
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.
2
+ * A client carrying a `ContextBaseUrlFilter` or `ContextFullUrlFilter` was called with no
3
+ * `WebpiecesCoreHeaders.OVERRIDE_BASE_URL` / `OVERRIDE_FULL_URL` (respectively) in the ambient
4
+ * RequestContext, so this call has nowhere to go. ONE type for both filters, because a delivery
5
+ * worker's answer is identical either way: we were misconfigured.
5
6
  *
6
7
  * It is a THROW rather than a fallback to the client's configured service URL, deliberately: a
7
8
  * silent fallback would send a partner's payload to one of our own services, which is a worse
@@ -2,9 +2,10 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MissingRuntimeBaseUrlError = void 0;
4
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.
5
+ * A client carrying a `ContextBaseUrlFilter` or `ContextFullUrlFilter` was called with no
6
+ * `WebpiecesCoreHeaders.OVERRIDE_BASE_URL` / `OVERRIDE_FULL_URL` (respectively) in the ambient
7
+ * RequestContext, so this call has nowhere to go. ONE type for both filters, because a delivery
8
+ * worker's answer is identical either way: we were misconfigured.
8
9
  *
9
10
  * It is a THROW rather than a fallback to the client's configured service URL, deliberately: a
10
11
  * silent fallback would send a partner's payload to one of our own services, which is a worse
@@ -1 +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"]}
1
+ {"version":3,"file":"MissingRuntimeBaseUrlError.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/MissingRuntimeBaseUrlError.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;GAaG;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 `ContextBaseUrlFilter` or `ContextFullUrlFilter` was called with no\n * `WebpiecesCoreHeaders.OVERRIDE_BASE_URL` / `OVERRIDE_FULL_URL` (respectively) in the ambient\n * RequestContext, so this call has nowhere to go. ONE type for both filters, because a delivery\n * worker's answer is identical either way: we were misconfigured.\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"]}
@@ -56,7 +56,8 @@ export declare class NodeProxyClient extends ProxyClient {
56
56
  /**
57
57
  * WHICH policy the guard applies when something does re-point this client.
58
58
  *
59
- * Read off an installed {@link ContextBaseUrlFilter}, because that filter is where an app says
59
+ * Read off an installed {@link ContextBaseUrlFilter} or {@link ContextFullUrlFilter}, because
60
+ * that filter is where an app says
60
61
  * "this client may be re-pointed", and the single legitimate relaxation
61
62
  * ({@link SsrfTestingPolicy}) belongs at the same construction site as that decision rather
62
63
  * than in a second place a reader has to correlate. No such filter — or one built with the
@@ -13,6 +13,7 @@ const gcp_identity_1 = require("@webpieces/gcp-identity");
13
13
  const http_client_core_1 = require("@webpieces/http-client-core");
14
14
  const AddressResolver_1 = require("./AddressResolver");
15
15
  const ContextBaseUrlFilter_1 = require("./ContextBaseUrlFilter");
16
+ const ContextFullUrlFilter_1 = require("./ContextFullUrlFilter");
16
17
  const OutboundAuthFilter_1 = require("./OutboundAuthFilter");
17
18
  const SsrfGuardFilter_1 = require("./SsrfGuardFilter");
18
19
  const SsrfPolicy_1 = require("./SsrfPolicy");
@@ -148,7 +149,8 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
148
149
  /**
149
150
  * WHICH policy the guard applies when something does re-point this client.
150
151
  *
151
- * Read off an installed {@link ContextBaseUrlFilter}, because that filter is where an app says
152
+ * Read off an installed {@link ContextBaseUrlFilter} or {@link ContextFullUrlFilter}, because
153
+ * that filter is where an app says
152
154
  * "this client may be re-pointed", and the single legitimate relaxation
153
155
  * ({@link SsrfTestingPolicy}) belongs at the same construction site as that decision rather
154
156
  * than in a second place a reader has to correlate. No such filter — or one built with the
@@ -158,8 +160,9 @@ let NodeProxyClient = class NodeProxyClient extends http_client_core_1.ProxyClie
158
160
  ssrfPolicy() {
159
161
  for (const definition of this.appFilters) {
160
162
  const filter = definition.filter;
161
- if (filter instanceof ContextBaseUrlFilter_1.ContextBaseUrlFilter)
163
+ if (filter instanceof ContextBaseUrlFilter_1.ContextBaseUrlFilter || filter instanceof ContextFullUrlFilter_1.ContextFullUrlFilter) {
162
164
  return filter.ssrfPolicy;
165
+ }
163
166
  }
164
167
  return new SsrfPolicy_1.SsrfPolicy();
165
168
  }
@@ -1 +1 @@
1
- {"version":3,"file":"NodeProxyClient.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/NodeProxyClient.ts"],"names":[],"mappings":"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","sourcesContent":["import { inject, optional } from 'inversify';\nimport { once } from 'node:events';\nimport { request as httpRequest, IncomingMessage } from 'node:http';\nimport { request as httpsRequest } from 'node:https';\nimport { Readable } from 'node:stream';\nimport {\n ClientRegistry,\n DestinationTrust,\n ApiImplementationError,\n RecordedEndpoint,\n RecordedError,\n LogApiCallImpl,\n RouteMetadata,\n Secrets,\n SECRETS,\n TestCaseRecorder,\n toError,\n} from '@webpieces/core-util';\nimport {\n RequestContext,\n RequestContextApiCallContext,\n RequestContextHeaders,\n provideFrameworkTransient,\n} from '@webpieces/core-context';\nimport { GcpOidc } from '@webpieces/gcp-identity';\nimport {\n ApiPrototype,\n ByteReadableStream,\n ClientRequest,\n ClientFilterDefinition,\n ProxyClient,\n TranslatedFailure,\n} from '@webpieces/http-client-core';\nimport { AddressResolver } from './AddressResolver';\nimport { ClientConfig } from './ClientConfig';\nimport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\nimport { OutboundAuthFilter } from './OutboundAuthFilter';\nimport { SsrfGuardFilter } from './SsrfGuardFilter';\nimport { SsrfPolicy } from './SsrfPolicy';\nimport { WEBHOOK_SIGNER_CALLBACK, WebhookSignerCallback } from './WebhookSignerCallback';\n\n/**\n * The two framework built-ins' priorities, RELATIVE TO EACH OTHER and to nothing else — they are\n * ordered beneath every app filter structurally, not by number (see `ProxyClient.initRoutes`).\n *\n * The guard is OUTSIDE the minter deliberately: a destination that is going to be refused must be\n * refused BEFORE a credential is minted for it, so a hostile URL never causes a token to exist.\n */\nconst SSRF_GUARD_PRIORITY = 900;\nconst OUTBOUND_AUTH_PRIORITY = 800;\n\n/**\n * The server-side {@link ProxyClient}. Everything a browser cannot do lives here: reading the\n * ambient RequestContext, minting OIDC tokens, holding shared secrets, and recording test cases.\n *\n * TRANSIENT on purpose. Every `createRpcClient(api, config)` needs its own instance, because `init()`\n * binds one instance to exactly one API contract and one target. {@link ProxyClientProvider} hands\n * them out — see its doc.\n */\n@provideFrameworkTransient()\nexport class NodeProxyClient extends ProxyClient {\n private config!: ClientConfig;\n\n constructor(\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(RequestContextHeaders) private readonly headers: RequestContextHeaders,\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(GcpOidc) private readonly gcpOidc: GcpOidc,\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(AddressResolver) private readonly addressResolver: AddressResolver,\n // @optional: only @WpAuthSharedSecret endpoints need it; the client sends its bound value.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @optional() @inject(SECRETS) private readonly secrets?: Secrets,\n // @optional: only @WpAuthWebhook endpoints need it, and an unbound one makes them THROW\n // rather than deliver unsigned — see WebhookSignerCallback.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @optional()\n @inject(WEBHOOK_SIGNER_CALLBACK)\n private readonly webhookSigner?: WebhookSignerCallback,\n ) {\n // This package is node-only and already depends on core-context, so it builds the\n // RequestContext-backed ApiCallContext itself. No startup install, and therefore nothing a\n // non-webpieces host (plain NestJS/Express) can forget.\n super(new LogApiCallImpl(new RequestContextApiCallContext()));\n }\n\n /**\n * Bind this client to one API contract + target, with the app's outbound filters (url\n * rewriting, header editing, logging, per-call re-pointing).\n */\n init(\n apiPrototype: ApiPrototype<object>,\n config: ClientConfig,\n appFilters: ClientFilterDefinition[],\n ): void {\n this.config = config;\n this.initRoutes(apiPrototype, appFilters);\n }\n\n /**\n * The same chain every client runs — a ClientRegistry mapping, else the installed deriver — but\n * with NODE's fallback: THROW. A server has no \"own origin\" to fall back to the way a browser\n * does, so an unresolvable peer is a setup bug and must fail loudly (the error names the fixes).\n *\n * Resolved per call, never at construction, so building a client stays synchronous. Any metadata\n * read beneath a deriver is memoized process-wide, so only the first call pays.\n */\n protected override resolveBaseUrl(): Promise<string> {\n return ClientRegistry.resolve(this.config.svcName);\n }\n\n /** Node/undici supports a live ReadableStream body when fetch is given `duplex: 'half'`. */\n protected override supportsConcurrentDuplexFetch(): boolean {\n return true;\n }\n\n /**\n * Node's native HTTP stream is genuinely concurrent: unlike browser Fetch, an early response\n * does not cancel the still-open upload. The returned Web Response keeps parsing isomorphic.\n */\n protected override sendStreamingTransport(\n request: ClientRequest,\n signal: AbortSignal,\n body: ByteReadableStream,\n ): Promise<Response> {\n return new Promise<Response>(\n (resolve: (response: Response) => void, reject: (error: Error) => void) => {\n const url = new URL(request.url);\n const send = url.protocol === 'https:' ? httpsRequest : httpRequest;\n const outgoing = send(\n url,\n {\n method: 'POST',\n headers: request.headersAsRecord(),\n signal,\n },\n (incoming: IncomingMessage): void => {\n const headers = new Headers();\n for (const [name, value] of Object.entries(incoming.headers)) {\n if (Array.isArray(value)) {\n for (const item of value) headers.append(name, item);\n } else if (value !== undefined) {\n headers.set(name, value);\n }\n }\n const responseBody = Readable.toWeb(incoming) as ReadableStream<Uint8Array>;\n resolve(\n new Response(responseBody, {\n status: incoming.statusCode ?? 500,\n statusText: incoming.statusMessage,\n headers,\n }),\n );\n },\n );\n outgoing.once('error', reject);\n // webpieces-disable no-any-unknown -- a native socket/write rejection is normalized by toError\n void this.pumpStreamingRequest(body, outgoing).catch((err: unknown) => {\n const error = toError(err);\n outgoing.destroy(error);\n });\n },\n );\n }\n\n private async pumpStreamingRequest(\n body: ByteReadableStream,\n outgoing: import('node:http').ClientRequest,\n ): Promise<void> {\n const reader = body.getReader();\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- socket errors reject writer acknowledgements\n try {\n for (;;) {\n const part = await reader.read();\n if (part.done) break;\n if (!outgoing.write(part.value)) await once(outgoing, 'drain');\n }\n outgoing.end();\n } finally {\n reader.releaseLock();\n }\n }\n\n /**\n * The two framework built-ins, installed on EVERY client this package builds and ordered\n * beneath every app filter by {@link ProxyClient.initRoutes}.\n *\n * They are unconditional rather than opt-in because neither costs anything on the path that\n * does not need it: the SSRF guard steps aside when nothing re-pointed the request, and the\n * auth filter does nothing for a `@WpAuthPublic` endpoint. An app therefore cannot forget to install\n * the guard on the one client that takes runtime URLs — the ACT of re-pointing is what arms it.\n */\n protected override clientFilters(): ClientFilterDefinition[] {\n return [\n new ClientFilterDefinition(\n SSRF_GUARD_PRIORITY,\n new SsrfGuardFilter(this.ssrfPolicy(), this.addressResolver),\n ),\n new ClientFilterDefinition(\n OUTBOUND_AUTH_PRIORITY,\n new OutboundAuthFilter(this.gcpOidc, this.secrets, this.webhookSigner),\n ),\n ];\n }\n\n /**\n * WHICH policy the guard applies when something does re-point this client.\n *\n * Read off an installed {@link ContextBaseUrlFilter}, because that filter is where an app says\n * \"this client may be re-pointed\", and the single legitimate relaxation\n * ({@link SsrfTestingPolicy}) belongs at the same construction site as that decision rather\n * than in a second place a reader has to correlate. No such filter — or one built with the\n * default — means {@link SsrfPolicy} (the strict one), so the safe answer is what an app gets by saying\n * nothing.\n */\n private ssrfPolicy(): SsrfPolicy {\n for (const definition of this.appFilters) {\n const filter = definition.filter;\n if (filter instanceof ContextBaseUrlFilter) return filter.ssrfPolicy;\n }\n return new SsrfPolicy();\n }\n\n /**\n * Straight from the RequestContext. Throws when there is no active request scope.\n *\n * `destination` rides through unchanged: this is the ONE client that can legitimately propagate a\n * verified identity, and it does so exactly when the callee will authenticate us (@WpAuthOidc /\n * @WpAuthSharedSecret). Calling a peer's @WpAuthPublic or @WpAuthJwt endpoint now omits `x-user-id` and\n * friends instead of shipping headers that endpoint's AuthFilter is obliged to reject.\n */\n protected override outboundContextHeaders(destination: DestinationTrust): Map<string, string> {\n return this.headers.buildOutboundHeaders(destination);\n }\n\n /**\n * Test-case recording hook (mirror of Java HttpsJsonClientInvokeHandler): if a recorder is\n * travelling in the magic context, capture this outbound call + its result so it becomes a mock\n * in the generated test. Absent a recorder this is exactly the base behavior.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n protected override async execute(\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n // Preserve the context precondition before logging, which also requires an active scope.\n if (!RequestContext.isActive()) {\n throw new Error(\n 'No active RequestContext. Run the client call inside RequestContext.run(...).',\n );\n }\n const recorder = this.headers.findRecorder();\n if (!recorder) {\n return super.execute(route, requestDto, method);\n }\n return this.recordCall(recorder, route, requestDto, method);\n }\n\n /**\n * Execute the call while recording it (args + masked ctx snapshot + result).\n *\n * The snapshot is a FIXTURE field, not a log line, so it is built here rather than handed down\n * from the call path — a logging backend stamps its own fields and never sees this.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n private async recordCall(\n recorder: TestCaseRecorder,\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n const ctxSnapshot: Record<string, string> = {};\n for (const entry of RequestContext.buildLogFields().entries()) {\n ctxSnapshot[entry[0]] = entry[1];\n }\n const recorded = new RecordedEndpoint(\n this.contractName(),\n route.methodName,\n [requestDto],\n ctxSnapshot,\n );\n recorder.addEndpointInfo(recorded);\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- capture failure into the recording, then rethrow unchanged\n try {\n const response = await super.execute(route, requestDto, method);\n recorded.successResponse = response;\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n recorded.failureResponse = new RecordedError(error.name, error.message);\n throw err;\n }\n }\n\n /**\n * SERVER-TO-SERVER: a 4xx received from a dependency becomes THIS server's own 500.\n *\n * THE INVARIANT:\n *\n * A status received from a downstream dependency describes OUR request to it. It is never the\n * status we return to OUR caller. The server that answered 404 is correct; the server that\n * asked for a route that does not exist is broken, and must say so as a 500.\n *\n * Every 4xx is a CALLER-side defect on this hop: 404 = wrong path / wrong base URL / a dependency\n * that is not deployed yet, 400 = we sent a malformed request, 401/403 = our service credentials\n * or the callee's caller allow-list are wrong. None of them is an answer for whoever called US, and\n * relaying one lets an internal misconfiguration impersonate a legitimate response. That is not\n * hypothetical: a partner-facing Management API reported an EMPTY store estate for an org with six\n * live storefronts, because its dependency had not been promoted and Express served an HTML 404\n * which arrived here as `ApiNotFoundError` and went straight back out. A 500 would have been\n * loud, correct, and attributable to the one server that actually had the bug — which is the whole\n * point: only ONE server should be paged for this.\n *\n * DELIBERATELY 4xx ONLY. 5xx (502/503/504) already mean \"the dependency is unavailable\", which is\n * honest and useful outward, and 500 is already a 500. `ApiEndUserError` (266, a 2xx code carrying\n * user validation) and `ApiDependencyBackoffError` are not statuses about our request at all. All of\n * them pass through untouched.\n *\n * THE OPT-OUT IS `appRegistered`, not a config key. A thin proxy or gateway that genuinely wants to\n * relay a downstream status as its own registers a `ClientRegistry` error translation for it at\n * startup — one greppable line saying so out loud — and that translation wins here. Only the\n * framework's built-in default mapping gets wrapped. There is no flag, because a flag would make\n * the dangerous choice invisible in the code that suffers from it.\n *\n * The downstream diagnostic is NOT lost: the original error (which for the incident above names the\n * method, the status, the `text/html` content-type and a snippet of the body) is both quoted in the\n * message and kept as `cause`.\n *\n * How much \"Downstream said:\" is worth depends on WHO answered, and both halves are by design:\n * - a NON-webpieces answer (an lb's html 404, a proxy's plain-text 502) is described CLIENT-side by\n * `ResponseBodyReader.describeForeignBody`, so the full diagnostic is ours to quote — this is the\n * mealco incident's exact shape, and it is the case that mattered.\n * - a WEBPIECES peer deliberately sends only the generic reason phrase for its status (see\n * `ApiErrorHttpMapper` in http-server — only `ApiEndUserError`'s message is caller-facing), so this\n * reads \"Downstream said: Not Found\". That is correct and not a regression: the peer's real\n * message is in the PEER's log, correlated by request id, which is the only place it was ever\n * safe to read it.\n *\n * This whole string is an operator-facing message on an `ApiImplementationError`, so when THIS\n * server answers its own caller none of it goes on the wire — it goes to this server's log.\n */\n protected override adaptDownstreamFailure(failure: TranslatedFailure, callId: string): Error {\n if (failure.appRegistered) {\n return failure.error;\n }\n if (failure.statusCode < 400 || failure.statusCode >= 500) {\n return failure.error;\n }\n return new ApiImplementationError(\n `${callId}: dependency answered HTTP ${failure.statusCode}. That status describes OUR ` +\n `request to it, not an answer for our caller, so this server owns it as a 500 — check the ` +\n `path, the base URL, whether the dependency is deployed, and our service credentials. ` +\n `Downstream said: ${failure.error.message}`,\n failure.error,\n );\n }\n}\n\n/**\n * DI token for the `Provider<NodeProxyClient>` that hands out RPC clients — one per API contract.\n * `Provider<T>` is erased at runtime, so it cannot be its own token; this Symbol names T.\n *\n * Because NodeProxyClient is bound TRANSIENT, every `get()` constructs a new one. (Were it bound\n * `@provideFrameworkSingleton`, the very same Provider would instead hand back one lazily-created\n * instance — the provider caches nothing, so the target's scope decides.)\n */\n// webpieces-disable no-symbol-di-tokens -- Provider<T> is erased at runtime; the Symbol names T\nexport const NODE_PROXY_CLIENT_PROVIDER = Symbol.for('Provider<NodeProxyClient>');\n"]}
1
+ {"version":3,"file":"NodeProxyClient.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/NodeProxyClient.ts"],"names":[],"mappings":";;;;AAAA,yCAA6C;AAC7C,6CAAmC;AACnC,yCAAoE;AACpE,2CAAqD;AACrD,6CAAuC;AACvC,oDAY8B;AAC9B,0DAKiC;AACjC,0DAAkD;AAClD,kEAOqC;AACrC,uDAAoD;AAEpD,iEAA8D;AAC9D,iEAA8D;AAC9D,6DAA0D;AAC1D,uDAAoD;AACpD,6CAA0C;AAC1C,mEAAyF;AAEzF;;;;;;GAMG;AACH,MAAM,mBAAmB,GAAG,GAAG,CAAC;AAChC,MAAM,sBAAsB,GAAG,GAAG,CAAC;AAEnC;;;;;;;GAOG;AAEI,IAAM,eAAe,GAArB,MAAM,eAAgB,SAAQ,8BAAW;IAKQ;IAEd;IAEQ;IAGI;IAM7B;IAjBb,MAAM,CAAgB;IAE9B,YAEoD,OAA8B,EAE5C,OAAgB,EAER,eAAgC,EAG5B,OAAiB,EAM9C,aAAqC;QAEtD,kFAAkF;QAClF,2FAA2F;QAC3F,wDAAwD;QACxD,KAAK,CAAC,IAAI,0BAAc,CAAC,IAAI,2CAA4B,EAAE,CAAC,CAAC,CAAC;QAlBd,YAAO,GAAP,OAAO,CAAuB;QAE5C,YAAO,GAAP,OAAO,CAAS;QAER,oBAAe,GAAf,eAAe,CAAiB;QAG5B,YAAO,GAAP,OAAO,CAAU;QAM9C,kBAAa,GAAb,aAAa,CAAwB;IAM1D,CAAC;IAED;;;OAGG;IACH,IAAI,CACA,YAAkC,EAClC,MAAoB,EACpB,UAAoC;QAEpC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,UAAU,CAAC,YAAY,EAAE,UAAU,CAAC,CAAC;IAC9C,CAAC;IAED;;;;;;;OAOG;IACgB,cAAc;QAC7B,OAAO,0BAAc,CAAC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;IACvD,CAAC;IAED,4FAA4F;IACzE,6BAA6B;QAC5C,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;OAGG;IACgB,sBAAsB,CACrC,OAAsB,EACtB,MAAmB,EACnB,IAAwB;QAExB,OAAO,IAAI,OAAO,CACd,CAAC,OAAqC,EAAE,MAA8B,EAAE,EAAE;YACtE,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YACjC,MAAM,IAAI,GAAG,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,oBAAY,CAAC,CAAC,CAAC,mBAAW,CAAC;YACpE,MAAM,QAAQ,GAAG,IAAI,CACjB,GAAG,EACH;gBACI,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,OAAO,CAAC,eAAe,EAAE;gBAClC,MAAM;aACT,EACD,CAAC,QAAyB,EAAQ,EAAE;gBAChC,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;gBAC9B,KAAK,MAAM,CAAC,IAAI,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC;oBAC3D,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,EAAE,CAAC;wBACvB,KAAK,MAAM,IAAI,IAAI,KAAK;4BAAE,OAAO,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;oBACzD,CAAC;yBAAM,IAAI,KAAK,KAAK,SAAS,EAAE,CAAC;wBAC7B,OAAO,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAC;oBAC7B,CAAC;gBACL,CAAC;gBACD,MAAM,YAAY,GAAG,sBAAQ,CAAC,KAAK,CAAC,QAAQ,CAA+B,CAAC;gBAC5E,OAAO,CACH,IAAI,QAAQ,CAAC,YAAY,EAAE;oBACvB,MAAM,EAAE,QAAQ,CAAC,UAAU,IAAI,GAAG;oBAClC,UAAU,EAAE,QAAQ,CAAC,aAAa;oBAClC,OAAO;iBACV,CAAC,CACL,CAAC;YACN,CAAC,CACJ,CAAC;YACF,QAAQ,CAAC,IAAI,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC/B,+FAA+F;YAC/F,KAAK,IAAI,CAAC,oBAAoB,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC,KAAK,CAAC,CAAC,GAAY,EAAE,EAAE;gBAClE,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;gBAC3B,QAAQ,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YAC5B,CAAC,CAAC,CAAC;QACP,CAAC,CACJ,CAAC;IACN,CAAC;IAEO,KAAK,CAAC,oBAAoB,CAC9B,IAAwB,EACxB,QAA2C;QAE3C,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAChC,8GAA8G;QAC9G,IAAI,CAAC;YACD,SAAS,CAAC;gBACN,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;gBACjC,IAAI,IAAI,CAAC,IAAI;oBAAE,MAAM;gBACrB,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC;oBAAE,MAAM,IAAA,kBAAI,EAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;YACnE,CAAC;YACD,QAAQ,CAAC,GAAG,EAAE,CAAC;QACnB,CAAC;gBAAS,CAAC;YACP,MAAM,CAAC,WAAW,EAAE,CAAC;QACzB,CAAC;IACL,CAAC;IAED;;;;;;;;OAQG;IACgB,aAAa;QAC5B,OAAO;YACH,IAAI,yCAAsB,CACtB,mBAAmB,EACnB,IAAI,iCAAe,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,IAAI,CAAC,eAAe,CAAC,CAC/D;YACD,IAAI,yCAAsB,CACtB,sBAAsB,EACtB,IAAI,uCAAkB,CAAC,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,OAAO,EAAE,IAAI,CAAC,aAAa,CAAC,CACzE;SACJ,CAAC;IACN,CAAC;IAED;;;;;;;;;;OAUG;IACK,UAAU;QACd,KAAK,MAAM,UAAU,IAAI,IAAI,CAAC,UAAU,EAAE,CAAC;YACvC,MAAM,MAAM,GAAG,UAAU,CAAC,MAAM,CAAC;YACjC,IAAI,MAAM,YAAY,2CAAoB,IAAI,MAAM,YAAY,2CAAoB,EAAE,CAAC;gBACnF,OAAO,MAAM,CAAC,UAAU,CAAC;YAC7B,CAAC;QACL,CAAC;QACD,OAAO,IAAI,uBAAU,EAAE,CAAC;IAC5B,CAAC;IAED;;;;;;;OAOG;IACgB,sBAAsB,CAAC,WAA6B;QACnE,OAAO,IAAI,CAAC,OAAO,CAAC,oBAAoB,CAAC,WAAW,CAAC,CAAC;IAC1D,CAAC;IAED;;;;OAIG;IACH,iFAAiF;IAC9D,KAAK,CAAC,OAAO,CAC5B,KAAoB,EACpB,UAAmB;IACnB,iFAAiF;IACjF,MAA8B;QAG9B,yFAAyF;QACzF,IAAI,CAAC,6BAAc,CAAC,QAAQ,EAAE,EAAE,CAAC;YAC7B,MAAM,IAAI,KAAK,CACX,+EAA+E,CAClF,CAAC;QACN,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,YAAY,EAAE,CAAC;QAC7C,IAAI,CAAC,QAAQ,EAAE,CAAC;YACZ,OAAO,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;QACpD,CAAC;QACD,OAAO,IAAI,CAAC,UAAU,CAAC,QAAQ,EAAE,KAAK,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;IAChE,CAAC;IAED;;;;;OAKG;IACH,iFAAiF;IACzE,KAAK,CAAC,UAAU,CACpB,QAA0B,EAC1B,KAAoB,EACpB,UAAmB;IACnB,iFAAiF;IACjF,MAA8B;QAG9B,MAAM,WAAW,GAA2B,EAAE,CAAC;QAC/C,KAAK,MAAM,KAAK,IAAI,6BAAc,CAAC,cAAc,EAAE,CAAC,OAAO,EAAE,EAAE,CAAC;YAC5D,WAAW,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACrC,CAAC;QACD,MAAM,QAAQ,GAAG,IAAI,4BAAgB,CACjC,IAAI,CAAC,YAAY,EAAE,EACnB,KAAK,CAAC,UAAU,EAChB,CAAC,UAAU,CAAC,EACZ,WAAW,CACd,CAAC;QACF,QAAQ,CAAC,eAAe,CAAC,QAAQ,CAAC,CAAC;QAEnC,4HAA4H;QAC5H,IAAI,CAAC;YACD,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,OAAO,CAAC,KAAK,EAAE,UAAU,EAAE,MAAM,CAAC,CAAC;YAChE,QAAQ,CAAC,eAAe,GAAG,QAAQ,CAAC;YACpC,OAAO,QAAQ,CAAC;QACpB,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,QAAQ,CAAC,eAAe,GAAG,IAAI,yBAAa,CAAC,KAAK,CAAC,IAAI,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YACxE,MAAM,GAAG,CAAC;QACd,CAAC;IACL,CAAC;IAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8CG;IACgB,sBAAsB,CAAC,OAA0B,EAAE,MAAc;QAChF,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC;YACxB,OAAO,OAAO,CAAC,KAAK,CAAC;QACzB,CAAC;QACD,IAAI,OAAO,CAAC,UAAU,GAAG,GAAG,IAAI,OAAO,CAAC,UAAU,IAAI,GAAG,EAAE,CAAC;YACxD,OAAO,OAAO,CAAC,KAAK,CAAC;QACzB,CAAC;QACD,OAAO,IAAI,kCAAsB,CAC7B,GAAG,MAAM,8BAA8B,OAAO,CAAC,UAAU,8BAA8B;YACnF,2FAA2F;YAC3F,uFAAuF;YACvF,oBAAoB,OAAO,CAAC,KAAK,CAAC,OAAO,EAAE,EAC/C,OAAO,CAAC,KAAK,CAChB,CAAC;IACN,CAAC;CACJ,CAAA;AAjTY,0CAAe;0BAAf,eAAe;IAD3B,IAAA,wCAAyB,GAAE;IAMnB,mBAAA,IAAA,kBAAM,EAAC,oCAAqB,CAAC,CAAA;IAE7B,mBAAA,IAAA,kBAAM,EAAC,sBAAO,CAAC,CAAA;IAEf,mBAAA,IAAA,kBAAM,EAAC,iCAAe,CAAC,CAAA;IAGvB,mBAAA,IAAA,oBAAQ,GAAE,CAAA;IAAE,mBAAA,IAAA,kBAAM,EAAC,mBAAO,CAAC,CAAA;IAI3B,mBAAA,IAAA,oBAAQ,GAAE,CAAA;IACV,mBAAA,IAAA,kBAAM,EAAC,+CAAuB,CAAC,CAAA;6CAZyB,oCAAqB;QAEnC,sBAAO;QAES,iCAAe;QAGlB,mBAAO;QAM9B,6CAAqB;GAlBjD,eAAe,CAiT3B;AAED;;;;;;;GAOG;AACH,gGAAgG;AACnF,QAAA,0BAA0B,GAAG,MAAM,CAAC,GAAG,CAAC,2BAA2B,CAAC,CAAC","sourcesContent":["import { inject, optional } from 'inversify';\nimport { once } from 'node:events';\nimport { request as httpRequest, IncomingMessage } from 'node:http';\nimport { request as httpsRequest } from 'node:https';\nimport { Readable } from 'node:stream';\nimport {\n ClientRegistry,\n DestinationTrust,\n ApiImplementationError,\n RecordedEndpoint,\n RecordedError,\n LogApiCallImpl,\n RouteMetadata,\n Secrets,\n SECRETS,\n TestCaseRecorder,\n toError,\n} from '@webpieces/core-util';\nimport {\n RequestContext,\n RequestContextApiCallContext,\n RequestContextHeaders,\n provideFrameworkTransient,\n} from '@webpieces/core-context';\nimport { GcpOidc } from '@webpieces/gcp-identity';\nimport {\n ApiPrototype,\n ByteReadableStream,\n ClientRequest,\n ClientFilterDefinition,\n ProxyClient,\n TranslatedFailure,\n} from '@webpieces/http-client-core';\nimport { AddressResolver } from './AddressResolver';\nimport { ClientConfig } from './ClientConfig';\nimport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\nimport { ContextFullUrlFilter } from './ContextFullUrlFilter';\nimport { OutboundAuthFilter } from './OutboundAuthFilter';\nimport { SsrfGuardFilter } from './SsrfGuardFilter';\nimport { SsrfPolicy } from './SsrfPolicy';\nimport { WEBHOOK_SIGNER_CALLBACK, WebhookSignerCallback } from './WebhookSignerCallback';\n\n/**\n * The two framework built-ins' priorities, RELATIVE TO EACH OTHER and to nothing else — they are\n * ordered beneath every app filter structurally, not by number (see `ProxyClient.initRoutes`).\n *\n * The guard is OUTSIDE the minter deliberately: a destination that is going to be refused must be\n * refused BEFORE a credential is minted for it, so a hostile URL never causes a token to exist.\n */\nconst SSRF_GUARD_PRIORITY = 900;\nconst OUTBOUND_AUTH_PRIORITY = 800;\n\n/**\n * The server-side {@link ProxyClient}. Everything a browser cannot do lives here: reading the\n * ambient RequestContext, minting OIDC tokens, holding shared secrets, and recording test cases.\n *\n * TRANSIENT on purpose. Every `createRpcClient(api, config)` needs its own instance, because `init()`\n * binds one instance to exactly one API contract and one target. {@link ProxyClientProvider} hands\n * them out — see its doc.\n */\n@provideFrameworkTransient()\nexport class NodeProxyClient extends ProxyClient {\n private config!: ClientConfig;\n\n constructor(\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(RequestContextHeaders) private readonly headers: RequestContextHeaders,\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(GcpOidc) private readonly gcpOidc: GcpOidc,\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @inject(AddressResolver) private readonly addressResolver: AddressResolver,\n // @optional: only @WpAuthSharedSecret endpoints need it; the client sends its bound value.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @optional() @inject(SECRETS) private readonly secrets?: Secrets,\n // @optional: only @WpAuthWebhook endpoints need it, and an unbound one makes them THROW\n // rather than deliver unsigned — see WebhookSignerCallback.\n // webpieces-disable inject-annotation-not-needed-for-concrete-class -- DI-resolved param; the esbuild/vitest path elides type-only imports (no design:paramtypes), so the explicit token is required\n @optional()\n @inject(WEBHOOK_SIGNER_CALLBACK)\n private readonly webhookSigner?: WebhookSignerCallback,\n ) {\n // This package is node-only and already depends on core-context, so it builds the\n // RequestContext-backed ApiCallContext itself. No startup install, and therefore nothing a\n // non-webpieces host (plain NestJS/Express) can forget.\n super(new LogApiCallImpl(new RequestContextApiCallContext()));\n }\n\n /**\n * Bind this client to one API contract + target, with the app's outbound filters (url\n * rewriting, header editing, logging, per-call re-pointing).\n */\n init(\n apiPrototype: ApiPrototype<object>,\n config: ClientConfig,\n appFilters: ClientFilterDefinition[],\n ): void {\n this.config = config;\n this.initRoutes(apiPrototype, appFilters);\n }\n\n /**\n * The same chain every client runs — a ClientRegistry mapping, else the installed deriver — but\n * with NODE's fallback: THROW. A server has no \"own origin\" to fall back to the way a browser\n * does, so an unresolvable peer is a setup bug and must fail loudly (the error names the fixes).\n *\n * Resolved per call, never at construction, so building a client stays synchronous. Any metadata\n * read beneath a deriver is memoized process-wide, so only the first call pays.\n */\n protected override resolveBaseUrl(): Promise<string> {\n return ClientRegistry.resolve(this.config.svcName);\n }\n\n /** Node/undici supports a live ReadableStream body when fetch is given `duplex: 'half'`. */\n protected override supportsConcurrentDuplexFetch(): boolean {\n return true;\n }\n\n /**\n * Node's native HTTP stream is genuinely concurrent: unlike browser Fetch, an early response\n * does not cancel the still-open upload. The returned Web Response keeps parsing isomorphic.\n */\n protected override sendStreamingTransport(\n request: ClientRequest,\n signal: AbortSignal,\n body: ByteReadableStream,\n ): Promise<Response> {\n return new Promise<Response>(\n (resolve: (response: Response) => void, reject: (error: Error) => void) => {\n const url = new URL(request.url);\n const send = url.protocol === 'https:' ? httpsRequest : httpRequest;\n const outgoing = send(\n url,\n {\n method: 'POST',\n headers: request.headersAsRecord(),\n signal,\n },\n (incoming: IncomingMessage): void => {\n const headers = new Headers();\n for (const [name, value] of Object.entries(incoming.headers)) {\n if (Array.isArray(value)) {\n for (const item of value) headers.append(name, item);\n } else if (value !== undefined) {\n headers.set(name, value);\n }\n }\n const responseBody = Readable.toWeb(incoming) as ReadableStream<Uint8Array>;\n resolve(\n new Response(responseBody, {\n status: incoming.statusCode ?? 500,\n statusText: incoming.statusMessage,\n headers,\n }),\n );\n },\n );\n outgoing.once('error', reject);\n // webpieces-disable no-any-unknown -- a native socket/write rejection is normalized by toError\n void this.pumpStreamingRequest(body, outgoing).catch((err: unknown) => {\n const error = toError(err);\n outgoing.destroy(error);\n });\n },\n );\n }\n\n private async pumpStreamingRequest(\n body: ByteReadableStream,\n outgoing: import('node:http').ClientRequest,\n ): Promise<void> {\n const reader = body.getReader();\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- socket errors reject writer acknowledgements\n try {\n for (;;) {\n const part = await reader.read();\n if (part.done) break;\n if (!outgoing.write(part.value)) await once(outgoing, 'drain');\n }\n outgoing.end();\n } finally {\n reader.releaseLock();\n }\n }\n\n /**\n * The two framework built-ins, installed on EVERY client this package builds and ordered\n * beneath every app filter by {@link ProxyClient.initRoutes}.\n *\n * They are unconditional rather than opt-in because neither costs anything on the path that\n * does not need it: the SSRF guard steps aside when nothing re-pointed the request, and the\n * auth filter does nothing for a `@WpAuthPublic` endpoint. An app therefore cannot forget to install\n * the guard on the one client that takes runtime URLs — the ACT of re-pointing is what arms it.\n */\n protected override clientFilters(): ClientFilterDefinition[] {\n return [\n new ClientFilterDefinition(\n SSRF_GUARD_PRIORITY,\n new SsrfGuardFilter(this.ssrfPolicy(), this.addressResolver),\n ),\n new ClientFilterDefinition(\n OUTBOUND_AUTH_PRIORITY,\n new OutboundAuthFilter(this.gcpOidc, this.secrets, this.webhookSigner),\n ),\n ];\n }\n\n /**\n * WHICH policy the guard applies when something does re-point this client.\n *\n * Read off an installed {@link ContextBaseUrlFilter} or {@link ContextFullUrlFilter}, because\n * that filter is where an app says\n * \"this client may be re-pointed\", and the single legitimate relaxation\n * ({@link SsrfTestingPolicy}) belongs at the same construction site as that decision rather\n * than in a second place a reader has to correlate. No such filter — or one built with the\n * default — means {@link SsrfPolicy} (the strict one), so the safe answer is what an app gets by saying\n * nothing.\n */\n private ssrfPolicy(): SsrfPolicy {\n for (const definition of this.appFilters) {\n const filter = definition.filter;\n if (filter instanceof ContextBaseUrlFilter || filter instanceof ContextFullUrlFilter) {\n return filter.ssrfPolicy;\n }\n }\n return new SsrfPolicy();\n }\n\n /**\n * Straight from the RequestContext. Throws when there is no active request scope.\n *\n * `destination` rides through unchanged: this is the ONE client that can legitimately propagate a\n * verified identity, and it does so exactly when the callee will authenticate us (@WpAuthOidc /\n * @WpAuthSharedSecret). Calling a peer's @WpAuthPublic or @WpAuthJwt endpoint now omits `x-user-id` and\n * friends instead of shipping headers that endpoint's AuthFilter is obliged to reject.\n */\n protected override outboundContextHeaders(destination: DestinationTrust): Map<string, string> {\n return this.headers.buildOutboundHeaders(destination);\n }\n\n /**\n * Test-case recording hook (mirror of Java HttpsJsonClientInvokeHandler): if a recorder is\n * travelling in the magic context, capture this outbound call + its result so it becomes a mock\n * in the generated test. Absent a recorder this is exactly the base behavior.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n protected override async execute(\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n // Preserve the context precondition before logging, which also requires an active scope.\n if (!RequestContext.isActive()) {\n throw new Error(\n 'No active RequestContext. Run the client call inside RequestContext.run(...).',\n );\n }\n const recorder = this.headers.findRecorder();\n if (!recorder) {\n return super.execute(route, requestDto, method);\n }\n return this.recordCall(recorder, route, requestDto, method);\n }\n\n /**\n * Execute the call while recording it (args + masked ctx snapshot + result).\n *\n * The snapshot is a FIXTURE field, not a log line, so it is built here rather than handed down\n * from the call path — a logging backend stamps its own fields and never sees this.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n private async recordCall(\n recorder: TestCaseRecorder,\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n const ctxSnapshot: Record<string, string> = {};\n for (const entry of RequestContext.buildLogFields().entries()) {\n ctxSnapshot[entry[0]] = entry[1];\n }\n const recorded = new RecordedEndpoint(\n this.contractName(),\n route.methodName,\n [requestDto],\n ctxSnapshot,\n );\n recorder.addEndpointInfo(recorded);\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- capture failure into the recording, then rethrow unchanged\n try {\n const response = await super.execute(route, requestDto, method);\n recorded.successResponse = response;\n return response;\n } catch (err: unknown) {\n const error = toError(err);\n recorded.failureResponse = new RecordedError(error.name, error.message);\n throw err;\n }\n }\n\n /**\n * SERVER-TO-SERVER: a 4xx received from a dependency becomes THIS server's own 500.\n *\n * THE INVARIANT:\n *\n * A status received from a downstream dependency describes OUR request to it. It is never the\n * status we return to OUR caller. The server that answered 404 is correct; the server that\n * asked for a route that does not exist is broken, and must say so as a 500.\n *\n * Every 4xx is a CALLER-side defect on this hop: 404 = wrong path / wrong base URL / a dependency\n * that is not deployed yet, 400 = we sent a malformed request, 401/403 = our service credentials\n * or the callee's caller allow-list are wrong. None of them is an answer for whoever called US, and\n * relaying one lets an internal misconfiguration impersonate a legitimate response. That is not\n * hypothetical: a partner-facing Management API reported an EMPTY store estate for an org with six\n * live storefronts, because its dependency had not been promoted and Express served an HTML 404\n * which arrived here as `ApiNotFoundError` and went straight back out. A 500 would have been\n * loud, correct, and attributable to the one server that actually had the bug — which is the whole\n * point: only ONE server should be paged for this.\n *\n * DELIBERATELY 4xx ONLY. 5xx (502/503/504) already mean \"the dependency is unavailable\", which is\n * honest and useful outward, and 500 is already a 500. `ApiEndUserError` (266, a 2xx code carrying\n * user validation) and `ApiDependencyBackoffError` are not statuses about our request at all. All of\n * them pass through untouched.\n *\n * THE OPT-OUT IS `appRegistered`, not a config key. A thin proxy or gateway that genuinely wants to\n * relay a downstream status as its own registers a `ClientRegistry` error translation for it at\n * startup — one greppable line saying so out loud — and that translation wins here. Only the\n * framework's built-in default mapping gets wrapped. There is no flag, because a flag would make\n * the dangerous choice invisible in the code that suffers from it.\n *\n * The downstream diagnostic is NOT lost: the original error (which for the incident above names the\n * method, the status, the `text/html` content-type and a snippet of the body) is both quoted in the\n * message and kept as `cause`.\n *\n * How much \"Downstream said:\" is worth depends on WHO answered, and both halves are by design:\n * - a NON-webpieces answer (an lb's html 404, a proxy's plain-text 502) is described CLIENT-side by\n * `ResponseBodyReader.describeForeignBody`, so the full diagnostic is ours to quote — this is the\n * mealco incident's exact shape, and it is the case that mattered.\n * - a WEBPIECES peer deliberately sends only the generic reason phrase for its status (see\n * `ApiErrorHttpMapper` in http-server — only `ApiEndUserError`'s message is caller-facing), so this\n * reads \"Downstream said: Not Found\". That is correct and not a regression: the peer's real\n * message is in the PEER's log, correlated by request id, which is the only place it was ever\n * safe to read it.\n *\n * This whole string is an operator-facing message on an `ApiImplementationError`, so when THIS\n * server answers its own caller none of it goes on the wire — it goes to this server's log.\n */\n protected override adaptDownstreamFailure(failure: TranslatedFailure, callId: string): Error {\n if (failure.appRegistered) {\n return failure.error;\n }\n if (failure.statusCode < 400 || failure.statusCode >= 500) {\n return failure.error;\n }\n return new ApiImplementationError(\n `${callId}: dependency answered HTTP ${failure.statusCode}. That status describes OUR ` +\n `request to it, not an answer for our caller, so this server owns it as a 500 — check the ` +\n `path, the base URL, whether the dependency is deployed, and our service credentials. ` +\n `Downstream said: ${failure.error.message}`,\n failure.error,\n );\n }\n}\n\n/**\n * DI token for the `Provider<NodeProxyClient>` that hands out RPC clients — one per API contract.\n * `Provider<T>` is erased at runtime, so it cannot be its own token; this Symbol names T.\n *\n * Because NodeProxyClient is bound TRANSIENT, every `get()` constructs a new one. (Were it bound\n * `@provideFrameworkSingleton`, the very same Provider would instead hand back one lazily-created\n * instance — the provider caches nothing, so the target's scope decides.)\n */\n// webpieces-disable no-symbol-di-tokens -- Provider<T> is erased at runtime; the Symbol names T\nexport const NODE_PROXY_CLIENT_PROVIDER = Symbol.for('Provider<NodeProxyClient>');\n"]}
@@ -111,7 +111,8 @@ class SsrfGuardFilter extends core_util_1.Filter {
111
111
  `URL is never judged here at all. If this client genuinely must dial an internal address ` +
112
112
  `from RUNTIME data (exercising the partner path against a local fake), say so at the ` +
113
113
  `construction site with ` +
114
- `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>')).`, url);
114
+ `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>')) (or the same argument to ` +
115
+ `ContextFullUrlFilter).`, url);
115
116
  }
116
117
  /** Every address for `hostname`, or a refusal — a name that will not resolve is not a destination. */
117
118
  async resolveOrRefuse(url, hostname) {
@@ -1 +1 @@
1
- {"version":3,"file":"SsrfGuardFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/SsrfGuardFilter.ts"],"names":[],"mappings":"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","sourcesContent":["import { Filter, Service, toError } from '@webpieces/core-util';\nimport { ClientRequest } from '@webpieces/http-client-core';\nimport { AddressResolver } from './AddressResolver';\nimport { InternalAddressRules } from './InternalAddressRules';\nimport { SsrfPolicy } from './SsrfPolicy';\nimport { SsrfRefusedError } from './SsrfRefusedError';\n\n/**\n * Enforces {@link SsrfPolicy} on a call whose destination came from RUNTIME data.\n *\n * The moment the base URL is a database column a partner edited, \"do not let our process be used as\n * a proxy into our own network\" stops being the caller's job and becomes the framework's. Every\n * consumer was reinventing this, badly or not at all, and the one that did it best still only\n * managed `maxRedirects: 0` by hand.\n *\n * ## Installed on EVERY client; it costs nothing until a filter moves the request\n *\n * It sits beneath every app filter of every client this package builds, and the first thing it does\n * is ask `request.destinationCameFromData`. FALSE — the URL is what `ClientRegistry` resolved, an\n * address we chose — and it steps aside without parsing a URL or resolving a name, so an ordinary\n * service-to-service RPC runs exactly the code path it ran before this class existed. TRUE only\n * once something re-pointed the request, which is the one input a partner controls.\n *\n * That is why there is no per-client switch: an app cannot forget to turn the guard on for a client\n * that takes runtime URLs, and cannot turn it off for one — the ACT of re-pointing is the trigger.\n *\n * ## What it enforces, per hop\n *\n * 1. the URL parses and its scheme is allowed (https only, by default);\n * 2. the hostname is not internal BY NAME (`localhost`, `metadata.google.internal`, `*.internal`);\n * 3. EVERY address the hostname resolves to is public — one private answer among several condemns\n * the request, which is what makes DNS rebinding a refusal rather than a coin toss;\n * 4. redirects are not followed by the transport. The guard reads the `Location` itself, re-runs\n * (1)–(3) on it, and only then re-invokes the chain. A partner URL that 302s to\n * `http://169.254.169.254/…` is refused at the redirect, not obeyed — and because the whole\n * chain below re-runs, whatever signs the request signs it for the host it is actually going to.\n *\n * ## What it deliberately does NOT claim\n *\n * There is a TOCTOU window between resolving a name and the transport connecting: a hostile DNS\n * server can answer differently for the two lookups. Closing it properly means pinning the resolved\n * address into the socket, which node's transport gives no seam for. This guard raises the cost of\n * the attack a great deal and is honest about not eliminating it; egress firewalling remains the\n * control that actually cannot be tricked.\n */\nexport class SsrfGuardFilter extends Filter<ClientRequest, Response> {\n private readonly rules = new InternalAddressRules();\n\n constructor(\n private readonly policy: SsrfPolicy,\n private readonly addressResolver: AddressResolver,\n ) {\n super();\n }\n\n override async filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response> {\n // The destination is still the one this client resolved for itself, so there is nothing\n // attacker-influenced to judge. Step aside entirely — no parse, no DNS, no redirect\n // interception — so the deployed-service path is byte-identical to having no guard at all.\n if (!request.destinationCameFromData) {\n return nextFilter.invoke(request);\n }\n await this.assertAllowed(request.url);\n\n // The transport must not follow a redirect on its own — that would be a hop this policy\n // never saw. We read the Location and judge it ourselves, below.\n request.followRedirects = false;\n\n let response = await nextFilter.invoke(request);\n for (let hop = 0; this.isRedirect(response); hop++) {\n if (hop >= this.policy.maxRedirects) {\n throw new SsrfRefusedError(\n `${request.contractName}.${request.route.methodName}: refused to follow more than ` +\n `${this.policy.maxRedirects} redirect(s) from a runtime-supplied destination. ` +\n `The last one was ${request.url}. A webhook endpoint that needs a longer redirect ` +\n `chain should be registered at its final URL instead.`,\n request.url,\n );\n }\n const target = this.redirectTargetOf(response, request);\n await this.assertAllowed(target);\n request.followRedirectTo(target);\n response = await nextFilter.invoke(request);\n }\n return response;\n }\n\n /**\n * @throws SsrfRefusedError naming what condemned the url and the ONE opt-out that would allow\n * it. Returns normally when the destination is acceptable.\n */\n private async assertAllowed(url: string): Promise<void> {\n const parsed = this.parse(url);\n if (!this.policy.allowedSchemes.has(parsed.protocol)) {\n const allowed = [...this.policy.allowedSchemes].join(', ');\n throw new SsrfRefusedError(\n `Refusing to send to ${url}: scheme '${parsed.protocol}' is not allowed (allowed: ${allowed}). ` +\n `A destination supplied at runtime must be reached over TLS — a plaintext hop leaks the ` +\n `payload and its signature to anything on the path.`,\n url,\n );\n }\n if (this.policy.allowInternalAddresses) {\n return;\n }\n const hostname = parsed.hostname.replace(/^\\[|\\]$/g, '');\n if (this.rules.isInternalHostname(hostname)) {\n throw this.refuseInternal(url, `'${hostname}' names infrastructure inside our own network`);\n }\n for (const address of await this.resolveOrRefuse(url, hostname)) {\n if (this.rules.isInternalAddress(address)) {\n throw this.refuseInternal(url, `it resolves to the internal address ${address}`);\n }\n }\n }\n\n private refuseInternal(url: string, because: string): SsrfRefusedError {\n return new SsrfRefusedError(\n `Refusing to send to ${url}: ${because}. A destination supplied at runtime is attacker-influenced ` +\n `data, so loopback, RFC1918, link-local and cloud-metadata addresses are refused — reaching ` +\n `169.254.169.254 would hand this process's own service-account tokens to whoever registered ` +\n `the URL. To reach one of OUR OWN services at a local port, register it — ` +\n `ClientRegistry.addUrlMapping('svc', 'http://localhost:8202') — and a registry-resolved ` +\n `URL is never judged here at all. If this client genuinely must dial an internal address ` +\n `from RUNTIME data (exercising the partner path against a local fake), say so at the ` +\n `construction site with ` +\n `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>')).`,\n url,\n );\n }\n\n /** Every address for `hostname`, or a refusal — a name that will not resolve is not a destination. */\n private async resolveOrRefuse(url: string, hostname: string): Promise<string[]> {\n // An IP LITERAL never went to DNS, so judge it directly; hostnames go to the resolver.\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(hostname) || hostname.includes(':')) {\n return [hostname];\n }\n // webpieces-disable no-unmanaged-exceptions -- turn an unresolvable name into the SAME refusal a bad address gets\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await this.addressResolver.resolve(hostname);\n } catch (err: unknown) {\n const error = toError(err);\n throw new SsrfRefusedError(\n `Refusing to send to ${url}: '${hostname}' did not resolve (${error.message}). ` +\n `An unresolvable destination is refused rather than attempted, so a partner row that has ` +\n `gone stale fails as a delivery error instead of as a network timeout.`,\n url,\n );\n }\n }\n\n private parse(url: string): URL {\n // webpieces-disable no-unmanaged-exceptions -- an unparseable url is a refusal, in this filter's own vocabulary\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return new URL(url);\n } catch (err: unknown) {\n const error = toError(err);\n throw new SsrfRefusedError(\n `Refusing to send to '${url}': it is not a parseable absolute URL (${error.message}). ` +\n `A runtime-supplied base URL must be absolute, e.g. 'https://api.partner.example'.`,\n url,\n );\n }\n }\n\n private isRedirect(response: Response): boolean {\n return response.status >= 300 && response.status < 400 && response.headers.get('location') !== null;\n }\n\n /** The redirect's absolute target, resolving a relative Location against the URL we just called. */\n private redirectTargetOf(response: Response, request: ClientRequest): string {\n const location = response.headers.get('location') ?? '';\n // webpieces-disable no-unmanaged-exceptions -- a malformed Location is a refusal, in this filter's own vocabulary\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return new URL(location, request.url).toString();\n } catch (err: unknown) {\n const error = toError(err);\n throw new SsrfRefusedError(\n `Refusing to follow the redirect from ${request.url}: its Location header ` +\n `'${location}' is not a usable URL (${error.message}).`,\n request.url,\n );\n }\n }\n}\n"]}
1
+ {"version":3,"file":"SsrfGuardFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/SsrfGuardFilter.ts"],"names":[],"mappings":"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","sourcesContent":["import { Filter, Service, toError } from '@webpieces/core-util';\nimport { ClientRequest } from '@webpieces/http-client-core';\nimport { AddressResolver } from './AddressResolver';\nimport { InternalAddressRules } from './InternalAddressRules';\nimport { SsrfPolicy } from './SsrfPolicy';\nimport { SsrfRefusedError } from './SsrfRefusedError';\n\n/**\n * Enforces {@link SsrfPolicy} on a call whose destination came from RUNTIME data.\n *\n * The moment the base URL is a database column a partner edited, \"do not let our process be used as\n * a proxy into our own network\" stops being the caller's job and becomes the framework's. Every\n * consumer was reinventing this, badly or not at all, and the one that did it best still only\n * managed `maxRedirects: 0` by hand.\n *\n * ## Installed on EVERY client; it costs nothing until a filter moves the request\n *\n * It sits beneath every app filter of every client this package builds, and the first thing it does\n * is ask `request.destinationCameFromData`. FALSE — the URL is what `ClientRegistry` resolved, an\n * address we chose — and it steps aside without parsing a URL or resolving a name, so an ordinary\n * service-to-service RPC runs exactly the code path it ran before this class existed. TRUE only\n * once something re-pointed the request, which is the one input a partner controls.\n *\n * That is why there is no per-client switch: an app cannot forget to turn the guard on for a client\n * that takes runtime URLs, and cannot turn it off for one — the ACT of re-pointing is the trigger.\n *\n * ## What it enforces, per hop\n *\n * 1. the URL parses and its scheme is allowed (https only, by default);\n * 2. the hostname is not internal BY NAME (`localhost`, `metadata.google.internal`, `*.internal`);\n * 3. EVERY address the hostname resolves to is public — one private answer among several condemns\n * the request, which is what makes DNS rebinding a refusal rather than a coin toss;\n * 4. redirects are not followed by the transport. The guard reads the `Location` itself, re-runs\n * (1)–(3) on it, and only then re-invokes the chain. A partner URL that 302s to\n * `http://169.254.169.254/…` is refused at the redirect, not obeyed — and because the whole\n * chain below re-runs, whatever signs the request signs it for the host it is actually going to.\n *\n * ## What it deliberately does NOT claim\n *\n * There is a TOCTOU window between resolving a name and the transport connecting: a hostile DNS\n * server can answer differently for the two lookups. Closing it properly means pinning the resolved\n * address into the socket, which node's transport gives no seam for. This guard raises the cost of\n * the attack a great deal and is honest about not eliminating it; egress firewalling remains the\n * control that actually cannot be tricked.\n */\nexport class SsrfGuardFilter extends Filter<ClientRequest, Response> {\n private readonly rules = new InternalAddressRules();\n\n constructor(\n private readonly policy: SsrfPolicy,\n private readonly addressResolver: AddressResolver,\n ) {\n super();\n }\n\n override async filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response> {\n // The destination is still the one this client resolved for itself, so there is nothing\n // attacker-influenced to judge. Step aside entirely — no parse, no DNS, no redirect\n // interception — so the deployed-service path is byte-identical to having no guard at all.\n if (!request.destinationCameFromData) {\n return nextFilter.invoke(request);\n }\n await this.assertAllowed(request.url);\n\n // The transport must not follow a redirect on its own — that would be a hop this policy\n // never saw. We read the Location and judge it ourselves, below.\n request.followRedirects = false;\n\n let response = await nextFilter.invoke(request);\n for (let hop = 0; this.isRedirect(response); hop++) {\n if (hop >= this.policy.maxRedirects) {\n throw new SsrfRefusedError(\n `${request.contractName}.${request.route.methodName}: refused to follow more than ` +\n `${this.policy.maxRedirects} redirect(s) from a runtime-supplied destination. ` +\n `The last one was ${request.url}. A webhook endpoint that needs a longer redirect ` +\n `chain should be registered at its final URL instead.`,\n request.url,\n );\n }\n const target = this.redirectTargetOf(response, request);\n await this.assertAllowed(target);\n request.followRedirectTo(target);\n response = await nextFilter.invoke(request);\n }\n return response;\n }\n\n /**\n * @throws SsrfRefusedError naming what condemned the url and the ONE opt-out that would allow\n * it. Returns normally when the destination is acceptable.\n */\n private async assertAllowed(url: string): Promise<void> {\n const parsed = this.parse(url);\n if (!this.policy.allowedSchemes.has(parsed.protocol)) {\n const allowed = [...this.policy.allowedSchemes].join(', ');\n throw new SsrfRefusedError(\n `Refusing to send to ${url}: scheme '${parsed.protocol}' is not allowed (allowed: ${allowed}). ` +\n `A destination supplied at runtime must be reached over TLS — a plaintext hop leaks the ` +\n `payload and its signature to anything on the path.`,\n url,\n );\n }\n if (this.policy.allowInternalAddresses) {\n return;\n }\n const hostname = parsed.hostname.replace(/^\\[|\\]$/g, '');\n if (this.rules.isInternalHostname(hostname)) {\n throw this.refuseInternal(url, `'${hostname}' names infrastructure inside our own network`);\n }\n for (const address of await this.resolveOrRefuse(url, hostname)) {\n if (this.rules.isInternalAddress(address)) {\n throw this.refuseInternal(url, `it resolves to the internal address ${address}`);\n }\n }\n }\n\n private refuseInternal(url: string, because: string): SsrfRefusedError {\n return new SsrfRefusedError(\n `Refusing to send to ${url}: ${because}. A destination supplied at runtime is attacker-influenced ` +\n `data, so loopback, RFC1918, link-local and cloud-metadata addresses are refused — reaching ` +\n `169.254.169.254 would hand this process's own service-account tokens to whoever registered ` +\n `the URL. To reach one of OUR OWN services at a local port, register it — ` +\n `ClientRegistry.addUrlMapping('svc', 'http://localhost:8202') — and a registry-resolved ` +\n `URL is never judged here at all. If this client genuinely must dial an internal address ` +\n `from RUNTIME data (exercising the partner path against a local fake), say so at the ` +\n `construction site with ` +\n `new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>')) (or the same argument to ` +\n `ContextFullUrlFilter).`,\n url,\n );\n }\n\n /** Every address for `hostname`, or a refusal — a name that will not resolve is not a destination. */\n private async resolveOrRefuse(url: string, hostname: string): Promise<string[]> {\n // An IP LITERAL never went to DNS, so judge it directly; hostnames go to the resolver.\n if (/^\\d{1,3}(\\.\\d{1,3}){3}$/.test(hostname) || hostname.includes(':')) {\n return [hostname];\n }\n // webpieces-disable no-unmanaged-exceptions -- turn an unresolvable name into the SAME refusal a bad address gets\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return await this.addressResolver.resolve(hostname);\n } catch (err: unknown) {\n const error = toError(err);\n throw new SsrfRefusedError(\n `Refusing to send to ${url}: '${hostname}' did not resolve (${error.message}). ` +\n `An unresolvable destination is refused rather than attempted, so a partner row that has ` +\n `gone stale fails as a delivery error instead of as a network timeout.`,\n url,\n );\n }\n }\n\n private parse(url: string): URL {\n // webpieces-disable no-unmanaged-exceptions -- an unparseable url is a refusal, in this filter's own vocabulary\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return new URL(url);\n } catch (err: unknown) {\n const error = toError(err);\n throw new SsrfRefusedError(\n `Refusing to send to '${url}': it is not a parseable absolute URL (${error.message}). ` +\n `A runtime-supplied base URL must be absolute, e.g. 'https://api.partner.example'.`,\n url,\n );\n }\n }\n\n private isRedirect(response: Response): boolean {\n return response.status >= 300 && response.status < 400 && response.headers.get('location') !== null;\n }\n\n /** The redirect's absolute target, resolving a relative Location against the URL we just called. */\n private redirectTargetOf(response: Response, request: ClientRequest): string {\n const location = response.headers.get('location') ?? '';\n // webpieces-disable no-unmanaged-exceptions -- a malformed Location is a refusal, in this filter's own vocabulary\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return new URL(location, request.url).toString();\n } catch (err: unknown) {\n const error = toError(err);\n throw new SsrfRefusedError(\n `Refusing to follow the redirect from ${request.url}: its Location header ` +\n `'${location}' is not a usable URL (${error.message}).`,\n request.url,\n );\n }\n }\n}\n"]}
package/src/index.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  *
20
20
  * // a client whose destination arrives per call: ONE filter, and nothing else changes
21
21
  * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
22
- * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
22
+ * new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
23
23
  * ]);
24
24
  * ```
25
25
  */
@@ -27,6 +27,7 @@ export { ClientHttpFactory } from './ClientHttpFactory';
27
27
  export { NodeProxyClient, NODE_PROXY_CLIENT_PROVIDER } from './NodeProxyClient';
28
28
  export { ClientConfig } from './ClientConfig';
29
29
  export { ContextBaseUrlFilter } from './ContextBaseUrlFilter';
30
+ export { ContextFullUrlFilter } from './ContextFullUrlFilter';
30
31
  export { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';
31
32
  export { MissingSharedSecretError, MissingWebhookSignerError } from './OutboundAuthErrors';
32
33
  export { WebhookSignerCallback, SignableRequest, WEBHOOK_SIGNER_CALLBACK, } from './WebhookSignerCallback';
package/src/index.js CHANGED
@@ -20,12 +20,12 @@
20
20
  *
21
21
  * // a client whose destination arrives per call: ONE filter, and nothing else changes
22
22
  * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [
23
- * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),
23
+ * new ClientFilterDefinition(1000, new ContextFullUrlFilter()),
24
24
  * ]);
25
25
  * ```
26
26
  */
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.StreamingCapabilityError = exports.ClientFilterDefinition = exports.ClientRequest = exports.TranslatedFailure = exports.ClientErrorTranslator = exports.ProxyClient = exports.DnsAddressResolver = exports.AddressResolver = exports.InternalAddressRules = exports.OutboundAuthFilter = exports.SsrfRefusedError = exports.SsrfGuardFilter = exports.SsrfTestingPolicy = exports.SsrfPolicy = exports.WEBHOOK_SIGNER_CALLBACK = exports.SignableRequest = exports.WebhookSignerCallback = exports.MissingWebhookSignerError = exports.MissingSharedSecretError = exports.MissingRuntimeBaseUrlError = exports.ContextBaseUrlFilter = exports.ClientConfig = exports.NODE_PROXY_CLIENT_PROVIDER = exports.NodeProxyClient = exports.ClientHttpFactory = void 0;
28
+ exports.StreamingCapabilityError = exports.ClientFilterDefinition = exports.ClientRequest = exports.TranslatedFailure = exports.ClientErrorTranslator = exports.ProxyClient = exports.DnsAddressResolver = exports.AddressResolver = exports.InternalAddressRules = exports.OutboundAuthFilter = exports.SsrfRefusedError = exports.SsrfGuardFilter = exports.SsrfTestingPolicy = exports.SsrfPolicy = exports.WEBHOOK_SIGNER_CALLBACK = exports.SignableRequest = exports.WebhookSignerCallback = exports.MissingWebhookSignerError = exports.MissingSharedSecretError = exports.MissingRuntimeBaseUrlError = exports.ContextFullUrlFilter = exports.ContextBaseUrlFilter = exports.ClientConfig = exports.NODE_PROXY_CLIENT_PROVIDER = exports.NodeProxyClient = exports.ClientHttpFactory = void 0;
29
29
  var ClientHttpFactory_1 = require("./ClientHttpFactory");
30
30
  Object.defineProperty(exports, "ClientHttpFactory", { enumerable: true, get: function () { return ClientHttpFactory_1.ClientHttpFactory; } });
31
31
  var NodeProxyClient_1 = require("./NodeProxyClient");
@@ -37,6 +37,10 @@ Object.defineProperty(exports, "ClientConfig", { enumerable: true, get: function
37
37
  // without it ignores an ambient OVERRIDE_BASE_URL entirely.
38
38
  var ContextBaseUrlFilter_1 = require("./ContextBaseUrlFilter");
39
39
  Object.defineProperty(exports, "ContextBaseUrlFilter", { enumerable: true, get: function () { return ContextBaseUrlFilter_1.ContextBaseUrlFilter; } });
40
+ // The same opt-in for a COMPLETE stored url (host + path + query), sent verbatim with no contract
41
+ // path appended — the usual partner-webhook shape. Reads OVERRIDE_FULL_URL.
42
+ var ContextFullUrlFilter_1 = require("./ContextFullUrlFilter");
43
+ Object.defineProperty(exports, "ContextFullUrlFilter", { enumerable: true, get: function () { return ContextFullUrlFilter_1.ContextFullUrlFilter; } });
40
44
  var MissingRuntimeBaseUrlError_1 = require("./MissingRuntimeBaseUrlError");
41
45
  Object.defineProperty(exports, "MissingRuntimeBaseUrlError", { enumerable: true, get: function () { return MissingRuntimeBaseUrlError_1.MissingRuntimeBaseUrlError; } });
42
46
  // The two ways outbound auth refuses to send: a binding a contract's auth mode requires is absent.
package/src/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;;;AAEH,yDAAwD;AAA/C,sHAAA,iBAAiB,OAAA;AAC1B,qDAAgF;AAAvE,kHAAA,eAAe,OAAA;AAAE,6HAAA,0BAA0B,OAAA;AACpD,+CAA8C;AAArC,4GAAA,YAAY,OAAA;AAErB,kGAAkG;AAClG,4DAA4D;AAC5D,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA;AAC7B,2EAA0E;AAAjE,wIAAA,0BAA0B,OAAA;AAEnC,mGAAmG;AACnG,kGAAkG;AAClG,6DAA6D;AAC7D,2DAA2F;AAAlF,8HAAA,wBAAwB,OAAA;AAAE,+HAAA,yBAAyB,OAAA;AAE5D,qGAAqG;AACrG,qFAAqF;AACrF,iEAIiC;AAH7B,8HAAA,qBAAqB,OAAA;AACrB,wHAAA,eAAe,OAAA;AACf,gIAAA,uBAAuB,OAAA;AAG3B,qGAAqG;AACrG,+FAA+F;AAC/F,2CAA6D;AAApD,wGAAA,UAAU,OAAA;AAAE,+GAAA,iBAAiB,OAAA;AACtC,qDAAoD;AAA3C,kHAAA,eAAe,OAAA;AACxB,uDAAsD;AAA7C,oHAAA,gBAAgB,OAAA;AACzB,2DAA0D;AAAjD,wHAAA,kBAAkB,OAAA;AAC3B,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA;AAC7B,qDAAwE;AAA/D,kHAAA,eAAe,OAAA;AAAE,qHAAA,kBAAkB,OAAA;AAE5C,uEAAuE;AACvE,gEAAoG;AAA3F,+GAAA,WAAW,OAAA;AAAE,yHAAA,qBAAqB,OAAA;AAAE,qHAAA,iBAAiB,OAAA;AAC9D,gEAAoF;AAA3E,iHAAA,aAAa,OAAA;AAAE,0HAAA,sBAAsB,OAAA;AAG9C,gEAAuE;AAA9D,4HAAA,wBAAwB,OAAA","sourcesContent":["/**\n * @webpieces/http-client-node\n *\n * The SERVER-side HTTP client. Reads an API contract's decorators and generates type-safe HTTP\n * clients from it — the same contract the callee's controller implements.\n *\n * Node-only, so unlike @webpieces/http-client-browser it is fully inversify-wired and reads the\n * magic context straight out of the AsyncLocalStorage-backed RequestContext. There is no\n * ContextReader indirection, because a server has exactly one right answer, and a call made\n * OUTSIDE `RequestContext.run(...)` throws instead of silently dropping the trace.\n *\n * Usage:\n * ```typescript\n * import { ClientHttpFactory, ClientConfig } from '@webpieces/http-client-node';\n *\n * // inject the factory, then one client per contract\n * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));\n * const response = await server2.fetchValue(req);\n *\n * // a client whose destination arrives per call: ONE filter, and nothing else changes\n * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [\n * new ClientFilterDefinition(1000, new ContextBaseUrlFilter()),\n * ]);\n * ```\n */\n\nexport { ClientHttpFactory } from './ClientHttpFactory';\nexport { NodeProxyClient, NODE_PROXY_CLIENT_PROVIDER } from './NodeProxyClient';\nexport { ClientConfig } from './ClientConfig';\n\n// Install this on the ONE client whose destination is data. Installing it IS the opt-in; a client\n// without it ignores an ambient OVERRIDE_BASE_URL entirely.\nexport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\nexport { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';\n\n// The two ways outbound auth refuses to send: a binding a contract's auth mode requires is absent.\n// Typed, so a delivery worker tells 'we are misconfigured' from 'the partner registered something\n// hostile' (SsrfRefusedError) without matching message text.\nexport { MissingSharedSecretError, MissingWebhookSignerError } from './OutboundAuthErrors';\n\n// The OUTBOUND half of @WpAuthWebhook(name) — the mirror of http-routing's WebhookAuthCallback. Bind\n// one, or every outbound @WpAuthWebhook call throws rather than delivering unsigned.\nexport {\n WebhookSignerCallback,\n SignableRequest,\n WEBHOOK_SIGNER_CALLBACK,\n} from './WebhookSignerCallback';\n\n// The SSRF policy a re-pointed URL is judged under, and the refusal it produces. Automatic, armed by\n// the ACT of re-pointing; the ONLY way to relax it is naming SsrfTestingPolicy at a call site.\nexport { SsrfPolicy, SsrfTestingPolicy } from './SsrfPolicy';\nexport { SsrfGuardFilter } from './SsrfGuardFilter';\nexport { SsrfRefusedError } from './SsrfRefusedError';\nexport { OutboundAuthFilter } from './OutboundAuthFilter';\nexport { InternalAddressRules } from './InternalAddressRules';\nexport { AddressResolver, DnsAddressResolver } from './AddressResolver';\n\n// The isomorphic engine, re-exported so a server app needs one import.\nexport { ProxyClient, ClientErrorTranslator, TranslatedFailure } from '@webpieces/http-client-core';\nexport { ClientRequest, ClientFilterDefinition } from '@webpieces/http-client-core';\nexport type { ClientFilter } from '@webpieces/http-client-core';\nexport type { ApiPrototype } from '@webpieces/http-client-core';\nexport { StreamingCapabilityError } from '@webpieces/http-client-core';\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;;;AAEH,yDAAwD;AAA/C,sHAAA,iBAAiB,OAAA;AAC1B,qDAAgF;AAAvE,kHAAA,eAAe,OAAA;AAAE,6HAAA,0BAA0B,OAAA;AACpD,+CAA8C;AAArC,4GAAA,YAAY,OAAA;AAErB,kGAAkG;AAClG,4DAA4D;AAC5D,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA;AAC7B,kGAAkG;AAClG,4EAA4E;AAC5E,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA;AAC7B,2EAA0E;AAAjE,wIAAA,0BAA0B,OAAA;AAEnC,mGAAmG;AACnG,kGAAkG;AAClG,6DAA6D;AAC7D,2DAA2F;AAAlF,8HAAA,wBAAwB,OAAA;AAAE,+HAAA,yBAAyB,OAAA;AAE5D,qGAAqG;AACrG,qFAAqF;AACrF,iEAIiC;AAH7B,8HAAA,qBAAqB,OAAA;AACrB,wHAAA,eAAe,OAAA;AACf,gIAAA,uBAAuB,OAAA;AAG3B,qGAAqG;AACrG,+FAA+F;AAC/F,2CAA6D;AAApD,wGAAA,UAAU,OAAA;AAAE,+GAAA,iBAAiB,OAAA;AACtC,qDAAoD;AAA3C,kHAAA,eAAe,OAAA;AACxB,uDAAsD;AAA7C,oHAAA,gBAAgB,OAAA;AACzB,2DAA0D;AAAjD,wHAAA,kBAAkB,OAAA;AAC3B,+DAA8D;AAArD,4HAAA,oBAAoB,OAAA;AAC7B,qDAAwE;AAA/D,kHAAA,eAAe,OAAA;AAAE,qHAAA,kBAAkB,OAAA;AAE5C,uEAAuE;AACvE,gEAAoG;AAA3F,+GAAA,WAAW,OAAA;AAAE,yHAAA,qBAAqB,OAAA;AAAE,qHAAA,iBAAiB,OAAA;AAC9D,gEAAoF;AAA3E,iHAAA,aAAa,OAAA;AAAE,0HAAA,sBAAsB,OAAA;AAG9C,gEAAuE;AAA9D,4HAAA,wBAAwB,OAAA","sourcesContent":["/**\n * @webpieces/http-client-node\n *\n * The SERVER-side HTTP client. Reads an API contract's decorators and generates type-safe HTTP\n * clients from it — the same contract the callee's controller implements.\n *\n * Node-only, so unlike @webpieces/http-client-browser it is fully inversify-wired and reads the\n * magic context straight out of the AsyncLocalStorage-backed RequestContext. There is no\n * ContextReader indirection, because a server has exactly one right answer, and a call made\n * OUTSIDE `RequestContext.run(...)` throws instead of silently dropping the trace.\n *\n * Usage:\n * ```typescript\n * import { ClientHttpFactory, ClientConfig } from '@webpieces/http-client-node';\n *\n * // inject the factory, then one client per contract\n * const server2 = factory.createRpcClient(Server2Api, new ClientConfig('server2'));\n * const response = await server2.fetchValue(req);\n *\n * // a client whose destination arrives per call: ONE filter, and nothing else changes\n * const partner = factory.createRpcClient(PartnerWebhookApi, new ClientConfig('partner-webhooks'), [\n * new ClientFilterDefinition(1000, new ContextFullUrlFilter()),\n * ]);\n * ```\n */\n\nexport { ClientHttpFactory } from './ClientHttpFactory';\nexport { NodeProxyClient, NODE_PROXY_CLIENT_PROVIDER } from './NodeProxyClient';\nexport { ClientConfig } from './ClientConfig';\n\n// Install this on the ONE client whose destination is data. Installing it IS the opt-in; a client\n// without it ignores an ambient OVERRIDE_BASE_URL entirely.\nexport { ContextBaseUrlFilter } from './ContextBaseUrlFilter';\n// The same opt-in for a COMPLETE stored url (host + path + query), sent verbatim with no contract\n// path appended — the usual partner-webhook shape. Reads OVERRIDE_FULL_URL.\nexport { ContextFullUrlFilter } from './ContextFullUrlFilter';\nexport { MissingRuntimeBaseUrlError } from './MissingRuntimeBaseUrlError';\n\n// The two ways outbound auth refuses to send: a binding a contract's auth mode requires is absent.\n// Typed, so a delivery worker tells 'we are misconfigured' from 'the partner registered something\n// hostile' (SsrfRefusedError) without matching message text.\nexport { MissingSharedSecretError, MissingWebhookSignerError } from './OutboundAuthErrors';\n\n// The OUTBOUND half of @WpAuthWebhook(name) — the mirror of http-routing's WebhookAuthCallback. Bind\n// one, or every outbound @WpAuthWebhook call throws rather than delivering unsigned.\nexport {\n WebhookSignerCallback,\n SignableRequest,\n WEBHOOK_SIGNER_CALLBACK,\n} from './WebhookSignerCallback';\n\n// The SSRF policy a re-pointed URL is judged under, and the refusal it produces. Automatic, armed by\n// the ACT of re-pointing; the ONLY way to relax it is naming SsrfTestingPolicy at a call site.\nexport { SsrfPolicy, SsrfTestingPolicy } from './SsrfPolicy';\nexport { SsrfGuardFilter } from './SsrfGuardFilter';\nexport { SsrfRefusedError } from './SsrfRefusedError';\nexport { OutboundAuthFilter } from './OutboundAuthFilter';\nexport { InternalAddressRules } from './InternalAddressRules';\nexport { AddressResolver, DnsAddressResolver } from './AddressResolver';\n\n// The isomorphic engine, re-exported so a server app needs one import.\nexport { ProxyClient, ClientErrorTranslator, TranslatedFailure } from '@webpieces/http-client-core';\nexport { ClientRequest, ClientFilterDefinition } from '@webpieces/http-client-core';\nexport type { ClientFilter } from '@webpieces/http-client-core';\nexport type { ApiPrototype } from '@webpieces/http-client-core';\nexport { StreamingCapabilityError } from '@webpieces/http-client-core';\n"]}