@sproutboat/wire 0.10.0 → 0.11.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,23 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4dd16b7: `fetch()` reaches any public address and never a private or reserved one (baronunread/sproutboat#174). The `outbound` allowlist is gone.
8
+
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - `@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`.
13
+ - `@sproutboat/wire`: `Bindings` loses `outbound`.
14
+ - `@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.
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [4dd16b7]
19
+ - @sproutboat/runtime@0.14.0
20
+
3
21
  ## 0.10.0
4
22
 
5
23
  ### 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.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {
@@ -11,7 +11,8 @@
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",
@@ -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,15 @@ 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, egressRefusal } from "@sproutboat/runtime";
28
+ import { lookup } from "node:dns/promises";
29
+ import { isIP } from "node:net";
27
30
  import { isSafeInteger } from "./json";
28
31
  import { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
29
32
 
30
33
  export type Bindings = {
31
34
  kv: string[];
32
35
  secrets: string[];
33
- outbound: string[];
34
36
  d1: string[];
35
37
  r2: string[];
36
38
  queues: string[];
@@ -117,6 +119,11 @@ export type BrokerOptions = {
117
119
  assetsDir?: string;
118
120
  /** Injected in tests; defaults to the global `fetch`. */
119
121
  fetchImpl?: FetchLike;
122
+ /** Injected in tests; defaults to `dns.lookup` with every address. */
123
+ resolveImpl?: (hostname: string) => Promise<Array<{ address: string }>>;
124
+ /** #174 — private addresses `fetch()` may reach anyway. Defaults to
125
+ * `SB_EGRESS_ALLOW` (`*` or comma-separated exact addresses). */
126
+ egressAllow?: string[];
120
127
  };
121
128
 
122
129
  type SqlParam = string | number | null;
@@ -214,7 +221,6 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
214
221
  const bindings: Bindings = {
215
222
  kv: [],
216
223
  secrets: [],
217
- outbound: [],
218
224
  d1: [],
219
225
  r2: [],
220
226
  queues: [],
@@ -255,6 +261,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
255
261
  const replayed = new Map<string, Frame>();
256
262
  const serviceHosts = opts.services ?? {};
257
263
  const doFetch = opts.fetchImpl ?? fetch;
264
+ const resolveHost = opts.resolveImpl ?? ((hostname: string) => lookup(hostname, { all: true }));
265
+ const egressAllow = opts.egressAllow ?? egressAllowList(process.env.SB_EGRESS_ALLOW);
258
266
 
259
267
  const dbPath = opts.db ?? ":memory:";
260
268
  const inMemory = dbPath === ":memory:" || dbPath === "";
@@ -1129,7 +1137,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1129
1137
  *
1130
1138
  * The size is the remote host's choice, and the body is held whole: measured
1131
1139
  * 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
1140
+ * to 321 MB. One upstream having a bad day should not be able to
1133
1141
  * end the process. 32 MiB by default, and the same variable the embedded
1134
1142
  * transport reads so both backends agree.
1135
1143
  */
@@ -1179,7 +1187,6 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1179
1187
  throw new Error(`invalid url: ${str(msg.url)}`);
1180
1188
  }
1181
1189
  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
1190
 
1184
1191
  const headers = new Headers();
1185
1192
  if (Array.isArray(msg.headers)) {
@@ -1187,13 +1194,37 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1187
1194
  if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
1188
1195
  }
1189
1196
  }
1197
+ // #174 — resolve, vet every address, then connect to the vetted one, never
1198
+ // the name: handing the name to fetch() would resolve it again, and DNS
1199
+ // rebinding lives in that gap. TLS still verifies against the name.
1200
+ const hostname = url.hostname.replace(/^\[|\]$/g, "");
1201
+ let addresses: Array<{ address: string }>;
1202
+ try {
1203
+ addresses = await resolveHost(hostname);
1204
+ } catch {
1205
+ throw new Error(`could not resolve ${url.hostname}`);
1206
+ }
1207
+ let refusal: string | null = null;
1208
+ const vetted = addresses.find(({ address }) => {
1209
+ const why = egressRefusal(url.hostname, address, egressAllow);
1210
+ refusal ??= why;
1211
+ return why === null;
1212
+ });
1213
+ if (!vetted) throw new Error(refusal ?? `could not resolve ${url.hostname}`);
1214
+ const pinned = new URL(url);
1215
+ pinned.hostname = isIP(vetted.address) === 6 ? `[${vetted.address}]` : vetted.address;
1216
+ headers.set("host", url.host);
1217
+
1190
1218
  const method = str(msg.method || "GET").toUpperCase();
1191
- const res = await doFetch(url, {
1219
+ const init: RequestInit & { tls?: { serverName: string } } = {
1192
1220
  method,
1193
1221
  headers,
1194
1222
  body: method === "GET" || method === "HEAD" ? undefined : upstreamBody(msg),
1195
1223
  redirect: "manual",
1196
- });
1224
+ };
1225
+ // Bun's TLS option: SNI and certificate checks against the name, not the address.
1226
+ if (url.protocol === "https:" && !isIP(hostname)) init.tls = { serverName: hostname };
1227
+ const res = await doFetch(pinned, init);
1197
1228
  const outHeaders: Array<[string, string]> = [];
1198
1229
  res.headers.forEach((v, k) => outHeaders.push([k, v]));
1199
1230
  return { reply: { ok: true, status: res.status, headers: outHeaders }, bytes: await readCapped(res, url.host) };