@stigmer/outbound 3.34.0 → 3.35.0
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 +1 -1
- package/egress/check.d.ts +4 -0
- package/egress/check.d.ts.map +1 -1
- package/egress/check.js +2 -0
- package/egress/check.js.map +1 -1
- package/egress/fetch.d.ts +15 -0
- package/egress/fetch.d.ts.map +1 -1
- package/egress/fetch.js +34 -1
- package/egress/fetch.js.map +1 -1
- package/egress/index.d.ts +1 -1
- package/egress/index.d.ts.map +1 -1
- package/egress/index.js +1 -1
- package/egress/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/fetch.test.ts +77 -2
- package/src/egress/check.ts +4 -1
- package/src/egress/fetch.ts +51 -1
- package/src/egress/index.ts +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ The rules a Stigmer process follows when it dials a URL a user supplied, shared
|
|
|
4
4
|
|
|
5
5
|
Two entry points:
|
|
6
6
|
|
|
7
|
-
- `@stigmer/outbound/egress`: which addresses a process refuses to dial (`blockedReason`, two postures, `strict` for managed cloud processes and `relaxed` for a machine that belongs to the user), the check that applies a policy to a URL after resolving every address it has (`checkEgress`), and the fetch that applies it to every redirect hop (`guardedFetch`). A process composes one guarded fetch at its root and hands it to every module that dials; the modules hold no `fetch` of their own.
|
|
7
|
+
- `@stigmer/outbound/egress`: which addresses a process refuses to dial (`blockedReason`, two postures, `strict` for managed cloud processes and `relaxed` for a machine that belongs to the user), the check that applies a policy to a URL after resolving every address it has (`checkEgress`), and the fetch that applies it to every redirect hop and bounds every response body (`guardedFetch`). A process composes one guarded fetch at its root and hands it to every module that dials; the modules hold no `fetch` of their own.
|
|
8
8
|
- `@stigmer/outbound/mcp-oauth`: how an MCP endpoint's authentication is read without a credential (`isOAuthChallenge`, the complete `initializeRequest`, `probeEndpointAuth`) and how its login server is found (`resolveAuthorizationServers` over RFC 9728, `readAuthorizationServerMetadata` over RFC 8414 with the issuer's path and OpenID's document).
|
|
9
9
|
|
|
10
10
|
## Why one library
|
package/egress/check.d.ts
CHANGED
|
@@ -24,6 +24,10 @@ export type EgressRefusal = {
|
|
|
24
24
|
readonly kind: "too-many-redirects";
|
|
25
25
|
readonly url: URL;
|
|
26
26
|
readonly hops: number;
|
|
27
|
+
} | {
|
|
28
|
+
readonly kind: "response-too-large";
|
|
29
|
+
readonly url: URL;
|
|
30
|
+
readonly maxBytes: number;
|
|
27
31
|
};
|
|
28
32
|
export type EgressCheck = {
|
|
29
33
|
readonly ok: true;
|
package/egress/check.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../../src/egress/check.ts"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,4EAA4E;AAC5E,MAAM,MAAM,QAAQ,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAC;AAExE,uFAAuF;AACvF,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACnF;IAAE,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC/E;IACE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB,GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"check.d.ts","sourceRoot":"","sources":["../../src/egress/check.ts"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAEjD,4EAA4E;AAC5E,MAAM,MAAM,QAAQ,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAC;AAExE,uFAAuF;AACvF,MAAM,MAAM,aAAa,GACrB;IAAE,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACnF;IAAE,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC/E;IACE,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAClB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB,GACD;IAAE,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACjF;IAAE,QAAQ,CAAC,IAAI,EAAE,oBAAoB,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAE1F,MAAM,MAAM,WAAW,GACnB;IAAE,QAAQ,CAAC,EAAE,EAAE,IAAI,CAAC;IAAC,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC;IAAC,QAAQ,CAAC,SAAS,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GAC/E;IAAE,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC;IAAC,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAA;CAAE,CAAC;AAE5D,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,yEAAyE;IACzE,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,CAAC;CAClD;AAED,gEAAgE;AAChE,wBAAgB,eAAe,CAAC,OAAO,EAAE,aAAa,GAAG,MAAM,CAmB9D;AAED,2DAA2D;AAC3D,qBAAa,WAAY,SAAQ,KAAK;IACxB,QAAQ,CAAC,OAAO,EAAE,aAAa;gBAAtB,OAAO,EAAE,aAAa;CAI5C;AAED,uEAAuE;AACvE,wBAAsB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,WAAW,CAAC,CAyB/H"}
|
package/egress/check.js
CHANGED
|
@@ -39,6 +39,8 @@ export function describeRefusal(refusal) {
|
|
|
39
39
|
return `Refusing to reach ${refusal.hostname}: it resolves to ${refusal.address}, a ${refusal.reason} address the ${refusal.policy} egress policy does not dial.`;
|
|
40
40
|
case "too-many-redirects":
|
|
41
41
|
return `Refusing to follow more than ${refusal.hops} redirects from ${refusal.url.href}.`;
|
|
42
|
+
case "response-too-large":
|
|
43
|
+
return `Refusing to read more than ${refusal.maxBytes} bytes from ${refusal.url.href}.`;
|
|
42
44
|
default: {
|
|
43
45
|
const exhaustive = refusal;
|
|
44
46
|
return exhaustive;
|
package/egress/check.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"check.js","sourceRoot":"","sources":["../../src/egress/check.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"check.js","sourceRoot":"","sources":["../../src/egress/check.ts"],"names":[],"mappings":"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"}
|
package/egress/fetch.d.ts
CHANGED
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
* one host never travels to another. A 303, or a 301/302 answering a
|
|
21
21
|
* non-GET, becomes a GET without a body; 307 and 308 keep method and body.
|
|
22
22
|
*
|
|
23
|
+
* Every response body is bounded (`maxResponseBytes`, a mebibyte by
|
|
24
|
+
* default). The URLs these callers dial are written by users, so a document
|
|
25
|
+
* that never ends is an attack on the process that reads it, and every
|
|
26
|
+
* caller today reads a small document: a JWKS, a discovery or metadata
|
|
27
|
+
* document, a token response. The bound applies to the body, not the dial:
|
|
28
|
+
* the response resolves as it came, and reading past the bound rejects with
|
|
29
|
+
* an `EgressError` whose refusal is `response-too-large`, so each caller's
|
|
30
|
+
* existing "could not read the document" arm answers it. A declared
|
|
31
|
+
* `Content-Length` over the bound rejects on the first read, before any byte
|
|
32
|
+
* is buffered. A caller that streams passes `Number.POSITIVE_INFINITY`.
|
|
33
|
+
*
|
|
23
34
|
* `OutboundFetch` takes a string or a URL, never a `Request`: a Request
|
|
24
35
|
* carries its own headers and body that a wrapper would have to merge, and
|
|
25
36
|
* no caller here builds one. The global `fetch` is assignable to it, so a
|
|
@@ -36,9 +47,13 @@ export interface GuardedFetchOptions {
|
|
|
36
47
|
readonly lookup: LookupFn;
|
|
37
48
|
/** Hops followed before refusing; default 3. */
|
|
38
49
|
readonly maxRedirects?: number;
|
|
50
|
+
/** Bytes of a response body read before refusing; default one mebibyte. */
|
|
51
|
+
readonly maxResponseBytes?: number;
|
|
39
52
|
}
|
|
40
53
|
/** The default hop budget: a scheme upgrade and a canonicalisation, with one to spare. */
|
|
41
54
|
export declare const DEFAULT_MAX_REDIRECTS = 3;
|
|
55
|
+
/** The default body bound: a thousand times a large JWKS, far below what a pod can buffer. */
|
|
56
|
+
export declare const DEFAULT_MAX_RESPONSE_BYTES: number;
|
|
42
57
|
/**
|
|
43
58
|
* An `OutboundFetch` in the global `fetch`'s shape, for a seam typed
|
|
44
59
|
* `typeof fetch`. A `Request` input is refused rather than flattened: this
|
package/egress/fetch.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../../src/egress/fetch.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"fetch.d.ts","sourceRoot":"","sources":["../../src/egress/fetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AACH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AACjD,OAAO,EAA4B,KAAK,QAAQ,EAAE,MAAM,YAAY,CAAC;AAErE,6FAA6F;AAC7F,MAAM,MAAM,aAAa,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEzF,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,SAAS,EAAE,aAAa,CAAC;IAClC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC;IAC1B,gDAAgD;IAChD,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,2EAA2E;IAC3E,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;CACpC;AAED,0FAA0F;AAC1F,eAAO,MAAM,qBAAqB,IAAI,CAAC;AAEvC,8FAA8F;AAC9F,eAAO,MAAM,0BAA0B,QAAc,CAAC;AAOtD;;;;;;GAMG;AACH,wBAAgB,OAAO,CAAC,QAAQ,EAAE,aAAa,GAAG,OAAO,KAAK,CAK7D;AAED,0EAA0E;AAC1E,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,EAAE,mBAAmB,GAAG,aAAa,CA0C9F"}
|
package/egress/fetch.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { checkEgress, EgressError } from "./check.js";
|
|
2
2
|
/** The default hop budget: a scheme upgrade and a canonicalisation, with one to spare. */
|
|
3
3
|
export const DEFAULT_MAX_REDIRECTS = 3;
|
|
4
|
+
/** The default body bound: a thousand times a large JWKS, far below what a pod can buffer. */
|
|
5
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
4
6
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
7
|
+
/** Statuses whose responses carry no body, which `new Response` refuses to give one. */
|
|
8
|
+
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
|
5
9
|
/**
|
|
6
10
|
* An `OutboundFetch` in the global `fetch`'s shape, for a seam typed
|
|
7
11
|
* `typeof fetch`. A `Request` input is refused rather than flattened: this
|
|
@@ -19,6 +23,7 @@ export function asFetch(outbound) {
|
|
|
19
23
|
/** Compose a fetch that judges every URL and every hop under `policy`. */
|
|
20
24
|
export function guardedFetch(policy, options) {
|
|
21
25
|
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
26
|
+
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
22
27
|
return async (input, init) => {
|
|
23
28
|
let url = new URL(input);
|
|
24
29
|
let method = (init?.method ?? "GET").toUpperCase();
|
|
@@ -32,7 +37,7 @@ export function guardedFetch(policy, options) {
|
|
|
32
37
|
const response = await options.fetchImpl(url, { ...init, method, body, headers, redirect: "manual" });
|
|
33
38
|
const location = response.headers.get("location");
|
|
34
39
|
if (!REDIRECT_STATUSES.has(response.status) || location === null || callerRedirect === "manual") {
|
|
35
|
-
return response;
|
|
40
|
+
return boundedResponse(response, url, maxResponseBytes);
|
|
36
41
|
}
|
|
37
42
|
if (callerRedirect === "error") {
|
|
38
43
|
await response.body?.cancel();
|
|
@@ -56,4 +61,32 @@ export function guardedFetch(policy, options) {
|
|
|
56
61
|
}
|
|
57
62
|
};
|
|
58
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* The response with its body bounded: the same status and headers over a
|
|
66
|
+
* body that errors once it passes `maxBytes`, or at once when the declared
|
|
67
|
+
* length already does.
|
|
68
|
+
*/
|
|
69
|
+
function boundedResponse(response, url, maxBytes) {
|
|
70
|
+
if (response.body === null || NULL_BODY_STATUSES.has(response.status) || !Number.isFinite(maxBytes)) {
|
|
71
|
+
return response;
|
|
72
|
+
}
|
|
73
|
+
const tooLarge = () => new EgressError({ kind: "response-too-large", url, maxBytes });
|
|
74
|
+
const declared = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
75
|
+
let read = 0;
|
|
76
|
+
const body = response.body.pipeThrough(new TransformStream({
|
|
77
|
+
start(controller) {
|
|
78
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
79
|
+
controller.error(tooLarge());
|
|
80
|
+
},
|
|
81
|
+
transform(chunk, controller) {
|
|
82
|
+
read += chunk.byteLength;
|
|
83
|
+
if (read > maxBytes) {
|
|
84
|
+
controller.error(tooLarge());
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
controller.enqueue(chunk);
|
|
88
|
+
},
|
|
89
|
+
}));
|
|
90
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
91
|
+
}
|
|
59
92
|
//# sourceMappingURL=fetch.js.map
|
package/egress/fetch.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch.js","sourceRoot":"","sources":["../../src/egress/fetch.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"fetch.js","sourceRoot":"","sources":["../../src/egress/fetch.ts"],"names":[],"mappings":"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"}
|
package/egress/index.d.ts
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { blockedReason, egressPolicyForPosture, type EgressPolicy, type EgressPosture } from "./address.js";
|
|
7
7
|
export { checkEgress, describeRefusal, EgressError, type EgressCheck, type EgressCheckOptions, type EgressRefusal, type LookupFn, } from "./check.js";
|
|
8
|
-
export { asFetch, DEFAULT_MAX_REDIRECTS, guardedFetch, type GuardedFetchOptions, type OutboundFetch } from "./fetch.js";
|
|
8
|
+
export { asFetch, DEFAULT_MAX_REDIRECTS, DEFAULT_MAX_RESPONSE_BYTES, guardedFetch, type GuardedFetchOptions, type OutboundFetch } from "./fetch.js";
|
|
9
9
|
export { nodeLookup } from "./node-lookup.js";
|
|
10
10
|
//# sourceMappingURL=index.d.ts.map
|
package/egress/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/egress/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAC5G,OAAO,EACL,WAAW,EACX,eAAe,EACf,WAAW,EACX,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,aAAa,EAClB,KAAK,QAAQ,GACd,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,qBAAqB,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/egress/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAE,KAAK,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,cAAc,CAAC;AAC5G,OAAO,EACL,WAAW,EACX,eAAe,EACf,WAAW,EACX,KAAK,WAAW,EAChB,KAAK,kBAAkB,EACvB,KAAK,aAAa,EAClB,KAAK,QAAQ,GACd,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,qBAAqB,EAAE,0BAA0B,EAAE,YAAY,EAAE,KAAK,mBAAmB,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC;AACpJ,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC"}
|
package/egress/index.js
CHANGED
|
@@ -5,6 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { blockedReason, egressPolicyForPosture } from "./address.js";
|
|
7
7
|
export { checkEgress, describeRefusal, EgressError, } from "./check.js";
|
|
8
|
-
export { asFetch, DEFAULT_MAX_REDIRECTS, guardedFetch } from "./fetch.js";
|
|
8
|
+
export { asFetch, DEFAULT_MAX_REDIRECTS, DEFAULT_MAX_RESPONSE_BYTES, guardedFetch } from "./fetch.js";
|
|
9
9
|
export { nodeLookup } from "./node-lookup.js";
|
|
10
10
|
//# sourceMappingURL=index.js.map
|
package/egress/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/egress/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAyC,MAAM,cAAc,CAAC;AAC5G,OAAO,EACL,WAAW,EACX,eAAe,EACf,WAAW,GAKZ,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,qBAAqB,EAAE,YAAY,EAAgD,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/egress/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAE,aAAa,EAAE,sBAAsB,EAAyC,MAAM,cAAc,CAAC;AAC5G,OAAO,EACL,WAAW,EACX,eAAe,EACf,WAAW,GAKZ,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,OAAO,EAAE,qBAAqB,EAAE,0BAA0B,EAAE,YAAY,EAAgD,MAAM,YAAY,CAAC;AACpJ,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/outbound",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.35.0",
|
|
4
4
|
"description": "The rules Stigmer's TypeScript processes dial a user-supplied endpoint by, shared by the control plane, the runner and the catalogue audit: the egress address policy and its guarded fetch, and the way an MCP endpoint's authentication is read (the OAuth-challenge rule, the complete initialize handshake, the RFC 9728 / RFC 8414 / OpenID resolution of its login server)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -2,13 +2,17 @@
|
|
|
2
2
|
* Pins the guarded fetch: the first URL and every hop judged, a caller's
|
|
3
3
|
* `redirect: "manual"` honoured, `redirect: "error"` thrown like fetch, the
|
|
4
4
|
* hop budget, the Authorization header dropped across origins, and the
|
|
5
|
-
* method rewrite a 303 (or a 301/302 on a POST) implies.
|
|
5
|
+
* method rewrite a 303 (or a 301/302 on a POST) implies. Then the body
|
|
6
|
+
* bound: a body within it reads whole, one that runs past it or declares
|
|
7
|
+
* a length past it rejects its read (never the dial) as
|
|
8
|
+
* `response-too-large`, the default is a mebibyte, and an unbounded caller
|
|
9
|
+
* reads everything.
|
|
6
10
|
*/
|
|
7
11
|
import { describe, expect, it } from "vitest";
|
|
8
12
|
|
|
9
13
|
import { egressPolicyForPosture } from "../egress/address.js";
|
|
10
14
|
import { EgressError, type LookupFn } from "../egress/check.js";
|
|
11
|
-
import { asFetch, guardedFetch, type OutboundFetch } from "../egress/fetch.js";
|
|
15
|
+
import { asFetch, DEFAULT_MAX_RESPONSE_BYTES, guardedFetch, type OutboundFetch } from "../egress/fetch.js";
|
|
12
16
|
|
|
13
17
|
const strict = egressPolicyForPosture("strict");
|
|
14
18
|
|
|
@@ -159,3 +163,74 @@ describe("guardedFetch", () => {
|
|
|
159
163
|
await expect(pending).rejects.toThrow("deadline");
|
|
160
164
|
});
|
|
161
165
|
});
|
|
166
|
+
|
|
167
|
+
/** A body served in chunks with no declared length, as a streaming server sends it. */
|
|
168
|
+
function chunked(...chunks: readonly string[]): () => Response {
|
|
169
|
+
return () => {
|
|
170
|
+
const encoder = new TextEncoder();
|
|
171
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
172
|
+
start(controller) {
|
|
173
|
+
for (const chunk of chunks) controller.enqueue(encoder.encode(chunk));
|
|
174
|
+
controller.close();
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
return new Response(stream, { status: 200 });
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function refusalOf(pending: Promise<unknown>): Promise<unknown> {
|
|
182
|
+
try {
|
|
183
|
+
await pending;
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return error instanceof EgressError ? error.refusal : error;
|
|
186
|
+
}
|
|
187
|
+
throw new Error("the read resolved");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
describe("guardedFetch's body bound", () => {
|
|
191
|
+
it("reads a body within the bound whole, with the status and headers it came with", async () => {
|
|
192
|
+
const { fetchImpl } = scripted({
|
|
193
|
+
"https://a.vendor.test/jwks": () => new Response('{"keys":[]}', { status: 200, headers: { "content-type": "application/json" } }),
|
|
194
|
+
});
|
|
195
|
+
const response = await guardedFetch(strict, { fetchImpl, lookup, maxResponseBytes: 64 })("https://a.vendor.test/jwks");
|
|
196
|
+
expect(response.status).toBe(200);
|
|
197
|
+
expect(response.headers.get("content-type")).toBe("application/json");
|
|
198
|
+
await expect(response.json()).resolves.toEqual({ keys: [] });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("resolves the dial and rejects the read of a body that runs past the bound", async () => {
|
|
202
|
+
const { fetchImpl } = scripted({ "https://a.vendor.test/jwks": chunked("0123456789", "0123456789") });
|
|
203
|
+
const response = await guardedFetch(strict, { fetchImpl, lookup, maxResponseBytes: 15 })("https://a.vendor.test/jwks");
|
|
204
|
+
expect(response.status).toBe(200);
|
|
205
|
+
expect(await refusalOf(response.text())).toMatchObject({ kind: "response-too-large", maxBytes: 15 });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("rejects the first read of a body whose declared length is past the bound", async () => {
|
|
209
|
+
const { fetchImpl } = scripted({
|
|
210
|
+
"https://a.vendor.test/jwks": () => new Response("0123456789", { status: 200, headers: { "content-length": "10" } }),
|
|
211
|
+
});
|
|
212
|
+
const response = await guardedFetch(strict, { fetchImpl, lookup, maxResponseBytes: 9 })("https://a.vendor.test/jwks");
|
|
213
|
+
const refusal = await refusalOf(response.arrayBuffer());
|
|
214
|
+
expect(refusal).toMatchObject({ kind: "response-too-large", maxBytes: 9 });
|
|
215
|
+
expect((refusal as { url: URL }).url.href).toBe("https://a.vendor.test/jwks");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("bounds every caller by a mebibyte unless it says otherwise", async () => {
|
|
219
|
+
expect(DEFAULT_MAX_RESPONSE_BYTES).toBe(1024 * 1024);
|
|
220
|
+
const oversized = "x".repeat(DEFAULT_MAX_RESPONSE_BYTES + 1);
|
|
221
|
+
const { fetchImpl } = scripted({ "https://a.vendor.test/doc": chunked(oversized) });
|
|
222
|
+
const response = await guardedFetch(strict, { fetchImpl, lookup })("https://a.vendor.test/doc");
|
|
223
|
+
expect(await refusalOf(response.text())).toMatchObject({ kind: "response-too-large", maxBytes: DEFAULT_MAX_RESPONSE_BYTES });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("reads everything for a caller that streams", async () => {
|
|
227
|
+
const { fetchImpl } = scripted({ "https://a.vendor.test/events": chunked("a".repeat(32), "b".repeat(32)) });
|
|
228
|
+
const response = await guardedFetch(strict, { fetchImpl, lookup, maxResponseBytes: Number.POSITIVE_INFINITY })("https://a.vendor.test/events");
|
|
229
|
+
await expect(response.text()).resolves.toHaveLength(64);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("describes the refusal with the bound and the URL", () => {
|
|
233
|
+
const error = new EgressError({ kind: "response-too-large", url: new URL("https://a.vendor.test/jwks"), maxBytes: 9 });
|
|
234
|
+
expect(error.message).toBe("Refusing to read more than 9 bytes from https://a.vendor.test/jwks.");
|
|
235
|
+
});
|
|
236
|
+
});
|
package/src/egress/check.ts
CHANGED
|
@@ -45,7 +45,8 @@ export type EgressRefusal =
|
|
|
45
45
|
readonly reason: string;
|
|
46
46
|
readonly policy: string;
|
|
47
47
|
}
|
|
48
|
-
| { readonly kind: "too-many-redirects"; readonly url: URL; readonly hops: number }
|
|
48
|
+
| { readonly kind: "too-many-redirects"; readonly url: URL; readonly hops: number }
|
|
49
|
+
| { readonly kind: "response-too-large"; readonly url: URL; readonly maxBytes: number };
|
|
49
50
|
|
|
50
51
|
export type EgressCheck =
|
|
51
52
|
| { readonly ok: true; readonly url: URL; readonly addresses: readonly string[] }
|
|
@@ -70,6 +71,8 @@ export function describeRefusal(refusal: EgressRefusal): string {
|
|
|
70
71
|
return `Refusing to reach ${refusal.hostname}: it resolves to ${refusal.address}, a ${refusal.reason} address the ${refusal.policy} egress policy does not dial.`;
|
|
71
72
|
case "too-many-redirects":
|
|
72
73
|
return `Refusing to follow more than ${refusal.hops} redirects from ${refusal.url.href}.`;
|
|
74
|
+
case "response-too-large":
|
|
75
|
+
return `Refusing to read more than ${refusal.maxBytes} bytes from ${refusal.url.href}.`;
|
|
73
76
|
default: {
|
|
74
77
|
const exhaustive: never = refusal;
|
|
75
78
|
return exhaustive;
|
package/src/egress/fetch.ts
CHANGED
|
@@ -20,6 +20,17 @@
|
|
|
20
20
|
* one host never travels to another. A 303, or a 301/302 answering a
|
|
21
21
|
* non-GET, becomes a GET without a body; 307 and 308 keep method and body.
|
|
22
22
|
*
|
|
23
|
+
* Every response body is bounded (`maxResponseBytes`, a mebibyte by
|
|
24
|
+
* default). The URLs these callers dial are written by users, so a document
|
|
25
|
+
* that never ends is an attack on the process that reads it, and every
|
|
26
|
+
* caller today reads a small document: a JWKS, a discovery or metadata
|
|
27
|
+
* document, a token response. The bound applies to the body, not the dial:
|
|
28
|
+
* the response resolves as it came, and reading past the bound rejects with
|
|
29
|
+
* an `EgressError` whose refusal is `response-too-large`, so each caller's
|
|
30
|
+
* existing "could not read the document" arm answers it. A declared
|
|
31
|
+
* `Content-Length` over the bound rejects on the first read, before any byte
|
|
32
|
+
* is buffered. A caller that streams passes `Number.POSITIVE_INFINITY`.
|
|
33
|
+
*
|
|
23
34
|
* `OutboundFetch` takes a string or a URL, never a `Request`: a Request
|
|
24
35
|
* carries its own headers and body that a wrapper would have to merge, and
|
|
25
36
|
* no caller here builds one. The global `fetch` is assignable to it, so a
|
|
@@ -38,13 +49,21 @@ export interface GuardedFetchOptions {
|
|
|
38
49
|
readonly lookup: LookupFn;
|
|
39
50
|
/** Hops followed before refusing; default 3. */
|
|
40
51
|
readonly maxRedirects?: number;
|
|
52
|
+
/** Bytes of a response body read before refusing; default one mebibyte. */
|
|
53
|
+
readonly maxResponseBytes?: number;
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
/** The default hop budget: a scheme upgrade and a canonicalisation, with one to spare. */
|
|
44
57
|
export const DEFAULT_MAX_REDIRECTS = 3;
|
|
45
58
|
|
|
59
|
+
/** The default body bound: a thousand times a large JWKS, far below what a pod can buffer. */
|
|
60
|
+
export const DEFAULT_MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
61
|
+
|
|
46
62
|
const REDIRECT_STATUSES: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
|
|
47
63
|
|
|
64
|
+
/** Statuses whose responses carry no body, which `new Response` refuses to give one. */
|
|
65
|
+
const NULL_BODY_STATUSES: ReadonlySet<number> = new Set([101, 204, 205, 304]);
|
|
66
|
+
|
|
48
67
|
/**
|
|
49
68
|
* An `OutboundFetch` in the global `fetch`'s shape, for a seam typed
|
|
50
69
|
* `typeof fetch`. A `Request` input is refused rather than flattened: this
|
|
@@ -62,6 +81,7 @@ export function asFetch(outbound: OutboundFetch): typeof fetch {
|
|
|
62
81
|
/** Compose a fetch that judges every URL and every hop under `policy`. */
|
|
63
82
|
export function guardedFetch(policy: EgressPolicy, options: GuardedFetchOptions): OutboundFetch {
|
|
64
83
|
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
84
|
+
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
65
85
|
|
|
66
86
|
return async (input, init) => {
|
|
67
87
|
let url = new URL(input);
|
|
@@ -78,7 +98,7 @@ export function guardedFetch(policy: EgressPolicy, options: GuardedFetchOptions)
|
|
|
78
98
|
|
|
79
99
|
const location = response.headers.get("location");
|
|
80
100
|
if (!REDIRECT_STATUSES.has(response.status) || location === null || callerRedirect === "manual") {
|
|
81
|
-
return response;
|
|
101
|
+
return boundedResponse(response, url, maxResponseBytes);
|
|
82
102
|
}
|
|
83
103
|
if (callerRedirect === "error") {
|
|
84
104
|
await response.body?.cancel();
|
|
@@ -102,3 +122,33 @@ export function guardedFetch(policy: EgressPolicy, options: GuardedFetchOptions)
|
|
|
102
122
|
}
|
|
103
123
|
};
|
|
104
124
|
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The response with its body bounded: the same status and headers over a
|
|
128
|
+
* body that errors once it passes `maxBytes`, or at once when the declared
|
|
129
|
+
* length already does.
|
|
130
|
+
*/
|
|
131
|
+
function boundedResponse(response: Response, url: URL, maxBytes: number): Response {
|
|
132
|
+
if (response.body === null || NULL_BODY_STATUSES.has(response.status) || !Number.isFinite(maxBytes)) {
|
|
133
|
+
return response;
|
|
134
|
+
}
|
|
135
|
+
const tooLarge = (): EgressError => new EgressError({ kind: "response-too-large", url, maxBytes });
|
|
136
|
+
const declared = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
137
|
+
let read = 0;
|
|
138
|
+
const body = response.body.pipeThrough(
|
|
139
|
+
new TransformStream<Uint8Array, Uint8Array>({
|
|
140
|
+
start(controller) {
|
|
141
|
+
if (Number.isFinite(declared) && declared > maxBytes) controller.error(tooLarge());
|
|
142
|
+
},
|
|
143
|
+
transform(chunk, controller) {
|
|
144
|
+
read += chunk.byteLength;
|
|
145
|
+
if (read > maxBytes) {
|
|
146
|
+
controller.error(tooLarge());
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
controller.enqueue(chunk);
|
|
150
|
+
},
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
154
|
+
}
|
package/src/egress/index.ts
CHANGED
|
@@ -13,5 +13,5 @@ export {
|
|
|
13
13
|
type EgressRefusal,
|
|
14
14
|
type LookupFn,
|
|
15
15
|
} from "./check.js";
|
|
16
|
-
export { asFetch, DEFAULT_MAX_REDIRECTS, guardedFetch, type GuardedFetchOptions, type OutboundFetch } from "./fetch.js";
|
|
16
|
+
export { asFetch, DEFAULT_MAX_REDIRECTS, DEFAULT_MAX_RESPONSE_BYTES, guardedFetch, type GuardedFetchOptions, type OutboundFetch } from "./fetch.js";
|
|
17
17
|
export { nodeLookup } from "./node-lookup.js";
|