@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 +26 -0
- package/package.json +5 -3
- package/src/broker.test.ts +68 -17
- package/src/broker.ts +39 -9
- package/src/egress.test.ts +131 -0
- package/src/egress.ts +150 -0
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.
|
|
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 ./"
|
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,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
|
|
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
|
|
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
|
+
}
|