@foldrun/core 10.1.0 → 11.1.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.
@@ -21,7 +21,8 @@
21
21
  // Past the limit the body errors — a caller never gets the first megabyte
22
22
  // passed off as the whole answer.
23
23
 
24
- import { platform } from "./platform.ts";
24
+ import crypto from "node:crypto";
25
+ import { platform, guardsUntrustedUrls } from "./platform.ts";
25
26
 
26
27
  /** How many redirects one request may follow. fetch's own limit is 20;
27
28
  * nothing a tenant points the platform at needs more than a couple. */
@@ -88,6 +89,54 @@ export function capBody(res: Response, maxBytes: number, url: string): Response
88
89
  return out;
89
90
  }
90
91
 
92
+ /** One request with a client certificate, through node's https — a laptop's
93
+ * fetch has no way to present one. Redirects are the caller's. */
94
+ async function fetchWithClientCert(url: string, init: RequestInit, cert: { cert: string; key: string; passphrase?: string }, maxBytes: number): Promise<Response> {
95
+ const https = await import("node:https");
96
+ const body = init.body === undefined || init.body === null ? undefined : typeof init.body === "string" ? init.body : Buffer.from(await new Response(init.body as BodyInit).arrayBuffer());
97
+ const headers: Record<string, string> = {};
98
+ new Headers(init.headers).forEach((v, k) => (headers[k] = v));
99
+ // fetch decompresses; node's https does not. Ask for none, and decode one
100
+ // that comes anyway (Codex review).
101
+ headers["accept-encoding"] ??= "identity";
102
+ const zlib = await import("node:zlib");
103
+ const { pipeline } = await import("node:stream");
104
+ return new Promise<Response>((resolve, reject) => {
105
+ const r = https.request(url, { method: init.method ?? "GET", headers, cert: cert.cert, key: cert.key, ...(cert.passphrase ? { passphrase: cert.passphrase } : {}), ...(init.signal ? { signal: init.signal } : {}) }, (raw) => {
106
+ const enc = String(raw.headers["content-encoding"] ?? "").toLowerCase();
107
+ const decoder = enc === "gzip" || enc === "x-gzip" ? zlib.createGunzip()
108
+ : enc === "deflate" ? zlib.createInflate()
109
+ : enc === "br" ? zlib.createBrotliDecompress()
110
+ : null;
111
+ // pipeline: a reset mid-answer, or a bad compressed stream, rejects;
112
+ // and one side ending destroys the other (Codex review).
113
+ const res = decoder ? pipeline(raw, decoder, (err) => { if (err) reject(err); }) : raw;
114
+ const chunks: Buffer[] = [];
115
+ let size = 0;
116
+ res.on("data", (c: Buffer) => {
117
+ // The read limit holds while reading, not after (Codex review).
118
+ size += c.length;
119
+ if (size > maxBytes) {
120
+ res.destroy();
121
+ raw.destroy();
122
+ reject(new Error(`${url}: the answer is larger than ${maxBytes} bytes — not read`));
123
+ return;
124
+ }
125
+ chunks.push(c);
126
+ });
127
+ res.on("end", () => {
128
+ const h = new Headers();
129
+ for (const [k, v] of Object.entries(raw.headers)) if (v !== undefined && k !== "content-encoding" && k !== "content-length") h.set(k, Array.isArray(v) ? v.join(", ") : String(v));
130
+ resolve(new Response(chunks.length ? Buffer.concat(chunks) : null, { status: raw.statusCode ?? 502, headers: h }));
131
+ });
132
+ res.on("error", reject);
133
+ });
134
+ r.on("error", reject);
135
+ if (body !== undefined) r.write(body);
136
+ r.end();
137
+ });
138
+ }
139
+
91
140
  export class RedirectRefused extends Error {
92
141
  constructor(message: string) {
93
142
  super(message);
@@ -108,13 +157,23 @@ export async function fetchUntrusted(
108
157
  * caller to treat as the failure it is (a revocation that redirects to
109
158
  * a login page did not revoke anything). */
110
159
  noRedirects?: boolean;
160
+ /** Present this client certificate (mutual TLS, RFC 8705). */
161
+ clientCert?: { cert: string; key: string; passphrase?: string };
111
162
  } = {},
112
163
  ): Promise<Response> {
113
164
  const maxBytes = opts.maxBytes ?? MAX_BYTES;
114
165
  let current = url;
115
166
  let req: RequestInit = { ...init };
116
167
  for (let hop = 0; ; hop++) {
117
- const res = await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, { maxBytes });
168
+ const res = opts.clientCert && !guardsUntrustedUrls()
169
+ // A laptop's plain fetch cannot present a certificate: node's https does.
170
+ ? await fetchWithClientCert(current, { ...req, redirect: "manual" }, opts.clientCert, maxBytes)
171
+ : await platform.fetchUntrusted(current, { ...req, redirect: "manual" }, {
172
+ maxBytes,
173
+ // A pool per certificate: a connection that presented one client's
174
+ // certificate is never reused for another's (Codex review).
175
+ ...(opts.clientCert ? (() => { const fp = crypto.createHash("sha256").update(`${opts.clientCert!.cert}\n${opts.clientCert!.key}`).digest("hex"); return { clientCert: { pool: `oauth-mtls:${fp}`, identity: fp, ...opts.clientCert! } }; })() : {}),
176
+ });
118
177
  const location = REDIRECT.has(res.status) ? res.headers.get("location") : null;
119
178
  if (!location || opts.noRedirects) return capBody(res, maxBytes, current);
120
179
  // The body of a redirect is never read; free the socket.
@@ -131,7 +190,8 @@ export async function fetchUntrusted(
131
190
  }
132
191
  const from = new URL(current);
133
192
  const crossOrigin = next.origin !== from.origin;
134
- if (crossOrigin && opts.sameOrigin) {
193
+ // A client certificate is a credential too: never carried to another origin.
194
+ if (crossOrigin && (opts.sameOrigin || opts.clientCert)) {
135
195
  throw new RedirectRefused(
136
196
  `${from.origin} redirected to ${next.origin} — not followed: this request carries a credential, and it goes only where it was sent`,
137
197
  );