@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 +18 -0
- package/package.json +3 -2
- package/src/broker.test.ts +68 -17
- package/src/broker.ts +40 -9
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.
|
|
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",
|
package/src/broker.test.ts
CHANGED
|
@@ -29,7 +29,8 @@ afterEach(() => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
function make(opts: Parameters<typeof createBroker>[0] = {}): Broker {
|
|
32
|
-
|
|
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
|
|
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({
|
|
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://
|
|
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({
|
|
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({
|
|
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
|
|
1115
|
-
// The target is reached internally through the edge, so
|
|
1116
|
-
//
|
|
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:
|
|
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
|
|
1126
|
-
await expect(b.dispatch({ op: "fetch", url: "
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
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
|
|
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) };
|