@sproutboat/wire 0.10.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 9e9638d: An egress service for `fetch()` (baronunread/sproutboat#252). `@sproutboat/wire/egress` runs as its own process (`SB_EGRESS_TOKEN=... bun egress.ts --port 8070`), listens on loopback, requires a token on every call, and performs the vetted fetch itself. A broker given `SB_EGRESS_URL` and `SB_EGRESS_TOKEN` sends `fetch()` through it instead of connecting directly, so on a host where the broker's own unit has no network, sprouts can still reach the internet. The address checks and `SB_EGRESS_ALLOW` behave the same in both modes; refusals read the same; compressed bodies and redirects come back as a direct fetch would see them.
8
+
9
+ `fetch()` also moves on to the next resolved address when one can't be connected to, such as an IPv6 answer on a host with no IPv6 route. It only does so on failures where nothing was sent.
10
+
11
+ ## 0.11.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 4dd16b7: `fetch()` reaches any public address and never a private or reserved one (baronunread/sproutboat#174). The `outbound` allowlist is gone.
16
+
17
+ - Both backends, the broker and the standalone client, check the address they resolved, not the hostname, so DNS rebinding can't get past the check. The broker connects to the vetted address and verifies TLS against the name.
18
+ - Blocked: loopback, RFC 1918, link-local and cloud metadata, CGNAT, benchmarking, documentation, multicast and reserved ranges, IPv6 ULA and link-local, and the IPv4 address inside IPv4-mapped, NAT64 and 6to4 addresses.
19
+ - An operator can let addresses through with `SB_EGRESS_ALLOW` (`*` or exact addresses) in the broker's or the binary's environment. Handler code can't.
20
+ - `@sproutboat/runtime`: `URL.hostname` and `URL.port` handle IPv6 literals (`[::1]:9` used to give hostname `[`). Every sprout gets `fetch()`, with no binding needed. `validateHttpSyncSource` no longer takes an `outboundAllowed` argument, and `Bindings` loses `outbound`.
21
+ - `@sproutboat/wire`: `Bindings` loses `outbound`.
22
+ - `@sproutboat/config`: `outbound` is accepted but ignored, with a warning in the new `warnings` field of a successful parse (`OUTBOUND_IGNORED`). It will be refused in a later minor release.
23
+
24
+ ### Patch Changes
25
+
26
+ - Updated dependencies [4dd16b7]
27
+ - @sproutboat/runtime@0.14.0
28
+
3
29
  ## 0.10.0
4
30
 
5
31
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,12 +11,14 @@
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "dependencies": {
14
- "@sproutboat/assets": "^0.3.0"
14
+ "@sproutboat/assets": "^0.3.0",
15
+ "@sproutboat/runtime": "^0.14.0"
15
16
  },
16
17
  "exports": {
17
18
  ".": "./src/index.ts",
18
19
  "./broker": "./src/broker.ts",
19
- "./package.json": "./package.json"
20
+ "./package.json": "./package.json",
21
+ "./egress": "./src/egress.ts"
20
22
  },
21
23
  "scripts": {
22
24
  "test": "bun test ./"
@@ -29,7 +29,8 @@ afterEach(() => {
29
29
  });
30
30
 
31
31
  function make(opts: Parameters<typeof createBroker>[0] = {}): Broker {
32
- const b = createBroker(opts);
32
+ // A public address, so fetch tests are about fetch and not about DNS (#174).
33
+ const b = createBroker({ resolveImpl: async () => [{ address: "93.184.216.34" }], ...opts });
33
34
  brokers.push(b);
34
35
  return b;
35
36
  }
@@ -151,20 +152,70 @@ test("secrets resolve only when both bound and present", async () => {
151
152
  await expect(b.dispatch({ op: "secret.get", name: "UNBOUND" })).rejects.toThrow("not bound");
152
153
  });
153
154
 
154
- test("fetch is gated by the exact-host allowlist", async () => {
155
+ test("fetch reaches any public host, with no allowlist (#174)", async () => {
155
156
  const calls: string[] = [];
156
157
  const fetchImpl: FetchLike = async (url) => {
157
158
  calls.push(String(url));
158
159
  return new Response("body", { status: 200, headers: { "x-mark": "1" } });
159
160
  };
160
- const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
161
-
162
- await expect(b.dispatch({ op: "fetch", url: "https://evil.example.com/x" })).rejects.toThrow("allowlist");
163
- expect(calls).toEqual([]);
161
+ const b = make({ fetchImpl });
164
162
 
163
+ await expect(b.dispatch({ op: "fetch", url: "ftp://api.example.com/x" })).rejects.toThrow("unsupported protocol");
165
164
  const res = await b.dispatch({ op: "fetch", url: "https://api.example.com/x" });
166
165
  expect(res).toMatchObject({ ok: true, status: 200, body: "body" });
167
- expect(calls).toEqual(["https://api.example.com/x"]);
166
+ expect(calls).toEqual(["https://93.184.216.34/x"]);
167
+ });
168
+
169
+ test("#174: fetch never connects to a private address, whatever the name says", async () => {
170
+ const calls: string[] = [];
171
+ const fetchImpl: FetchLike = async (url) => {
172
+ calls.push(String(url));
173
+ return new Response("ok");
174
+ };
175
+ const resolving = (addresses: string[]) => async () => addresses.map((address) => ({ address }));
176
+
177
+ // A public-looking name that resolves somewhere private is refused before connect.
178
+ for (const address of ["127.0.0.1", "169.254.169.254", "10.0.0.7", "::1", "64:ff9b::7f00:1"]) {
179
+ const b = make({ fetchImpl, resolveImpl: resolving([address]), egressAllow: [] });
180
+ await expect(b.dispatch({ op: "fetch", url: "https://api.example.com/x" })).rejects.toThrow(
181
+ `api.example.com resolves to ${address}, a private or reserved address`,
182
+ );
183
+ }
184
+ expect(calls).toEqual([]);
185
+
186
+ // A mixed answer connects to the public address, not the first one.
187
+ const mixed = make({ fetchImpl, resolveImpl: resolving(["10.0.0.7", "2606:4700::1111"]), egressAllow: [] });
188
+ await mixed.dispatch({ op: "fetch", url: "https://api.example.com/x" });
189
+ expect(calls).toEqual(["https://[2606:4700::1111]/x"]);
190
+
191
+ // The operator, not the handler, can let one address through.
192
+ const allowed = make({ fetchImpl, resolveImpl: resolving(["10.0.0.7"]), egressAllow: ["10.0.0.7"] });
193
+ expect(await allowed.dispatch({ op: "fetch", url: "https://api.example.com/x" })).toMatchObject({ status: 200 });
194
+ });
195
+
196
+ test("#174: the pinned connection still sends the name as Host and verifies TLS against it", async () => {
197
+ const inits: RequestInit[] = [];
198
+ const fetchImpl: FetchLike = async (_url, init) => {
199
+ inits.push(init ?? {});
200
+ return new Response("ok");
201
+ };
202
+ const b = make({ fetchImpl });
203
+ await b.dispatch({ op: "fetch", url: "https://api.example.com/x" });
204
+ expect(new Headers(inits[0].headers).get("host")).toBe("api.example.com");
205
+ expect(inits[0]).toMatchObject({ tls: { serverName: "api.example.com" } });
206
+ });
207
+
208
+ test("#174: a real request reaches an allowed local upstream through the pinned address", async () => {
209
+ const upstream = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: (req) => new Response(req.headers.get("host")) });
210
+ try {
211
+ const host = `localhost:${upstream.port}`;
212
+ const b = make({ resolveImpl: undefined, egressAllow: ["127.0.0.1"] });
213
+ expect(await b.dispatch({ op: "fetch", url: `http://${host}/` })).toMatchObject({ status: 200, body: host });
214
+ const blocked = make({ resolveImpl: undefined, egressAllow: [] });
215
+ await expect(blocked.dispatch({ op: "fetch", url: `http://${host}/` })).rejects.toThrow("SB_EGRESS_ALLOW=");
216
+ } finally {
217
+ upstream.stop(true);
218
+ }
168
219
  });
169
220
 
170
221
  test("#232: a binary fetch request body is sent as its bytes, a text body as UTF-8", async () => {
@@ -173,7 +224,7 @@ test("#232: a binary fetch request body is sent as its bytes, a text body as UTF
173
224
  seen.push(new Uint8Array(await new Response(init?.body).arrayBuffer()));
174
225
  return new Response("ok");
175
226
  };
176
- const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
227
+ const b = make({ fetchImpl });
177
228
  // The runtime sends a binary body one char per byte, flagged bodyBinary.
178
229
  const raw = Array.from({ length: 256 }, (_, i) => String.fromCharCode(i)).join("");
179
230
  await b.dispatch({ op: "fetch", url: "https://api.example.com/up", method: "POST", body: raw, bodyBinary: true });
@@ -186,7 +237,7 @@ test("#232: a v1 fetch returns the upstream body as raw bytes; v0 keeps decoded
186
237
  const binary = Uint8Array.from({ length: 256 }, (_, i) => i);
187
238
  const fetchImpl: FetchLike = async (url) =>
188
239
  new Response(String(url).endsWith("/bin") ? binary : "café", { status: 200 });
189
- const b = make({ bindings: { outbound: ["api.example.com"] }, fetchImpl });
240
+ const b = make({ fetchImpl });
190
241
 
191
242
  const raw = decodeV1Frame(
192
243
  await b.handleFrame(encodeV1({ v: 1, token: "tok", op: "fetch", url: "https://api.example.com/bin" })),
@@ -1111,21 +1162,21 @@ test("service: declared but not deployed says so, instead of a bare 502", async
1111
1162
  );
1112
1163
  });
1113
1164
 
1114
- test("service: a call is not subject to the outbound allowlist", async () => {
1115
- // The target is reached internally through the edge, so a project with no
1116
- // `outbound` hosts can still call a service binding.
1165
+ test("service: a call reaches the edge on loopback, which plain fetch() may not", async () => {
1166
+ // The target is reached internally through the edge, so the private-address
1167
+ // block (#174) does not apply to it.
1117
1168
  const fetchImpl: FetchLike = async () => new Response("ok", { status: 200 });
1118
1169
  const b = make({
1119
- bindings: { ...svcBindings, outbound: [] },
1170
+ bindings: svcBindings,
1120
1171
  services: { AUTH: "auth-api.andrea.example.com" },
1121
1172
  edgeUrl: "http://127.0.0.1:8080/",
1122
1173
  fetchImpl,
1174
+ resolveImpl: async () => [{ address: "127.0.0.1" }],
1175
+ egressAllow: [],
1123
1176
  });
1124
1177
  expect((await b.dispatch({ op: "service.fetch", binding: "AUTH", url: "https://service/" })).status).toBe(200);
1125
- // ...while real egress to the same host is still refused.
1126
- await expect(b.dispatch({ op: "fetch", url: "https://auth-api.andrea.example.com/" })).rejects.toThrow(
1127
- /outbound allowlist/,
1128
- );
1178
+ // ...while real egress to the same place is refused.
1179
+ await expect(b.dispatch({ op: "fetch", url: "http://127.0.0.1:8080/" })).rejects.toThrow("private or reserved");
1129
1180
  });
1130
1181
 
1131
1182
  // --- v1 frames (#63) --------------------------------------------------------
package/src/broker.ts CHANGED
@@ -13,9 +13,9 @@
13
13
  * reply payload : "<json>"
14
14
  *
15
15
  * ponytail: one SQLite file for KV, secrets from a plain JSON file, fetch
16
- * allowlisted by exact host. Encryption at rest, per-key TTLs, redirect
17
- * re-validation and private-IP blocking are v2 — the exact-host allowlist is
18
- * the only SSRF control today.
16
+ * to any public address, never a private one (#174).
17
+ * Encryption at rest and per-key TTLs are v2; redirects are never followed,
18
+ * so there is nothing to re-validate.
19
19
  */
20
20
  import { Database, type Statement } from "bun:sqlite";
21
21
  import { createHash, randomBytes } from "node:crypto";
@@ -24,13 +24,14 @@ import { open } from "node:fs/promises";
24
24
  import { dirname, join, normalize, resolve } from "node:path";
25
25
  import { parseArgs } from "node:util";
26
26
  import { resolveAssetKey, type AssetManifest } from "@sproutboat/assets";
27
+ import { egressAllowList } from "@sproutboat/runtime";
28
+ import { EGRESS_ERROR_HEADER, EGRESS_TOKEN_HEADER, EGRESS_URL_HEADER, resolveAll, vettedFetch } from "./egress";
27
29
  import { isSafeInteger } from "./json";
28
30
  import { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
29
31
 
30
32
  export type Bindings = {
31
33
  kv: string[];
32
34
  secrets: string[];
33
- outbound: string[];
34
35
  d1: string[];
35
36
  r2: string[];
36
37
  queues: string[];
@@ -117,6 +118,15 @@ export type BrokerOptions = {
117
118
  assetsDir?: string;
118
119
  /** Injected in tests; defaults to the global `fetch`. */
119
120
  fetchImpl?: FetchLike;
121
+ /** Injected in tests; defaults to `dns.lookup` with every address. */
122
+ resolveImpl?: (hostname: string) => Promise<Array<{ address: string }>>;
123
+ /** #174 — private addresses `fetch()` may reach anyway. Defaults to
124
+ * `SB_EGRESS_ALLOW` (`*` or comma-separated exact addresses). */
125
+ egressAllow?: string[];
126
+ /** #252 — send `fetch()` through the egress service (egress.ts) instead of
127
+ * connecting directly. Defaults to `SB_EGRESS_URL` / `SB_EGRESS_TOKEN`. */
128
+ egressUrl?: string;
129
+ egressToken?: string;
120
130
  };
121
131
 
122
132
  type SqlParam = string | number | null;
@@ -214,7 +224,6 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
214
224
  const bindings: Bindings = {
215
225
  kv: [],
216
226
  secrets: [],
217
- outbound: [],
218
227
  d1: [],
219
228
  r2: [],
220
229
  queues: [],
@@ -255,6 +264,10 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
255
264
  const replayed = new Map<string, Frame>();
256
265
  const serviceHosts = opts.services ?? {};
257
266
  const doFetch = opts.fetchImpl ?? fetch;
267
+ const resolveHost = opts.resolveImpl ?? resolveAll;
268
+ const egressAllow = opts.egressAllow ?? egressAllowList(process.env.SB_EGRESS_ALLOW);
269
+ const egressUrl = opts.egressUrl ?? process.env.SB_EGRESS_URL ?? "";
270
+ const egressToken = opts.egressToken ?? process.env.SB_EGRESS_TOKEN ?? "";
258
271
 
259
272
  const dbPath = opts.db ?? ":memory:";
260
273
  const inMemory = dbPath === ":memory:" || dbPath === "";
@@ -1129,7 +1142,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1129
1142
  *
1130
1143
  * The size is the remote host's choice, and the body is held whole: measured
1131
1144
  * on a standalone binary, a 100 MB response took resident memory from 43 MB
1132
- * to 321 MB. One allowlisted upstream having a bad day should not be able to
1145
+ * to 321 MB. One upstream having a bad day should not be able to
1133
1146
  * end the process. 32 MiB by default, and the same variable the embedded
1134
1147
  * transport reads so both backends agree.
1135
1148
  */
@@ -1171,6 +1184,21 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1171
1184
  ? new Uint8Array(Buffer.from(str(msg.body), "latin1"))
1172
1185
  : str(msg.body);
1173
1186
 
1187
+ /** #252 — the egress service vets and fetches; its refusal reads as ours. */
1188
+ async function viaEgressService(url: URL, init: RequestInit): Promise<Response> {
1189
+ const headers = new Headers(init.headers);
1190
+ headers.set(EGRESS_URL_HEADER, url.href);
1191
+ headers.set(EGRESS_TOKEN_HEADER, egressToken);
1192
+ const res = await doFetch(egressUrl, { ...init, headers });
1193
+ if (res.headers.get(EGRESS_ERROR_HEADER)) {
1194
+ // SAFETY: the egress service answers a refusal with { error: string }.
1195
+ const { error } = (await res.json()) as { error: string };
1196
+ throw new Error(error);
1197
+ }
1198
+ if (res.status === 401) throw new Error("the egress service rejected this broker's token");
1199
+ return res;
1200
+ }
1201
+
1174
1202
  async function proxyFetch(msg: Frame): Promise<Upstream> {
1175
1203
  let url: URL;
1176
1204
  try {
@@ -1179,7 +1207,6 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1179
1207
  throw new Error(`invalid url: ${str(msg.url)}`);
1180
1208
  }
1181
1209
  if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`unsupported protocol: ${url.protocol}`);
1182
- if (!bindings.outbound.includes(url.host)) throw new Error(`host not in outbound allowlist: ${url.host}`);
1183
1210
 
1184
1211
  const headers = new Headers();
1185
1212
  if (Array.isArray(msg.headers)) {
@@ -1188,12 +1215,15 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1188
1215
  }
1189
1216
  }
1190
1217
  const method = str(msg.method || "GET").toUpperCase();
1191
- const res = await doFetch(url, {
1218
+ const init: RequestInit = {
1192
1219
  method,
1193
1220
  headers,
1194
1221
  body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1195
1222
  redirect: "manual",
1196
- });
1223
+ };
1224
+ const res = egressUrl
1225
+ ? await viaEgressService(url, init)
1226
+ : await vettedFetch(url, init, { resolve: resolveHost, allow: egressAllow, fetchImpl: doFetch });
1197
1227
  const outHeaders: Array<[string, string]> = [];
1198
1228
  res.headers.forEach((v, k) => outHeaders.push([k, v]));
1199
1229
  return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, url.host) };
@@ -0,0 +1,131 @@
1
+ import { afterEach, expect, test } from "bun:test";
2
+ import { gzipSync } from "node:zlib";
3
+ import { createBroker } from "./broker";
4
+ import { createEgressHandler, EGRESS_ERROR_HEADER, EGRESS_TOKEN_HEADER, EGRESS_URL_HEADER, vettedFetch } from "./egress";
5
+
6
+ const TOKEN = "egress-test-token";
7
+ const stops: Array<() => void> = [];
8
+ afterEach(() => {
9
+ for (const stop of stops.splice(0)) stop();
10
+ });
11
+
12
+ /** A real upstream on loopback: echoes method, body and Host, or answers gzip / a redirect. */
13
+ function upstream() {
14
+ const server = Bun.serve({
15
+ port: 0,
16
+ hostname: "127.0.0.1",
17
+ fetch: async (request) => {
18
+ const path = new URL(request.url).pathname;
19
+ if (path === "/gzip")
20
+ return new Response(gzipSync("compressed hello"), { headers: { "content-encoding": "gzip" } });
21
+ if (path === "/redirect") return new Response(null, { status: 302, headers: { location: "http://127.0.0.2/" } });
22
+ return Response.json(
23
+ { method: request.method, body: await request.text(), host: request.headers.get("host") },
24
+ { status: 201, headers: { "x-upstream": "1" } },
25
+ );
26
+ },
27
+ });
28
+ stops.push(() => server.stop(true));
29
+ return server;
30
+ }
31
+
32
+ /** The egress service on loopback, allowed to reach 127.0.0.1 only (for the upstream). */
33
+ function egress() {
34
+ const server = Bun.serve({
35
+ port: 0,
36
+ hostname: "127.0.0.1",
37
+ fetch: createEgressHandler({ token: TOKEN, allow: ["127.0.0.1"] }),
38
+ });
39
+ stops.push(() => server.stop(true));
40
+ return server;
41
+ }
42
+
43
+ const call = (service: { url: URL }, target: string, init: RequestInit = {}, token = TOKEN) =>
44
+ fetch(service.url, {
45
+ ...init,
46
+ // SAFETY: every caller here passes headers as a plain record, or none.
47
+ headers: { ...(init.headers as Record<string, string>), [EGRESS_URL_HEADER]: target, [EGRESS_TOKEN_HEADER]: token },
48
+ });
49
+
50
+ test("#252: the service relays a request and its response, Host set to the target's name", async () => {
51
+ const up = upstream();
52
+ const res = await call(egress(), `http://localhost:${up.port}/x`, { method: "POST", body: "payload" });
53
+ expect(res.status).toBe(201);
54
+ expect(res.headers.get("x-upstream")).toBe("1");
55
+ expect(await res.json()).toEqual({ method: "POST", body: "payload", host: `localhost:${up.port}` });
56
+ });
57
+
58
+ test("#252: compressed bodies and redirects pass through as a direct fetch would see them", async () => {
59
+ const up = upstream();
60
+ const service = egress();
61
+ const gz = await call(service, `http://127.0.0.1:${up.port}/gzip`);
62
+ expect([gz.headers.get("content-encoding"), await gz.text()]).toEqual(["gzip", "compressed hello"]);
63
+ const hop = await call(service, `http://127.0.0.1:${up.port}/redirect`, { redirect: "manual" });
64
+ expect([hop.status, hop.headers.get("location")]).toEqual([302, "http://127.0.0.2/"]);
65
+ });
66
+
67
+ test("#252: without the token the service does nothing", async () => {
68
+ const up = upstream();
69
+ const res = await call(egress(), `http://127.0.0.1:${up.port}/`, {}, "wrong-token-of-same-size");
70
+ expect(res.status).toBe(401);
71
+ expect(createEgressHandler.bind(null, { token: "" })).toThrow("needs a token");
72
+ });
73
+
74
+ test("#252: the service refuses private addresses with the #174 message", async () => {
75
+ const res = await call(egress(), "http://169.254.169.254/latest/meta-data/");
76
+ expect(res.headers.get(EGRESS_ERROR_HEADER)).toBe("1");
77
+ // SAFETY: a refusal (EGRESS_ERROR_HEADER, checked above) is { error: string }.
78
+ expect(((await res.json()) as { error: string }).error).toBe(
79
+ "fetch() refused: 169.254.169.254 resolves to 169.254.169.254, a private or reserved address (169.254.0.0/16). An operator can allow it with SB_EGRESS_ALLOW=169.254.169.254",
80
+ );
81
+ });
82
+
83
+ test("#252: a broker given SB_EGRESS_URL fetches through the service, refusals included", async () => {
84
+ const up = upstream();
85
+ const service = egress();
86
+ // The broker itself may not connect anywhere: egressAllow [] and a resolver
87
+ // that would only ever answer loopback. Success can only come via the service.
88
+ const broker = createBroker({
89
+ egressUrl: service.url.href,
90
+ egressToken: TOKEN,
91
+ egressAllow: [],
92
+ resolveImpl: async () => [{ address: "127.0.0.1" }],
93
+ });
94
+ stops.push(() => broker.close());
95
+
96
+ const ok = await broker.dispatch({ op: "fetch", url: `http://127.0.0.1:${up.port}/gzip` });
97
+ expect(ok).toMatchObject({ ok: true, status: 200, body: "compressed hello" });
98
+
99
+ await expect(broker.dispatch({ op: "fetch", url: "http://10.0.0.1/" })).rejects.toThrow(
100
+ "fetch() refused: 10.0.0.1 resolves to 10.0.0.1",
101
+ );
102
+
103
+ const stranger = createBroker({ egressUrl: service.url.href, egressToken: "not-the-token" });
104
+ stops.push(() => stranger.close());
105
+ await expect(stranger.dispatch({ op: "fetch", url: `http://127.0.0.1:${up.port}/` })).rejects.toThrow(
106
+ "rejected this broker's token",
107
+ );
108
+ });
109
+
110
+ test("an address that cannot be connected to falls through to the next, and only then", async () => {
111
+ const tried: string[] = [];
112
+ const unreachable = Object.assign(new TypeError("no route"), { code: "FailedToOpenSocket" });
113
+ const fetchImpl = async (url: URL | string) => {
114
+ tried.push(new URL(url).hostname);
115
+ if (tried.length === 1) throw unreachable;
116
+ return new Response("ok");
117
+ };
118
+ const resolve = async () => [{ address: "2606:4700::1111" }, { address: "1.1.1.1" }];
119
+ const res = await vettedFetch(new URL("https://one.example/"), {}, { resolve, allow: [], fetchImpl });
120
+ expect([tried, await res.text()]).toEqual([["[2606:4700::1111]", "1.1.1.1"], "ok"]);
121
+
122
+ // Any other failure may have sent the request already: no second attempt.
123
+ tried.length = 0;
124
+ const reset = Object.assign(new TypeError("reset"), { code: "ConnectionClosed" });
125
+ const failing = async (url: URL | string) => {
126
+ tried.push(new URL(url).hostname);
127
+ throw reset;
128
+ };
129
+ await expect(vettedFetch(new URL("https://one.example/"), {}, { resolve, allow: [], fetchImpl: failing })).rejects.toBe(reset);
130
+ expect(tried).toEqual(["[2606:4700::1111]"]);
131
+ });
package/src/egress.ts ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Outbound `fetch()` for sprouts: the private-address block (#174) and the
3
+ * egress service (baronunread/sproutboat#252).
4
+ *
5
+ * The broker normally fetches itself. On the systemd install it runs inside the
6
+ * edge unit, whose `IPAddressDeny=any` keeps sprouts off the network and the
7
+ * broker with them, so it hands each request to this service instead: a
8
+ * separate unit outside that cgroup, listening on loopback. Sprouts share
9
+ * loopback, so every call carries a token that only brokers are given (the
10
+ * sprout sandbox clears its environment).
11
+ *
12
+ * SB_EGRESS_TOKEN=... bun egress.ts --port 8070
13
+ *
14
+ * Either way the request goes through `vettedFetch`, so the address checks and
15
+ * `SB_EGRESS_ALLOW` behave identically.
16
+ */
17
+ import { timingSafeEqual } from "node:crypto";
18
+ import { lookup } from "node:dns/promises";
19
+ import { isIP } from "node:net";
20
+ import { parseArgs } from "node:util";
21
+ import { egressAllowList, egressRefusal } from "@sproutboat/runtime";
22
+
23
+ type FetchLike = (input: URL | string, init?: RequestInit) => Promise<Response>;
24
+ export type Resolve = (hostname: string) => Promise<Array<{ address: string }>>;
25
+ export const resolveAll: Resolve = (hostname) => lookup(hostname, { all: true });
26
+
27
+ /** The target URL, the caller's token, and the marker on a refusal reply. */
28
+ export const EGRESS_URL_HEADER = "x-sb-egress-url";
29
+ export const EGRESS_TOKEN_HEADER = "x-sb-egress-token";
30
+ export const EGRESS_ERROR_HEADER = "x-sb-egress-error";
31
+
32
+ /**
33
+ * #174 — resolve, vet every address, then connect to the vetted one, never the
34
+ * name: handing the name to fetch() would resolve it again, and DNS rebinding
35
+ * lives in that gap. TLS still verifies against the name. Throws the refusal.
36
+ */
37
+ export async function vettedFetch(
38
+ url: URL,
39
+ init: RequestInit,
40
+ opts: { resolve: Resolve; allow: readonly string[]; fetchImpl: FetchLike },
41
+ ): Promise<Response> {
42
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
43
+ let addresses: Array<{ address: string }>;
44
+ try {
45
+ addresses = await opts.resolve(hostname);
46
+ } catch {
47
+ throw new Error(`could not resolve ${url.hostname}`);
48
+ }
49
+ let refusal: string | null = null;
50
+ const vetted = addresses.filter(({ address }) => {
51
+ const why = egressRefusal(url.hostname, address, opts.allow);
52
+ refusal ??= why;
53
+ return why === null;
54
+ });
55
+ if (vetted.length === 0) throw new Error(refusal ?? `could not resolve ${url.hostname}`);
56
+ const headers = new Headers(init.headers);
57
+ headers.set("host", url.host);
58
+ const pinnedInit: RequestInit & { tls?: { serverName: string } } = { ...init, headers, redirect: "manual" };
59
+ // Bun's TLS option: SNI and certificate checks against the name, not the address.
60
+ if (url.protocol === "https:" && !isIP(hostname)) pinnedInit.tls = { serverName: hostname };
61
+ // Like the standalone client, move on to the next address when one cannot be
62
+ // connected to (an IPv6 answer on a host with no IPv6 route). Only on those
63
+ // codes: nothing has been sent yet, so a retry cannot repeat a request.
64
+ let lastError: unknown;
65
+ for (const { address } of vetted) {
66
+ const pinned = new URL(url);
67
+ pinned.hostname = isIP(address) === 6 ? `[${address}]` : address;
68
+ try {
69
+ return await opts.fetchImpl(pinned, pinnedInit);
70
+ } catch (error) {
71
+ lastError = error;
72
+ // SAFETY: only reads an optional `code`; anything without one is rethrown.
73
+ const code = (error as { code?: unknown } | null)?.code;
74
+ if (!CONNECT_FAILURES.has(String(code))) throw error;
75
+ }
76
+ }
77
+ throw lastError;
78
+ }
79
+
80
+ /** Bun's fetch codes for a connection that never opened. */
81
+ const CONNECT_FAILURES = new Set(["FailedToOpenSocket", "ConnectionRefused", "ECONNREFUSED", "ENETUNREACH", "EHOSTUNREACH"]);
82
+
83
+ const refuse = (status: number, error: string) =>
84
+ Response.json({ error }, { status, headers: { [EGRESS_ERROR_HEADER]: "1" } });
85
+
86
+ export type EgressOptions = {
87
+ /** Required: brokers send it, sprouts never see it. */
88
+ token: string;
89
+ /** `SB_EGRESS_ALLOW`, split. */
90
+ allow?: string[];
91
+ /** Injected in tests. */
92
+ resolve?: Resolve;
93
+ fetchImpl?: FetchLike;
94
+ };
95
+
96
+ /** The service's request handler: one upstream request per call. */
97
+ export function createEgressHandler(opts: EgressOptions): (request: Request) => Promise<Response> {
98
+ if (!opts.token) throw new Error("the egress service needs a token");
99
+ const expected = Buffer.from(opts.token);
100
+ const fetchImpl = opts.fetchImpl ?? fetch;
101
+ const resolve = opts.resolve ?? resolveAll;
102
+ const allow = opts.allow ?? [];
103
+ return async (request) => {
104
+ const given = Buffer.from(request.headers.get(EGRESS_TOKEN_HEADER) ?? "");
105
+ if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
106
+ return new Response("unauthorized", { status: 401 });
107
+ }
108
+ let url: URL;
109
+ try {
110
+ url = new URL(request.headers.get(EGRESS_URL_HEADER) ?? "");
111
+ } catch {
112
+ return refuse(400, `invalid url: ${request.headers.get(EGRESS_URL_HEADER) ?? ""}`);
113
+ }
114
+ if (url.protocol !== "https:" && url.protocol !== "http:") return refuse(400, `unsupported protocol: ${url.protocol}`);
115
+ const headers = new Headers(request.headers);
116
+ for (const name of [EGRESS_URL_HEADER, EGRESS_TOKEN_HEADER, "host", "content-length"]) headers.delete(name);
117
+ const method = request.method;
118
+ // `decompress: false` relays the upstream's bytes and Content-Encoding as
119
+ // they came, so the broker decodes them exactly as a direct fetch would.
120
+ const init: RequestInit & { decompress?: boolean } = {
121
+ method,
122
+ headers,
123
+ body: method === "GET" || method === "HEAD" ? undefined : await request.arrayBuffer(),
124
+ decompress: false,
125
+ };
126
+ try {
127
+ const upstream = await vettedFetch(url, init, { resolve, allow, fetchImpl });
128
+ return new Response(upstream.body, { status: upstream.status, headers: upstream.headers });
129
+ } catch (error) {
130
+ return refuse(502, error instanceof Error ? error.message : String(error));
131
+ }
132
+ };
133
+ }
134
+
135
+ if (import.meta.main) {
136
+ const { values } = parseArgs({ options: { port: { type: "string" }, host: { type: "string" } } });
137
+ const token = process.env.SB_EGRESS_TOKEN ?? "";
138
+ if (!token) {
139
+ console.error("sproutboat egress: SB_EGRESS_TOKEN is required");
140
+ process.exit(1);
141
+ }
142
+ const server = Bun.serve({
143
+ hostname: values.host ?? "127.0.0.1",
144
+ port: Number(values.port ?? process.env.PORT ?? 8070),
145
+ // A direct fetch has no idle cutoff either; the broker owns the deadline.
146
+ idleTimeout: 0,
147
+ fetch: createEgressHandler({ token, allow: egressAllowList(process.env.SB_EGRESS_ALLOW) }),
148
+ });
149
+ console.log(`sproutboat egress listening on ${server.url}`);
150
+ }