@webpieces/http-client-node 0.4.700 → 0.4.701
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -51
- package/package.json +5 -5
- package/src/AddressResolver.d.ts +11 -1
- package/src/AddressResolver.js +12 -2
- package/src/AddressResolver.js.map +1 -1
- package/src/ClientConfig.d.ts +30 -36
- package/src/ClientConfig.js +21 -21
- package/src/ClientConfig.js.map +1 -1
- package/src/ClientHttpFactory.d.ts +30 -18
- package/src/ClientHttpFactory.js +29 -17
- package/src/ClientHttpFactory.js.map +1 -1
- package/src/ContextBaseUrlFilter.d.ts +80 -0
- package/src/ContextBaseUrlFilter.js +95 -0
- package/src/ContextBaseUrlFilter.js.map +1 -0
- package/src/CreateRpcClientCompileAssertions.d.ts +17 -0
- package/src/CreateRpcClientCompileAssertions.js +50 -0
- package/src/CreateRpcClientCompileAssertions.js.map +1 -0
- package/src/MissingRuntimeBaseUrlError.d.ts +20 -0
- package/src/MissingRuntimeBaseUrlError.js +28 -0
- package/src/MissingRuntimeBaseUrlError.js.map +1 -0
- package/src/NodeProxyClient.d.ts +26 -27
- package/src/NodeProxyClient.js +60 -47
- package/src/NodeProxyClient.js.map +1 -1
- package/src/OutboundAuthErrors.d.ts +42 -0
- package/src/OutboundAuthErrors.js +54 -0
- package/src/OutboundAuthErrors.js.map +1 -0
- package/src/OutboundAuthFilter.d.ts +56 -0
- package/src/OutboundAuthFilter.js +103 -0
- package/src/OutboundAuthFilter.js.map +1 -0
- package/src/SsrfGuardFilter.d.ts +11 -0
- package/src/SsrfGuardFilter.js +23 -4
- package/src/SsrfGuardFilter.js.map +1 -1
- package/src/SsrfPolicy.d.ts +39 -45
- package/src/SsrfPolicy.js +49 -34
- package/src/SsrfPolicy.js.map +1 -1
- package/src/WebhookSignerCallback.d.ts +114 -0
- package/src/WebhookSignerCallback.js +106 -0
- package/src/WebhookSignerCallback.js.map +1 -0
- package/src/index.d.ts +13 -10
- package/src/index.js +30 -24
- package/src/index.js.map +1 -1
- package/src/ContextBaseUrlOverrideFilter.d.ts +0 -38
- package/src/ContextBaseUrlOverrideFilter.js +0 -57
- package/src/ContextBaseUrlOverrideFilter.js.map +0 -1
- package/src/HostPolicy.d.ts +0 -118
- package/src/HostPolicy.js +0 -169
- package/src/HostPolicy.js.map +0 -1
- package/src/RuntimeHostErrors.d.ts +0 -42
- package/src/RuntimeHostErrors.js +0 -54
- package/src/RuntimeHostErrors.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"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 {\n AuthMeta,\n ClientRegistry,\n DestinationTrust,\n HttpInternalServerError,\n RecordedEndpoint,\n RecordedError,\n RouteMetadata,\n Secrets,\n SECRETS,\n TestCaseRecorder,\n toError,\n} from '@webpieces/core-util';\nimport { RequestContext, RequestContextHeaders, provideFrameworkTransient } from '@webpieces/core-context';\nimport { GcpOidc } from '@webpieces/gcp-identity';\nimport { ApiPrototype, ClientFilterDefinition, ProxyClient, TranslatedFailure } from '@webpieces/http-client-core';\nimport { ClientConfig } from './ClientConfig';\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 // @optional: only @AuthSharedSecret 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 ) {\n super();\n }\n\n /**\n * Bind this client to one API contract + target, with the app's outbound filters.\n *\n * `appFilters` is REQUIRED, not defaulted: an empty array is a statement that this client signs\n * nothing and rewrites nothing, and it should be written down rather than inferred from an\n * omitted argument.\n */\n init(apiPrototype: ApiPrototype<object>, config: ClientConfig, appFilters: ClientFilterDefinition[]): 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 this.config.hostPolicy.resolveBaseUrl(this.config.svcName);\n }\n\n /**\n * The framework filters this client's {@link HostPolicy} demands — none for a deployed service,\n * the context override plus the SSRF guard for a runtime host. Delegated rather than decided\n * here so the two halves of \"where does this go\" (resolution and enforcement) cannot drift\n * apart into different policies.\n */\n protected override clientFilters(): ClientFilterDefinition[] {\n return this.config.hostPolicy.builtInFilters();\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 (@AuthOidc /\n * @AuthSharedSecret). Calling a peer's @Public or @AuthJwt 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 * Attach the outbound credential for the endpoint's AuthMode: an @AuthOidc bearer minted as\n * this caller's runtime SA (audience = the callee base URL — the server verifies the signature\n * + caller allow-list), or the @AuthSharedSecret(key) value THIS client sends from its bound\n * {@link Secrets}. Both ride in the ONE `Authorization` header under their own scheme —\n * `Bearer <oidc>` / `Webpieces <secret>` — which is never a context key, so it cannot leak onto\n * the next hop. Never reads process.env.\n */\n protected override async attachOutboundAuth(\n route: RouteMetadata,\n baseUrl: string,\n httpHeaders: Map<string, string>,\n ): Promise<void> {\n const mode = route.authMeta?.mode;\n if (mode?.kind === 'oidc') {\n httpHeaders.set('Authorization', `Bearer ${await this.gcpOidc.mintIdToken(baseUrl)}`);\n } else if (mode?.kind === 'shared-secret') {\n const secret = this.secrets?.get(mode.secretKey);\n if (!secret) {\n throw new Error(\n `No shared secret configured for @AuthSharedSecret('${mode.secretKey}') endpoint ${route.methodName}`,\n );\n }\n // Same header as a JWT/OIDC token, but its OWN scheme, so a secret can never be\n // mistaken for a token nor accepted where one was expected.\n httpHeaders.set('Authorization', `Webpieces ${secret}`);\n }\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 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(this.contractName(), route.methodName, [requestDto], ctxSnapshot);\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 * A server can satisfy every auth mode when it is talking to a peer it CHOSE, so the deployed\n * policy rejects nothing. A runtime-host policy does reject: an OIDC token's audience and a\n * shared secret both name a peer, and a destination that arrives per call has no honest one —\n * see {@link HostPolicy.assertEndpointSupported}.\n */\n protected override assertEndpointSupported(authMeta: AuthMeta | undefined, methodName: string): void {\n this.config.hostPolicy.assertEndpointSupported(authMeta, methodName, this.contractName());\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 `HttpNotFoundError` 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. `HttpUserError` (266, a 2xx code carrying\n * user validation) and `HttpVendorError` (598) 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 `httpCause`.\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 * `HttpErrorWireMapper` in http-server — only `HttpUserError`'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 `HttpInternalServerError`, 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 HttpInternalServerError(\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,oDAW8B;AAC9B,0DAA2G;AAC3G,0DAAkD;AAClD,kEAAmH;AACnH,uDAAoD;AAEpD,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;IAIgB;IAf1D,MAAM,CAAgB;IAE9B,YAEoD,OAA8B,EAE5C,OAAgB,EAER,eAAgC,EAG5B,OAAiB,EAID,aAAqC;QAEnG,KAAK,EAAE,CAAC;QAbwC,YAAO,GAAP,OAAO,CAAuB;QAE5C,YAAO,GAAP,OAAO,CAAS;QAER,oBAAe,GAAf,eAAe,CAAiB;QAG5B,YAAO,GAAP,OAAO,CAAU;QAID,kBAAa,GAAb,aAAa,CAAwB;IAGvG,CAAC;IAED;;;OAGG;IACH,IAAI,CAAC,YAAkC,EAAE,MAAoB,EAAE,UAAoC;QAC/F,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;;;;;;;;OAQG;IACgB,aAAa;QAC5B,OAAO;YACH,IAAI,yCAAsB,CAAC,mBAAmB,EAAE,IAAI,iCAAe,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,IAAI,CAAC,eAAe,CAAC,CAAC;YAC7G,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;;;;;;;;;OASG;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;gBAAE,OAAO,MAAM,CAAC,UAAU,CAAC;QACzE,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,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,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,KAAK,CAAC,UAAU,EAAE,CAAC,UAAU,CAAC,EAAE,WAAW,CAAC,CAAC;QACxG,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,mCAAuB,CAC9B,GAAG,MAAM,8BAA8B,OAAO,CAAC,UAAU,8BAA8B;YACvF,2FAA2F;YAC3F,uFAAuF;YACvF,oBAAoB,OAAO,CAAC,KAAK,CAAC,OAAO,EAAE,EAC3C,OAAO,CAAC,KAAK,CAChB,CAAC;IACN,CAAC;CACJ,CAAA;AA/MY,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;IAAE,mBAAA,IAAA,kBAAM,EAAC,+CAAuB,CAAC,CAAA;6CAXa,oCAAqB;QAEnC,sBAAO;QAES,iCAAe;QAGlB,mBAAO;QAIe,6CAAqB;GAhB9F,eAAe,CA+M3B;AAED;;;;;;;GAOG;AACH,gGAAgG;AACnF,QAAA,0BAA0B,GAAG,MAAM,CAAC,GAAG,CAAC,2BAA2B,CAAC,CAAC","sourcesContent":["import { inject, optional } from 'inversify';\nimport {\n ClientRegistry,\n DestinationTrust,\n HttpInternalServerError,\n RecordedEndpoint,\n RecordedError,\n RouteMetadata,\n Secrets,\n SECRETS,\n TestCaseRecorder,\n toError,\n} from '@webpieces/core-util';\nimport { RequestContext, RequestContextHeaders, provideFrameworkTransient } from '@webpieces/core-context';\nimport { GcpOidc } from '@webpieces/gcp-identity';\nimport { ApiPrototype, ClientFilterDefinition, ProxyClient, TranslatedFailure } 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 @AuthSharedSecret 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 @AuthWebhook 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() @inject(WEBHOOK_SIGNER_CALLBACK) private readonly webhookSigner?: WebhookSignerCallback,\n ) {\n super();\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(apiPrototype: ApiPrototype<object>, config: ClientConfig, appFilters: ClientFilterDefinition[]): 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 /**\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 `@Public` 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(SSRF_GUARD_PRIORITY, new SsrfGuardFilter(this.ssrfPolicy(), this.addressResolver)),\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 (@AuthOidc /\n * @AuthSharedSecret). Calling a peer's @Public or @AuthJwt 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 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(this.contractName(), route.methodName, [requestDto], ctxSnapshot);\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 `HttpNotFoundError` 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. `HttpUserError` (266, a 2xx code carrying\n * user validation) and `HttpVendorError` (598) 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 `httpCause`.\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 * `HttpErrorWireMapper` in http-server — only `HttpUserError`'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 `HttpInternalServerError`, 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 HttpInternalServerError(\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"]}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two ways outbound auth refuses to send, each its own type for the reason
|
|
3
|
+
* {@link MissingRuntimeBaseUrlError} is one: a delivery worker has to tell these apart from
|
|
4
|
+
* {@link SsrfRefusedError}. Both mean THIS SERVICE is misconfigured — page somebody, retrying is
|
|
5
|
+
* pointless — where an SSRF refusal means a partner registered something hostile and the delivery
|
|
6
|
+
* should be dead-lettered. A bare `Error` forces that decision to be made by matching message text.
|
|
7
|
+
*
|
|
8
|
+
* Neither is thrown for anything a caller can influence: they fire when the binding a contract's
|
|
9
|
+
* auth mode requires is simply absent.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* An `@AuthSharedSecret(key)` endpoint was called by a client whose bound `Secrets` holds no value
|
|
13
|
+
* for that key. Thrown at CALL time, from `OutboundAuthFilter`.
|
|
14
|
+
*/
|
|
15
|
+
export declare class MissingSharedSecretError extends Error {
|
|
16
|
+
/** `Contract.method`, so the log line names the call without a stack read. */
|
|
17
|
+
readonly endpoint: string;
|
|
18
|
+
/** The `@AuthSharedSecret` key that had no value, so the fix names itself. */
|
|
19
|
+
readonly secretKey: string;
|
|
20
|
+
constructor(message: string,
|
|
21
|
+
/** `Contract.method`, so the log line names the call without a stack read. */
|
|
22
|
+
endpoint: string,
|
|
23
|
+
/** The `@AuthSharedSecret` key that had no value, so the fix names itself. */
|
|
24
|
+
secretKey: string);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* An `@AuthWebhook(name)` endpoint was called outbound with no `WebhookSignerCallback` bound, so
|
|
28
|
+
* nothing can produce the signature the partner verifies. Thrown at CALL time, from
|
|
29
|
+
* `OutboundAuthFilter`, rather than delivering unsigned — the mirror of the inbound side, where an
|
|
30
|
+
* unbound `WebhookAuthCallback` 401s every `@AuthWebhook` endpoint instead of admitting it.
|
|
31
|
+
*/
|
|
32
|
+
export declare class MissingWebhookSignerError extends Error {
|
|
33
|
+
/** `Contract.method`, so the log line names the call without a stack read. */
|
|
34
|
+
readonly endpoint: string;
|
|
35
|
+
/** The vendor on the contract's `@AuthWebhook(name)`, which selects the scheme. */
|
|
36
|
+
readonly webhookName: string;
|
|
37
|
+
constructor(message: string,
|
|
38
|
+
/** `Contract.method`, so the log line names the call without a stack read. */
|
|
39
|
+
endpoint: string,
|
|
40
|
+
/** The vendor on the contract's `@AuthWebhook(name)`, which selects the scheme. */
|
|
41
|
+
webhookName: string);
|
|
42
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The two ways outbound auth refuses to send, each its own type for the reason
|
|
4
|
+
* {@link MissingRuntimeBaseUrlError} is one: a delivery worker has to tell these apart from
|
|
5
|
+
* {@link SsrfRefusedError}. Both mean THIS SERVICE is misconfigured — page somebody, retrying is
|
|
6
|
+
* pointless — where an SSRF refusal means a partner registered something hostile and the delivery
|
|
7
|
+
* should be dead-lettered. A bare `Error` forces that decision to be made by matching message text.
|
|
8
|
+
*
|
|
9
|
+
* Neither is thrown for anything a caller can influence: they fire when the binding a contract's
|
|
10
|
+
* auth mode requires is simply absent.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.MissingWebhookSignerError = exports.MissingSharedSecretError = void 0;
|
|
14
|
+
/**
|
|
15
|
+
* An `@AuthSharedSecret(key)` endpoint was called by a client whose bound `Secrets` holds no value
|
|
16
|
+
* for that key. Thrown at CALL time, from `OutboundAuthFilter`.
|
|
17
|
+
*/
|
|
18
|
+
class MissingSharedSecretError extends Error {
|
|
19
|
+
endpoint;
|
|
20
|
+
secretKey;
|
|
21
|
+
constructor(message,
|
|
22
|
+
/** `Contract.method`, so the log line names the call without a stack read. */
|
|
23
|
+
endpoint,
|
|
24
|
+
/** The `@AuthSharedSecret` key that had no value, so the fix names itself. */
|
|
25
|
+
secretKey) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.endpoint = endpoint;
|
|
28
|
+
this.secretKey = secretKey;
|
|
29
|
+
this.name = 'MissingSharedSecretError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
exports.MissingSharedSecretError = MissingSharedSecretError;
|
|
33
|
+
/**
|
|
34
|
+
* An `@AuthWebhook(name)` endpoint was called outbound with no `WebhookSignerCallback` bound, so
|
|
35
|
+
* nothing can produce the signature the partner verifies. Thrown at CALL time, from
|
|
36
|
+
* `OutboundAuthFilter`, rather than delivering unsigned — the mirror of the inbound side, where an
|
|
37
|
+
* unbound `WebhookAuthCallback` 401s every `@AuthWebhook` endpoint instead of admitting it.
|
|
38
|
+
*/
|
|
39
|
+
class MissingWebhookSignerError extends Error {
|
|
40
|
+
endpoint;
|
|
41
|
+
webhookName;
|
|
42
|
+
constructor(message,
|
|
43
|
+
/** `Contract.method`, so the log line names the call without a stack read. */
|
|
44
|
+
endpoint,
|
|
45
|
+
/** The vendor on the contract's `@AuthWebhook(name)`, which selects the scheme. */
|
|
46
|
+
webhookName) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.endpoint = endpoint;
|
|
49
|
+
this.webhookName = webhookName;
|
|
50
|
+
this.name = 'MissingWebhookSignerError';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
exports.MissingWebhookSignerError = MissingWebhookSignerError;
|
|
54
|
+
//# sourceMappingURL=OutboundAuthErrors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OutboundAuthErrors.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/OutboundAuthErrors.ts"],"names":[],"mappings":";AAAA;;;;;;;;;GASG;;;AAEH;;;GAGG;AACH,MAAa,wBAAyB,SAAQ,KAAK;IAI3B;IAEA;IALpB,YACI,OAAe;IACf,8EAA8E;IAC9D,QAAgB;IAChC,8EAA8E;IAC9D,SAAiB;QAEjC,KAAK,CAAC,OAAO,CAAC,CAAC;QAJC,aAAQ,GAAR,QAAQ,CAAQ;QAEhB,cAAS,GAAT,SAAS,CAAQ;QAGjC,IAAI,CAAC,IAAI,GAAG,0BAA0B,CAAC;IAC3C,CAAC;CACJ;AAXD,4DAWC;AAED;;;;;GAKG;AACH,MAAa,yBAA0B,SAAQ,KAAK;IAI5B;IAEA;IALpB,YACI,OAAe;IACf,8EAA8E;IAC9D,QAAgB;IAChC,mFAAmF;IACnE,WAAmB;QAEnC,KAAK,CAAC,OAAO,CAAC,CAAC;QAJC,aAAQ,GAAR,QAAQ,CAAQ;QAEhB,gBAAW,GAAX,WAAW,CAAQ;QAGnC,IAAI,CAAC,IAAI,GAAG,2BAA2B,CAAC;IAC5C,CAAC;CACJ;AAXD,8DAWC","sourcesContent":["/**\n * The two ways outbound auth refuses to send, each its own type for the reason\n * {@link MissingRuntimeBaseUrlError} is one: a delivery worker has to tell these apart from\n * {@link SsrfRefusedError}. Both mean THIS SERVICE is misconfigured — page somebody, retrying is\n * pointless — where an SSRF refusal means a partner registered something hostile and the delivery\n * should be dead-lettered. A bare `Error` forces that decision to be made by matching message text.\n *\n * Neither is thrown for anything a caller can influence: they fire when the binding a contract's\n * auth mode requires is simply absent.\n */\n\n/**\n * An `@AuthSharedSecret(key)` endpoint was called by a client whose bound `Secrets` holds no value\n * for that key. Thrown at CALL time, from `OutboundAuthFilter`.\n */\nexport class MissingSharedSecretError extends Error {\n constructor(\n message: string,\n /** `Contract.method`, so the log line names the call without a stack read. */\n public readonly endpoint: string,\n /** The `@AuthSharedSecret` key that had no value, so the fix names itself. */\n public readonly secretKey: string,\n ) {\n super(message);\n this.name = 'MissingSharedSecretError';\n }\n}\n\n/**\n * An `@AuthWebhook(name)` endpoint was called outbound with no `WebhookSignerCallback` bound, so\n * nothing can produce the signature the partner verifies. Thrown at CALL time, from\n * `OutboundAuthFilter`, rather than delivering unsigned — the mirror of the inbound side, where an\n * unbound `WebhookAuthCallback` 401s every `@AuthWebhook` endpoint instead of admitting it.\n */\nexport class MissingWebhookSignerError extends Error {\n constructor(\n message: string,\n /** `Contract.method`, so the log line names the call without a stack read. */\n public readonly endpoint: string,\n /** The vendor on the contract's `@AuthWebhook(name)`, which selects the scheme. */\n public readonly webhookName: string,\n ) {\n super(message);\n this.name = 'MissingWebhookSignerError';\n }\n}\n"]}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Filter, Secrets, Service } from '@webpieces/core-util';
|
|
2
|
+
import { GcpOidc } from '@webpieces/gcp-identity';
|
|
3
|
+
import { ClientRequest } from '@webpieces/http-client-core';
|
|
4
|
+
import { WebhookSignerCallback } from './WebhookSignerCallback';
|
|
5
|
+
/**
|
|
6
|
+
* Attaches the endpoint's outbound credential, against the destination the call is ACTUALLY going
|
|
7
|
+
* to.
|
|
8
|
+
*
|
|
9
|
+
* ## Why this is a filter, and why it is the LAST one
|
|
10
|
+
*
|
|
11
|
+
* It used to be a method on the client, called before the request object even existed — so it
|
|
12
|
+
* minted against the URL the client resolved at bind time, which is the URL BEFORE any filter had
|
|
13
|
+
* run. That is wrong the moment a filter can re-point the request: an OIDC token's audience is the
|
|
14
|
+
* callee's base URL, and a token minted for our own service name and then sent to a partner's
|
|
15
|
+
* server is a credential handed to the wrong party.
|
|
16
|
+
*
|
|
17
|
+
* As the innermost filter (`ProxyClient.initRoutes` puts the framework's built-ins beneath every app
|
|
18
|
+
* filter, and no app priority can get under them) it reads `request.baseUrl` / `request.url` after
|
|
19
|
+
* everything has settled, which makes the audience correct by construction rather than by
|
|
20
|
+
* convention. The SSRF guard sits immediately ABOVE it, so a destination that is going to be refused
|
|
21
|
+
* is refused BEFORE any credential is minted for it.
|
|
22
|
+
*
|
|
23
|
+
* ## The three modes, and why none of them is restricted to a fixed host
|
|
24
|
+
*
|
|
25
|
+
* - `@AuthOidc` → a bearer token minted as this caller's runtime SA, audience = the final base URL.
|
|
26
|
+
* - `@AuthSharedSecret` → the value this client holds for that key, as `Authorization: Webpieces …`.
|
|
27
|
+
* Legitimate against a re-pointed URL: N services implementing ONE contract behind ONE agreed
|
|
28
|
+
* secret is a real and common topology, and often stands in for OIDC where OIDC is not available.
|
|
29
|
+
* - `@AuthWebhook(name)` → the app's bound {@link WebhookSignerCallback} produces the headers. WE
|
|
30
|
+
* are the vendor on this side; see that class.
|
|
31
|
+
*
|
|
32
|
+
* Both credential-minting modes ride in the ONE `Authorization` header under their own scheme —
|
|
33
|
+
* `Bearer <oidc>` / `Webpieces <secret>` — which is never a context key, so neither can leak onto
|
|
34
|
+
* the next hop.
|
|
35
|
+
*/
|
|
36
|
+
export declare class OutboundAuthFilter extends Filter<ClientRequest, Response> {
|
|
37
|
+
private readonly gcpOidc;
|
|
38
|
+
/** Only @AuthSharedSecret endpoints need it; a server that has none binds nothing. */
|
|
39
|
+
private readonly secrets;
|
|
40
|
+
/** Only @AuthWebhook endpoints need it, and an unbound one makes them THROW. */
|
|
41
|
+
private readonly webhookSigner;
|
|
42
|
+
constructor(gcpOidc: GcpOidc,
|
|
43
|
+
/** Only @AuthSharedSecret endpoints need it; a server that has none binds nothing. */
|
|
44
|
+
secrets: Secrets | undefined,
|
|
45
|
+
/** Only @AuthWebhook endpoints need it, and an unbound one makes them THROW. */
|
|
46
|
+
webhookSigner: WebhookSignerCallback | undefined);
|
|
47
|
+
filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response>;
|
|
48
|
+
private attach;
|
|
49
|
+
/**
|
|
50
|
+
* @throws MissingWebhookSignerError when no {@link WebhookSignerCallback} is bound. FAIL CLOSED,
|
|
51
|
+
* matching the inbound side exactly: an unbound `WebhookAuthCallback` 401s every
|
|
52
|
+
* `@AuthWebhook` endpoint rather than admitting it unverified, so an unbound signer must
|
|
53
|
+
* refuse to send rather than deliver something the partner is obliged to reject.
|
|
54
|
+
*/
|
|
55
|
+
private signWebhook;
|
|
56
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.OutboundAuthFilter = void 0;
|
|
4
|
+
const core_util_1 = require("@webpieces/core-util");
|
|
5
|
+
const OutboundAuthErrors_1 = require("./OutboundAuthErrors");
|
|
6
|
+
const WebhookSignerCallback_1 = require("./WebhookSignerCallback");
|
|
7
|
+
/**
|
|
8
|
+
* Attaches the endpoint's outbound credential, against the destination the call is ACTUALLY going
|
|
9
|
+
* to.
|
|
10
|
+
*
|
|
11
|
+
* ## Why this is a filter, and why it is the LAST one
|
|
12
|
+
*
|
|
13
|
+
* It used to be a method on the client, called before the request object even existed — so it
|
|
14
|
+
* minted against the URL the client resolved at bind time, which is the URL BEFORE any filter had
|
|
15
|
+
* run. That is wrong the moment a filter can re-point the request: an OIDC token's audience is the
|
|
16
|
+
* callee's base URL, and a token minted for our own service name and then sent to a partner's
|
|
17
|
+
* server is a credential handed to the wrong party.
|
|
18
|
+
*
|
|
19
|
+
* As the innermost filter (`ProxyClient.initRoutes` puts the framework's built-ins beneath every app
|
|
20
|
+
* filter, and no app priority can get under them) it reads `request.baseUrl` / `request.url` after
|
|
21
|
+
* everything has settled, which makes the audience correct by construction rather than by
|
|
22
|
+
* convention. The SSRF guard sits immediately ABOVE it, so a destination that is going to be refused
|
|
23
|
+
* is refused BEFORE any credential is minted for it.
|
|
24
|
+
*
|
|
25
|
+
* ## The three modes, and why none of them is restricted to a fixed host
|
|
26
|
+
*
|
|
27
|
+
* - `@AuthOidc` → a bearer token minted as this caller's runtime SA, audience = the final base URL.
|
|
28
|
+
* - `@AuthSharedSecret` → the value this client holds for that key, as `Authorization: Webpieces …`.
|
|
29
|
+
* Legitimate against a re-pointed URL: N services implementing ONE contract behind ONE agreed
|
|
30
|
+
* secret is a real and common topology, and often stands in for OIDC where OIDC is not available.
|
|
31
|
+
* - `@AuthWebhook(name)` → the app's bound {@link WebhookSignerCallback} produces the headers. WE
|
|
32
|
+
* are the vendor on this side; see that class.
|
|
33
|
+
*
|
|
34
|
+
* Both credential-minting modes ride in the ONE `Authorization` header under their own scheme —
|
|
35
|
+
* `Bearer <oidc>` / `Webpieces <secret>` — which is never a context key, so neither can leak onto
|
|
36
|
+
* the next hop.
|
|
37
|
+
*/
|
|
38
|
+
class OutboundAuthFilter extends core_util_1.Filter {
|
|
39
|
+
gcpOidc;
|
|
40
|
+
secrets;
|
|
41
|
+
webhookSigner;
|
|
42
|
+
constructor(gcpOidc,
|
|
43
|
+
/** Only @AuthSharedSecret endpoints need it; a server that has none binds nothing. */
|
|
44
|
+
secrets,
|
|
45
|
+
/** Only @AuthWebhook endpoints need it, and an unbound one makes them THROW. */
|
|
46
|
+
webhookSigner) {
|
|
47
|
+
super();
|
|
48
|
+
this.gcpOidc = gcpOidc;
|
|
49
|
+
this.secrets = secrets;
|
|
50
|
+
this.webhookSigner = webhookSigner;
|
|
51
|
+
}
|
|
52
|
+
async filter(request, nextFilter) {
|
|
53
|
+
await this.attach(request);
|
|
54
|
+
return nextFilter.invoke(request);
|
|
55
|
+
}
|
|
56
|
+
async attach(request) {
|
|
57
|
+
const mode = request.route.authMeta?.mode;
|
|
58
|
+
if (mode?.kind === 'oidc') {
|
|
59
|
+
request.headers.set('Authorization', `Bearer ${await this.gcpOidc.mintIdToken(request.baseUrl)}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (mode?.kind === 'shared-secret') {
|
|
63
|
+
const secret = this.secrets?.get(mode.secretKey);
|
|
64
|
+
if (!secret) {
|
|
65
|
+
throw new OutboundAuthErrors_1.MissingSharedSecretError(`${request.contractName}.${request.route.methodName} is ` +
|
|
66
|
+
`@AuthSharedSecret('${mode.secretKey}'), but this client's bound Secrets holds no ` +
|
|
67
|
+
`value for that key, so there is no credential to send. Bind a Secrets carrying ` +
|
|
68
|
+
`'${mode.secretKey}'. Refusing to send is deliberate: the callee is obliged to 401 an ` +
|
|
69
|
+
`unauthenticated request, so sending it would report as the peer's failure.`, `${request.contractName}.${request.route.methodName}`, mode.secretKey);
|
|
70
|
+
}
|
|
71
|
+
// Same header as a JWT/OIDC token, but its OWN scheme, so a secret can never be
|
|
72
|
+
// mistaken for a token nor accepted where one was expected.
|
|
73
|
+
request.headers.set('Authorization', `Webpieces ${secret}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (mode?.kind === 'webhook') {
|
|
77
|
+
await this.signWebhook(request, mode.name);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* @throws MissingWebhookSignerError when no {@link WebhookSignerCallback} is bound. FAIL CLOSED,
|
|
82
|
+
* matching the inbound side exactly: an unbound `WebhookAuthCallback` 401s every
|
|
83
|
+
* `@AuthWebhook` endpoint rather than admitting it unverified, so an unbound signer must
|
|
84
|
+
* refuse to send rather than deliver something the partner is obliged to reject.
|
|
85
|
+
*/
|
|
86
|
+
async signWebhook(request, name) {
|
|
87
|
+
if (this.webhookSigner === undefined) {
|
|
88
|
+
throw new OutboundAuthErrors_1.MissingWebhookSignerError(`${request.contractName}.${request.route.methodName} is @AuthWebhook('${name}'), so this ` +
|
|
89
|
+
`client must SIGN the request the way ${name} verifies it — but no WebhookSignerCallback ` +
|
|
90
|
+
`is bound, so there is nothing to produce the signature. Bind one:\n` +
|
|
91
|
+
` options.bind(WEBHOOK_SIGNER_CALLBACK).to(MyWebhookSigner);\n` +
|
|
92
|
+
`Refusing to send is deliberate: an unsigned delivery is one the partner will reject, ` +
|
|
93
|
+
`and sending it anyway would hide the missing binding until they complained.`, `${request.contractName}.${request.route.methodName}`, name);
|
|
94
|
+
}
|
|
95
|
+
const signable = new WebhookSignerCallback_1.SignableRequest(request.url, request.route.httpMethod, request.body, request.headers, request.contractName, request.route.methodName);
|
|
96
|
+
const signed = await this.webhookSigner.sign(name, signable);
|
|
97
|
+
for (const entry of signed.entries()) {
|
|
98
|
+
request.headers.set(entry[0], entry[1]);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
exports.OutboundAuthFilter = OutboundAuthFilter;
|
|
103
|
+
//# sourceMappingURL=OutboundAuthFilter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"OutboundAuthFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/OutboundAuthFilter.ts"],"names":[],"mappings":"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","sourcesContent":["import { Filter, Secrets, Service } from '@webpieces/core-util';\nimport { GcpOidc } from '@webpieces/gcp-identity';\nimport { ClientRequest } from '@webpieces/http-client-core';\nimport { MissingSharedSecretError, MissingWebhookSignerError } from './OutboundAuthErrors';\nimport { SignableRequest, WebhookSignerCallback } from './WebhookSignerCallback';\n\n/**\n * Attaches the endpoint's outbound credential, against the destination the call is ACTUALLY going\n * to.\n *\n * ## Why this is a filter, and why it is the LAST one\n *\n * It used to be a method on the client, called before the request object even existed — so it\n * minted against the URL the client resolved at bind time, which is the URL BEFORE any filter had\n * run. That is wrong the moment a filter can re-point the request: an OIDC token's audience is the\n * callee's base URL, and a token minted for our own service name and then sent to a partner's\n * server is a credential handed to the wrong party.\n *\n * As the innermost filter (`ProxyClient.initRoutes` puts the framework's built-ins beneath every app\n * filter, and no app priority can get under them) it reads `request.baseUrl` / `request.url` after\n * everything has settled, which makes the audience correct by construction rather than by\n * convention. The SSRF guard sits immediately ABOVE it, so a destination that is going to be refused\n * is refused BEFORE any credential is minted for it.\n *\n * ## The three modes, and why none of them is restricted to a fixed host\n *\n * - `@AuthOidc` → a bearer token minted as this caller's runtime SA, audience = the final base URL.\n * - `@AuthSharedSecret` → the value this client holds for that key, as `Authorization: Webpieces …`.\n * Legitimate against a re-pointed URL: N services implementing ONE contract behind ONE agreed\n * secret is a real and common topology, and often stands in for OIDC where OIDC is not available.\n * - `@AuthWebhook(name)` → the app's bound {@link WebhookSignerCallback} produces the headers. WE\n * are the vendor on this side; see that class.\n *\n * Both credential-minting modes ride in the ONE `Authorization` header under their own scheme —\n * `Bearer <oidc>` / `Webpieces <secret>` — which is never a context key, so neither can leak onto\n * the next hop.\n */\nexport class OutboundAuthFilter extends Filter<ClientRequest, Response> {\n constructor(\n private readonly gcpOidc: GcpOidc,\n /** Only @AuthSharedSecret endpoints need it; a server that has none binds nothing. */\n private readonly secrets: Secrets | undefined,\n /** Only @AuthWebhook endpoints need it, and an unbound one makes them THROW. */\n private readonly webhookSigner: WebhookSignerCallback | undefined,\n ) {\n super();\n }\n\n override async filter(request: ClientRequest, nextFilter: Service<ClientRequest, Response>): Promise<Response> {\n await this.attach(request);\n return nextFilter.invoke(request);\n }\n\n private async attach(request: ClientRequest): Promise<void> {\n const mode = request.route.authMeta?.mode;\n if (mode?.kind === 'oidc') {\n request.headers.set('Authorization', `Bearer ${await this.gcpOidc.mintIdToken(request.baseUrl)}`);\n return;\n }\n if (mode?.kind === 'shared-secret') {\n const secret = this.secrets?.get(mode.secretKey);\n if (!secret) {\n throw new MissingSharedSecretError(\n `${request.contractName}.${request.route.methodName} is ` +\n `@AuthSharedSecret('${mode.secretKey}'), but this client's bound Secrets holds no ` +\n `value for that key, so there is no credential to send. Bind a Secrets carrying ` +\n `'${mode.secretKey}'. Refusing to send is deliberate: the callee is obliged to 401 an ` +\n `unauthenticated request, so sending it would report as the peer's failure.`,\n `${request.contractName}.${request.route.methodName}`,\n mode.secretKey,\n );\n }\n // Same header as a JWT/OIDC token, but its OWN scheme, so a secret can never be\n // mistaken for a token nor accepted where one was expected.\n request.headers.set('Authorization', `Webpieces ${secret}`);\n return;\n }\n if (mode?.kind === 'webhook') {\n await this.signWebhook(request, mode.name);\n }\n }\n\n /**\n * @throws MissingWebhookSignerError when no {@link WebhookSignerCallback} is bound. FAIL CLOSED,\n * matching the inbound side exactly: an unbound `WebhookAuthCallback` 401s every\n * `@AuthWebhook` endpoint rather than admitting it unverified, so an unbound signer must\n * refuse to send rather than deliver something the partner is obliged to reject.\n */\n private async signWebhook(request: ClientRequest, name: string): Promise<void> {\n if (this.webhookSigner === undefined) {\n throw new MissingWebhookSignerError(\n `${request.contractName}.${request.route.methodName} is @AuthWebhook('${name}'), so this ` +\n `client must SIGN the request the way ${name} verifies it — but no WebhookSignerCallback ` +\n `is bound, so there is nothing to produce the signature. Bind one:\\n` +\n ` options.bind(WEBHOOK_SIGNER_CALLBACK).to(MyWebhookSigner);\\n` +\n `Refusing to send is deliberate: an unsigned delivery is one the partner will reject, ` +\n `and sending it anyway would hide the missing binding until they complained.`,\n `${request.contractName}.${request.route.methodName}`,\n name,\n );\n }\n const signable = new SignableRequest(\n request.url,\n request.route.httpMethod,\n request.body,\n request.headers,\n request.contractName,\n request.route.methodName,\n );\n const signed = await this.webhookSigner.sign(name, signable);\n for (const entry of signed.entries()) {\n request.headers.set(entry[0], entry[1]);\n }\n }\n}\n"]}
|
package/src/SsrfGuardFilter.d.ts
CHANGED
|
@@ -10,6 +10,17 @@ import { SsrfPolicy } from './SsrfPolicy';
|
|
|
10
10
|
* consumer was reinventing this, badly or not at all, and the one that did it best still only
|
|
11
11
|
* managed `maxRedirects: 0` by hand.
|
|
12
12
|
*
|
|
13
|
+
* ## Installed on EVERY client; it costs nothing until a filter moves the request
|
|
14
|
+
*
|
|
15
|
+
* It sits beneath every app filter of every client this package builds, and the first thing it does
|
|
16
|
+
* is ask `request.destinationCameFromData`. FALSE — the URL is what `ClientRegistry` resolved, an
|
|
17
|
+
* address we chose — and it steps aside without parsing a URL or resolving a name, so an ordinary
|
|
18
|
+
* service-to-service RPC runs exactly the code path it ran before this class existed. TRUE only
|
|
19
|
+
* once something re-pointed the request, which is the one input a partner controls.
|
|
20
|
+
*
|
|
21
|
+
* That is why there is no per-client switch: an app cannot forget to turn the guard on for a client
|
|
22
|
+
* that takes runtime URLs, and cannot turn it off for one — the ACT of re-pointing is the trigger.
|
|
23
|
+
*
|
|
13
24
|
* ## What it enforces, per hop
|
|
14
25
|
*
|
|
15
26
|
* 1. the URL parses and its scheme is allowed (https only, by default);
|
package/src/SsrfGuardFilter.js
CHANGED
|
@@ -12,6 +12,17 @@ const SsrfRefusedError_1 = require("./SsrfRefusedError");
|
|
|
12
12
|
* consumer was reinventing this, badly or not at all, and the one that did it best still only
|
|
13
13
|
* managed `maxRedirects: 0` by hand.
|
|
14
14
|
*
|
|
15
|
+
* ## Installed on EVERY client; it costs nothing until a filter moves the request
|
|
16
|
+
*
|
|
17
|
+
* It sits beneath every app filter of every client this package builds, and the first thing it does
|
|
18
|
+
* is ask `request.destinationCameFromData`. FALSE — the URL is what `ClientRegistry` resolved, an
|
|
19
|
+
* address we chose — and it steps aside without parsing a URL or resolving a name, so an ordinary
|
|
20
|
+
* service-to-service RPC runs exactly the code path it ran before this class existed. TRUE only
|
|
21
|
+
* once something re-pointed the request, which is the one input a partner controls.
|
|
22
|
+
*
|
|
23
|
+
* That is why there is no per-client switch: an app cannot forget to turn the guard on for a client
|
|
24
|
+
* that takes runtime URLs, and cannot turn it off for one — the ACT of re-pointing is the trigger.
|
|
25
|
+
*
|
|
15
26
|
* ## What it enforces, per hop
|
|
16
27
|
*
|
|
17
28
|
* 1. the URL parses and its scheme is allowed (https only, by default);
|
|
@@ -41,6 +52,12 @@ class SsrfGuardFilter extends core_util_1.Filter {
|
|
|
41
52
|
this.addressResolver = addressResolver;
|
|
42
53
|
}
|
|
43
54
|
async filter(request, nextFilter) {
|
|
55
|
+
// The destination is still the one this client resolved for itself, so there is nothing
|
|
56
|
+
// attacker-influenced to judge. Step aside entirely — no parse, no DNS, no redirect
|
|
57
|
+
// interception — so the deployed-service path is byte-identical to having no guard at all.
|
|
58
|
+
if (!request.destinationCameFromData) {
|
|
59
|
+
return nextFilter.invoke(request);
|
|
60
|
+
}
|
|
44
61
|
await this.assertAllowed(request.url);
|
|
45
62
|
// The transport must not follow a redirect on its own — that would be a hop this policy
|
|
46
63
|
// never saw. We read the Location and judge it ourselves, below.
|
|
@@ -89,10 +106,12 @@ class SsrfGuardFilter extends core_util_1.Filter {
|
|
|
89
106
|
return new SsrfRefusedError_1.SsrfRefusedError(`Refusing to send to ${url}: ${because}. A destination supplied at runtime is attacker-influenced ` +
|
|
90
107
|
`data, so loopback, RFC1918, link-local and cloud-metadata addresses are refused — reaching ` +
|
|
91
108
|
`169.254.169.254 would hand this process's own service-account tokens to whoever registered ` +
|
|
92
|
-
`the URL.
|
|
93
|
-
`
|
|
94
|
-
`
|
|
95
|
-
`
|
|
109
|
+
`the URL. To reach one of OUR OWN services at a local port, register it — ` +
|
|
110
|
+
`ClientRegistry.addUrlMapping('svc', 'http://localhost:8202') — and a registry-resolved ` +
|
|
111
|
+
`URL is never judged here at all. If this client genuinely must dial an internal address ` +
|
|
112
|
+
`from RUNTIME data (exercising the partner path against a local fake), say so at the ` +
|
|
113
|
+
`construction site with ` +
|
|
114
|
+
`new ContextBaseUrlFilter(new SsrfTestingPolicy('<why>')).`, url);
|
|
96
115
|
}
|
|
97
116
|
/** Every address for `hostname`, or a refusal — a name that will not resolve is not a destination. */
|
|
98
117
|
async resolveOrRefuse(url, hostname) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"SsrfGuardFilter.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-node/src/SsrfGuardFilter.ts"],"names":[],"mappings":";;;AAAA,oDAAgE;AAGhE,iEAA8D;AAE9D,yDAAsD;AAEtD;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAa,eAAgB,SAAQ,kBAA+B;IAI3C;IACA;IAJJ,KAAK,GAAG,IAAI,2CAAoB,EAAE,CAAC;IAEpD,YACqB,MAAkB,EAClB,eAAgC;QAEjD,KAAK,EAAE,CAAC;QAHS,WAAM,GAAN,MAAM,CAAY;QAClB,oBAAe,GAAf,eAAe,CAAiB;IAGrD,CAAC;IAEQ,KAAK,CAAC,MAAM,CAAC,OAAsB,EAAE,UAA4C;QACtF,MAAM,IAAI,CAAC,aAAa,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAEtC,wFAAwF;QACxF,iEAAiE;QACjE,OAAO,CAAC,eAAe,GAAG,KAAK,CAAC;QAEhC,IAAI,QAAQ,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QAChD,KAAK,IAAI,GAAG,GAAG,CAAC,EAAE,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,EAAE,CAAC;YACjD,IAAI,GAAG,IAAI,IAAI,CAAC,MAAM,CAAC,YAAY,EAAE,CAAC;gBAClC,MAAM,IAAI,mCAAgB,CACtB,GAAG,OAAO,CAAC,YAAY,IAAI,OAAO,CAAC,KAAK,CAAC,UAAU,gCAAgC;oBAC/E,GAAG,IAAI,CAAC,MAAM,CAAC,YAAY,oDAAoD;oBAC/E,oBAAoB,OAAO,CAAC,GAAG,oDAAoD;oBACnF,sDAAsD,EAC1D,OAAO,CAAC,GAAG,CACd,CAAC;YACN,CAAC;YACD,MAAM,MAAM,GAAG,IAAI,CAAC,gBAAgB,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;YACxD,MAAM,IAAI,CAAC,aAAa,CAAC,MAAM,CAAC,CAAC;YACjC,OAAO,CAAC,gBAAgB,CAAC,MAAM,CAAC,CAAC;YACjC,QAAQ,GAAG,MAAM,UAAU,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QAChD,CAAC;QACD,OAAO,QAAQ,CAAC;IACpB,CAAC;IAED;;;OAGG;IACK,KAAK,CAAC,aAAa,CAAC,GAAW;QACnC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC/B,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,EAAE,CAAC;YACnD,MAAM,OAAO,GAAG,CAAC,GAAG,IAAI,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAC3D,MAAM,IAAI,mCAAgB,CACtB,uBAAuB,GAAG,aAAa,MAAM,CAAC,QAAQ,8BAA8B,OAAO,KAAK;gBAC5F,yFAAyF;gBACzF,oDAAoD,EACxD,GAAG,CACN,CAAC;QACN,CAAC;QACD,IAAI,IAAI,CAAC,MAAM,CAAC,sBAAsB,EAAE,CAAC;YACrC,OAAO;QACX,CAAC;QACD,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,UAAU,EAAE,EAAE,CAAC,CAAC;QACzD,IAAI,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,QAAQ,CAAC,EAAE,CAAC;YAC1C,MAAM,IAAI,CAAC,cAAc,CAAC,GAAG,EAAE,IAAI,QAAQ,+CAA+C,CAAC,CAAC;QAChG,CAAC;QACD,KAAK,MAAM,OAAO,IAAI,MAAM,IAAI,CAAC,eAAe,CAAC,GAAG,EAAE,QAAQ,CAAC,EAAE,CAAC;YAC9D,IAAI,IAAI,CAAC,KAAK,CAAC,iBAAiB,CAAC,OAAO,CAAC,EAAE,CAAC;gBACxC,MAAM,IAAI,CAAC,cAAc,CAAC,GAAG,EAAE,uCAAuC,OAAO,EAAE,CAAC,CAAC;YACrF,CAAC;QACL,CAAC;IACL,CAAC;IAEO,cAAc,CAAC,GAAW,EAAE,OAAe;QAC/C,OAAO,IAAI,mCAAgB,CACvB,uBAAuB,GAAG,KAAK,OAAO,6DAA6D;YAC/F,6FAA6F;YAC7F,6FAA6F;YAC7F,sFAAsF;YACtF,4DAA4D;YAC5D,yFAAyF;YACzF,kEAAkE,EACtE,GAAG,CACN,CAAC;IACN,CAAC;IAED,sGAAsG;IAC9F,KAAK,CAAC,eAAe,CAAC,GAAW,EAAE,QAAgB;QACvD,uFAAuF;QACvF,IAAI,yBAAyB,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;YACrE,OAAO,CAAC,QAAQ,CAAC,CAAC;QACtB,CAAC;QACD,kHAAkH;QAClH,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,MAAM,IAAI,CAAC,eAAe,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;QACxD,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,MAAM,IAAI,mCAAgB,CACtB,uBAAuB,GAAG,MAAM,QAAQ,sBAAsB,KAAK,CAAC,OAAO,KAAK;gBAC5E,0FAA0F;gBAC1F,uEAAuE,EAC3E,GAAG,CACN,CAAC;QACN,CAAC;IACL,CAAC;IAEO,KAAK,CAAC,GAAW;QACrB,gHAAgH;QAChH,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;QACxB,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,MAAM,IAAI,mCAAgB,CACtB,wBAAwB,GAAG,0CAA0C,KAAK,CAAC,OAAO,KAAK;gBACnF,mFAAmF,EACvF,GAAG,CACN,CAAC;QACN,CAAC;IACL,CAAC;IAEO,UAAU,CAAC,QAAkB;QACjC,OAAO,QAAQ,CAAC,MAAM,IAAI,GAAG,IAAI,QAAQ,CAAC,MAAM,GAAG,GAAG,IAAI,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,IAAI,CAAC;IACxG,CAAC;IAED,oGAAoG;IAC5F,gBAAgB,CAAC,QAAkB,EAAE,OAAsB;QAC/D,MAAM,QAAQ,GAAG,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,CAAC;QACxD,kHAAkH;QAClH,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAI,GAAG,CAAC,QAAQ,EAAE,OAAO,CAAC,GAAG,CAAC,CAAC,QAAQ,EAAE,CAAC;QACrD,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,mBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,MAAM,IAAI,mCAAgB,CACtB,wCAAwC,OAAO,CAAC,GAAG,wBAAwB;gBACvE,IAAI,QAAQ,0BAA0B,KAAK,CAAC,OAAO,IAAI,EAC3D,OAAO,CAAC,GAAG,CACd,CAAC;QACN,CAAC;IACL,CAAC;CACJ;AAtID,0CAsIC","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 * ## 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 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. If this client genuinely must reach an internal host (a local emulator, an ` +\n `on-cluster service), say so at the construction site with ` +\n `new RuntimeHostFromContextAllowingInternalAddresses('<why>', new DnsAddressResolver()) ` +\n `instead of new RuntimeHostFromContext(new DnsAddressResolver()).`,\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>')).`,\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/SsrfPolicy.d.ts
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What a
|
|
2
|
+
* What a destination supplied at RUNTIME has to satisfy before this client will send to it: HTTPS
|
|
3
|
+
* only, no internal addresses, at most one redirect — each hop re-judged.
|
|
3
4
|
*
|
|
4
|
-
* DATA ONLY — the enforcement is {@link SsrfGuardFilter}
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* DATA ONLY — the enforcement is {@link SsrfGuardFilter}, and the trigger is
|
|
6
|
+
* `ClientRequest.destinationCameFromData`: a URL that came out of `ClientRegistry` is an address WE
|
|
7
|
+
* chose and is never judged, so an ordinary service-to-service client pays nothing for this class
|
|
8
|
+
* existing.
|
|
9
|
+
*
|
|
10
|
+
* THIS class is what a client gets by saying nothing, and it is the whole policy — there is no
|
|
11
|
+
* argument to soften, no scheme list to widen, no flag to flip. Relaxing it means naming a
|
|
12
|
+
* DIFFERENT class, {@link SsrfTestingPolicy}, which is why that one carries a required reason.
|
|
9
13
|
*/
|
|
10
14
|
export declare class SsrfPolicy {
|
|
11
15
|
/**
|
|
12
|
-
* URL schemes that may be sent to, with their colons
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
+
* URL schemes that may be sent to, with their colons. HTTPS-only: a partner-registered
|
|
17
|
+
* destination reached over plaintext http leaks the payload and its signature to anything on the
|
|
18
|
+
* path, and "the partner has not got TLS yet" is their bug to fix, not ours to accommodate
|
|
19
|
+
* silently.
|
|
16
20
|
*/
|
|
17
21
|
readonly allowedSchemes: ReadonlySet<string>;
|
|
18
|
-
/**
|
|
19
|
-
* TRUE only under {@link RuntimeHostFromContextAllowingInternalAddresses}. When true, the
|
|
20
|
-
* loopback / RFC1918 / link-local / metadata refusals are skipped — scheme checking and the
|
|
21
|
-
* redirect cap still apply.
|
|
22
|
-
*/
|
|
22
|
+
/** When true, the loopback / RFC1918 / link-local / metadata refusals are skipped. */
|
|
23
23
|
readonly allowInternalAddresses: boolean;
|
|
24
24
|
/**
|
|
25
25
|
* How many redirects may be followed, each one re-judged under this same policy. Small on
|
|
@@ -27,38 +27,32 @@ export declare class SsrfPolicy {
|
|
|
27
27
|
* another chance for the destination to move somewhere we did not agree to.
|
|
28
28
|
*/
|
|
29
29
|
readonly maxRedirects: number;
|
|
30
|
-
/**
|
|
31
|
-
* WHY internal addresses are allowed, in prose, when they are. Required by
|
|
32
|
-
* {@link RuntimeHostFromContextAllowingInternalAddresses} and quoted in the log line the
|
|
33
|
-
* guard writes, so the reason travels with the decision instead of living in a commit
|
|
34
|
-
* message nobody will find.
|
|
35
|
-
*/
|
|
30
|
+
/** WHY internal addresses are allowed, in prose, when they are. See {@link SsrfTestingPolicy}. */
|
|
36
31
|
readonly allowInternalReason: string | undefined;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* {@link SsrfPolicy} with plaintext http and internal addresses ALLOWED, for the one case that
|
|
35
|
+
* genuinely needs them: exercising the partner delivery path against a local fake, where the
|
|
36
|
+
* per-call URL is `http://127.0.0.1:9123`.
|
|
37
|
+
*
|
|
38
|
+
* This is NOT the way to reach a local emulator by service name. `ClientRegistry.addMapping` already
|
|
39
|
+
* covers that, and a registry-resolved URL is never SSRF-checked in the first place — so a localhost
|
|
40
|
+
* peer needs no opt-out at all, and anyone reaching for this class to get one is in the wrong place.
|
|
41
|
+
*
|
|
42
|
+
* THE LONG NAME IS THE FEATURE. This is the permissive branch, so it is a NOUN a reviewer can grep —
|
|
43
|
+
* `grep -rn SsrfTestingPolicy` lists every client in a codebase that can reach inside the network
|
|
44
|
+
* with a runtime-supplied URL — rather than a boolean, an omitted argument, or an empty allow-list. A
|
|
45
|
+
* widening that reads as an ABSENCE is invisible exactly where it matters most.
|
|
46
|
+
*/
|
|
47
|
+
export declare class SsrfTestingPolicy extends SsrfPolicy {
|
|
48
|
+
readonly allowedSchemes: ReadonlySet<string>;
|
|
49
|
+
readonly allowInternalAddresses: boolean;
|
|
50
|
+
readonly allowInternalReason: string;
|
|
37
51
|
constructor(
|
|
38
52
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* fix, not ours to accommodate silently.
|
|
43
|
-
*/
|
|
44
|
-
allowedSchemes: ReadonlySet<string>,
|
|
45
|
-
/**
|
|
46
|
-
* TRUE only under {@link RuntimeHostFromContextAllowingInternalAddresses}. When true, the
|
|
47
|
-
* loopback / RFC1918 / link-local / metadata refusals are skipped — scheme checking and the
|
|
48
|
-
* redirect cap still apply.
|
|
49
|
-
*/
|
|
50
|
-
allowInternalAddresses: boolean,
|
|
51
|
-
/**
|
|
52
|
-
* How many redirects may be followed, each one re-judged under this same policy. Small on
|
|
53
|
-
* purpose: a legitimate webhook endpoint does not need a redirect chain, and every hop is
|
|
54
|
-
* another chance for the destination to move somewhere we did not agree to.
|
|
55
|
-
*/
|
|
56
|
-
maxRedirects: number,
|
|
57
|
-
/**
|
|
58
|
-
* WHY internal addresses are allowed, in prose, when they are. Required by
|
|
59
|
-
* {@link RuntimeHostFromContextAllowingInternalAddresses} and quoted in the log line the
|
|
60
|
-
* guard writes, so the reason travels with the decision instead of living in a commit
|
|
61
|
-
* message nobody will find.
|
|
53
|
+
* WHY this client may reach internal addresses, in prose. REQUIRED, and quoted back in this
|
|
54
|
+
* client's refusals, so the justification travels with the decision instead of living in a
|
|
55
|
+
* commit message nobody will find.
|
|
62
56
|
*/
|
|
63
|
-
|
|
57
|
+
reason: string);
|
|
64
58
|
}
|