@openparachute/hub 0.7.3-rc.8 → 0.7.3
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/package.json +1 -1
- package/src/__tests__/cli.test.ts +20 -0
- package/src/__tests__/hub-command.test.ts +433 -0
- package/src/__tests__/hub-origins-env-set.test.ts +273 -0
- package/src/__tests__/hub-server.test.ts +240 -0
- package/src/__tests__/init.test.ts +148 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/serve.test.ts +67 -1
- package/src/__tests__/setup-wizard.test.ts +121 -1
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/api-modules-ops.ts +12 -0
- package/src/cli.ts +35 -1
- package/src/commands/hub.ts +409 -0
- package/src/commands/init.ts +63 -0
- package/src/commands/serve-boot.ts +16 -2
- package/src/commands/serve.ts +39 -3
- package/src/help.ts +8 -0
- package/src/hub-origin.ts +64 -0
- package/src/hub-server.ts +46 -0
- package/src/jwt-sign.ts +14 -3
- package/src/setup-wizard.ts +20 -0
- package/src/vault-hub-origin-env.ts +109 -16
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-origin iss-set (onboarding-streamline 2026-06-25) — hub side.
|
|
3
|
+
*
|
|
4
|
+
* The hub publishes the SET of origins it legitimately answers on to its
|
|
5
|
+
* supervised resource servers via `PARACHUTE_HUB_ORIGINS` (comma-separated),
|
|
6
|
+
* alongside the single canonical `PARACHUTE_HUB_ORIGIN`. A resource server on
|
|
7
|
+
* scope-guard ≥0.5.0 widens its accepted-`iss` check to this set so a token
|
|
8
|
+
* minted under one URL of a multi-URL box validates via another URL of the
|
|
9
|
+
* SAME box.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin two things:
|
|
12
|
+
* 1. The serialize/parse round-trip + the assembly from hub-controlled
|
|
13
|
+
* inputs (issuer ∪ loopback aliases ∪ expose-state ∪ platform).
|
|
14
|
+
* 2. The SECURITY INVARIANT: the set is built ONLY from operator/hub config
|
|
15
|
+
* and on-disk state — never from an unvalidated request `Host` /
|
|
16
|
+
* `X-Forwarded-Host`. We feed an attacker-controlled "Host" through every
|
|
17
|
+
* input channel a request could plausibly reach and assert it never lands
|
|
18
|
+
* in the published set.
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
21
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { resolveStartupIssuer } from "../commands/serve.ts";
|
|
25
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
26
|
+
import { parseHubOrigins, serializeHubOrigins } from "../hub-origin.ts";
|
|
27
|
+
import { getHubOrigin, setHubOrigin } from "../hub-settings.ts";
|
|
28
|
+
import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
|
|
29
|
+
|
|
30
|
+
describe("serializeHubOrigins / parseHubOrigins round-trip", () => {
|
|
31
|
+
test("serialize dedupes, drops empties, strips trailing slashes", () => {
|
|
32
|
+
const v = serializeHubOrigins([
|
|
33
|
+
"https://a.example/",
|
|
34
|
+
"https://a.example",
|
|
35
|
+
"",
|
|
36
|
+
"https://b.example",
|
|
37
|
+
]);
|
|
38
|
+
expect(v).toBe("https://a.example,https://b.example");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("serialize returns undefined when nothing survives", () => {
|
|
42
|
+
expect(serializeHubOrigins([])).toBeUndefined();
|
|
43
|
+
expect(serializeHubOrigins(["", " "])).toBeUndefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("parse is the inverse — tolerant of whitespace + trailing slashes + empties", () => {
|
|
47
|
+
expect(parseHubOrigins("https://a.example, https://b.example/ ,,")).toEqual([
|
|
48
|
+
"https://a.example",
|
|
49
|
+
"https://b.example",
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("parse returns [] for absent/empty/garbage", () => {
|
|
54
|
+
expect(parseHubOrigins(undefined)).toEqual([]);
|
|
55
|
+
expect(parseHubOrigins("")).toEqual([]);
|
|
56
|
+
expect(parseHubOrigins(" , , ")).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("round-trips a real set", () => {
|
|
60
|
+
const origins = ["https://example.com", "http://127.0.0.1:1939", "http://localhost:1939"];
|
|
61
|
+
const wire = serializeHubOrigins(origins)!;
|
|
62
|
+
expect(parseHubOrigins(wire)).toEqual(origins);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("buildHubOriginsEnvValue — assembles the hub's legitimate-origin set", () => {
|
|
67
|
+
let dir: string;
|
|
68
|
+
const EXPOSE = () => join(dir, "expose-state.json");
|
|
69
|
+
|
|
70
|
+
/** Write a schema-valid expose-state.json carrying the given hubOrigin. */
|
|
71
|
+
function writeExposeState(hubOrigin: string): void {
|
|
72
|
+
writeFileSync(
|
|
73
|
+
EXPOSE(),
|
|
74
|
+
JSON.stringify({
|
|
75
|
+
version: 1,
|
|
76
|
+
layer: "public",
|
|
77
|
+
mode: "path",
|
|
78
|
+
canonicalFqdn: new URL(hubOrigin).host,
|
|
79
|
+
port: 1939,
|
|
80
|
+
funnel: true,
|
|
81
|
+
entries: [],
|
|
82
|
+
hubOrigin,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
dir = mkdtempSync(join(tmpdir(), "hub-origins-set-"));
|
|
89
|
+
// No hub.port file in this fresh configDir → readHubPort falls back to
|
|
90
|
+
// HUB_UNIT_DEFAULT_PORT (1939), the deterministic value these cases assert.
|
|
91
|
+
});
|
|
92
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
93
|
+
|
|
94
|
+
test("issuer ∪ loopback aliases when no expose / platform origin", () => {
|
|
95
|
+
const v = buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE());
|
|
96
|
+
const set = parseHubOrigins(v);
|
|
97
|
+
expect(set).toContain("https://example.com");
|
|
98
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
99
|
+
expect(set).toContain("http://localhost:1939");
|
|
100
|
+
expect(set).toHaveLength(3);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("loopback aliases are ALWAYS present (the always-present invariant)", () => {
|
|
104
|
+
// Even with a public issuer, loopback must be in the set so the co-located
|
|
105
|
+
// CLI / loopback-proxied request path validates.
|
|
106
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
107
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
108
|
+
expect(set).toContain("http://localhost:1939");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("folds in the expose-state public origin", () => {
|
|
112
|
+
writeExposeState("https://box.sslip.io");
|
|
113
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
114
|
+
expect(set).toContain("https://example.com");
|
|
115
|
+
expect(set).toContain("https://box.sslip.io");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("folds in the platform origin (RENDER_EXTERNAL_URL)", () => {
|
|
119
|
+
const set = parseHubOrigins(
|
|
120
|
+
buildHubOriginsEnvValue(
|
|
121
|
+
dir,
|
|
122
|
+
"https://example.com",
|
|
123
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
124
|
+
EXPOSE(),
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
expect(set).toContain("https://app.onrender.com");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("folds in the composed Fly default origin", () => {
|
|
131
|
+
const set = parseHubOrigins(
|
|
132
|
+
buildHubOriginsEnvValue(dir, "https://example.com", { FLY_APP_NAME: "myapp" }, EXPOSE()),
|
|
133
|
+
);
|
|
134
|
+
expect(set).toContain("https://myapp.fly.dev");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("absent issuer → loopback-only set (still useful, never empty on a normal box)", () => {
|
|
138
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, undefined, {}, EXPOSE()));
|
|
139
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
140
|
+
expect(set).toContain("http://localhost:1939");
|
|
141
|
+
// The empty issuer "" is dropped by buildHubBoundOrigins' URL parse.
|
|
142
|
+
expect(set).not.toContain("");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a malformed expose-state.json never throws — falls back to issuer + loopback", () => {
|
|
146
|
+
writeFileSync(EXPOSE(), "{ not valid json");
|
|
147
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
148
|
+
expect(set).toContain("https://example.com");
|
|
149
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("SECURITY INVARIANT — request Host never enters the set", () => {
|
|
153
|
+
const ATTACKER = "https://attacker.evil";
|
|
154
|
+
|
|
155
|
+
test("an attacker Host smuggled via expose-state IS honored ONLY because it's operator-written on-disk state — but a Host passed nowhere never appears", () => {
|
|
156
|
+
// There is NO request input to buildHubOriginsEnvValue at all — it takes
|
|
157
|
+
// configDir + issuer + env + expose-state-path. None of those is a
|
|
158
|
+
// request header. We assert the function's surface offers no channel for
|
|
159
|
+
// a request Host: feeding the attacker value to the only inputs an
|
|
160
|
+
// attacker might influence (a stray env var, a header-shaped string)
|
|
161
|
+
// never reaches the set unless it's a legitimate operator-config var.
|
|
162
|
+
const set = parseHubOrigins(
|
|
163
|
+
buildHubOriginsEnvValue(
|
|
164
|
+
dir,
|
|
165
|
+
"https://example.com",
|
|
166
|
+
{
|
|
167
|
+
// Header-shaped env vars an attacker might hope are read — none are
|
|
168
|
+
// consulted by the assembler (only RENDER_EXTERNAL_URL / FLY_APP_NAME).
|
|
169
|
+
HTTP_HOST: ATTACKER,
|
|
170
|
+
HTTP_X_FORWARDED_HOST: ATTACKER,
|
|
171
|
+
X_FORWARDED_HOST: ATTACKER,
|
|
172
|
+
HOST: ATTACKER,
|
|
173
|
+
} as NodeJS.ProcessEnv,
|
|
174
|
+
EXPOSE(),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
expect(set).not.toContain(ATTACKER);
|
|
178
|
+
expect(set.some((o) => o.includes("attacker"))).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("the set is exactly issuer ∪ loopback ∪ expose ∪ platform — no other source", () => {
|
|
182
|
+
writeExposeState("https://box.sslip.io");
|
|
183
|
+
const set = parseHubOrigins(
|
|
184
|
+
buildHubOriginsEnvValue(
|
|
185
|
+
dir,
|
|
186
|
+
"https://example.com",
|
|
187
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
188
|
+
EXPOSE(),
|
|
189
|
+
),
|
|
190
|
+
);
|
|
191
|
+
// Every member is one of the four sanctioned sources; nothing else.
|
|
192
|
+
const sanctioned = new Set([
|
|
193
|
+
"https://example.com",
|
|
194
|
+
"http://127.0.0.1:1939",
|
|
195
|
+
"http://localhost:1939",
|
|
196
|
+
"https://box.sslip.io",
|
|
197
|
+
"https://app.onrender.com",
|
|
198
|
+
]);
|
|
199
|
+
for (const o of set) expect(sanctioned.has(o)).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* THE CRUX (onboarding-streamline 2026-06-25, Caddy-direct zero-SSH path):
|
|
206
|
+
* a box whose ONLY canonical-origin source is the DB row `hub_settings.hub_origin`
|
|
207
|
+
* (no PARACHUTE_HUB_ORIGIN env, no expose-state, no RENDER/FLY platform var —
|
|
208
|
+
* the bare-droplet-behind-Caddy shape) MUST inject that public origin into the
|
|
209
|
+
* supervised modules' PARACHUTE_HUB_ORIGINS. Otherwise vault/scribe accept only
|
|
210
|
+
* loopback `iss` and reject every token the hub mints under the public origin
|
|
211
|
+
* (which the per-request resolveIssuer DOES stamp from the DB).
|
|
212
|
+
*
|
|
213
|
+
* These tests prove the full boot chain: DB row → `resolveStartupIssuer`
|
|
214
|
+
* (boot-time issuer seed) → `buildHubOriginsEnvValue` (the env injected at
|
|
215
|
+
* child spawn) CONTAINS the public origin.
|
|
216
|
+
*/
|
|
217
|
+
describe("DB hub_origin flows into the injected PARACHUTE_HUB_ORIGINS (Caddy-direct boot chain)", () => {
|
|
218
|
+
let dir: string;
|
|
219
|
+
const noExpose = (): string | undefined => undefined;
|
|
220
|
+
|
|
221
|
+
beforeEach(() => {
|
|
222
|
+
dir = mkdtempSync(join(tmpdir(), "hub-origins-db-"));
|
|
223
|
+
});
|
|
224
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
225
|
+
|
|
226
|
+
test("DB-persisted public origin lands in the assembled origin set", () => {
|
|
227
|
+
// Persist the Caddy public origin to the DB exactly as `hub set-origin` /
|
|
228
|
+
// `init --hub-origin` would.
|
|
229
|
+
const db = openHubDb(hubDbPath(dir));
|
|
230
|
+
setHubOrigin(db, "https://box.sslip.io");
|
|
231
|
+
db.close();
|
|
232
|
+
|
|
233
|
+
// Boot-time issuer resolution reads the DB row (passed as dbHubOrigin) —
|
|
234
|
+
// no env, no expose-state, no platform var (the bare-Caddy shape).
|
|
235
|
+
const dbOrigin = getHubOrigin(openHubDb(hubDbPath(dir))) ?? undefined;
|
|
236
|
+
const issuer = resolveStartupIssuer(
|
|
237
|
+
{ ...(dbOrigin !== undefined ? { dbHubOrigin: dbOrigin } : {}) },
|
|
238
|
+
{},
|
|
239
|
+
noExpose,
|
|
240
|
+
);
|
|
241
|
+
expect(issuer).toBe("https://box.sslip.io");
|
|
242
|
+
|
|
243
|
+
// That issuer seeds the env injected into vault/scribe — the public origin
|
|
244
|
+
// MUST be in their accepted-`iss` set.
|
|
245
|
+
const set = parseHubOrigins(
|
|
246
|
+
buildHubOriginsEnvValue(dir, issuer, {}, join(dir, "expose-state.json")),
|
|
247
|
+
);
|
|
248
|
+
expect(set).toContain("https://box.sslip.io");
|
|
249
|
+
// Loopback aliases stay present (co-located CLI / loopback proxy path).
|
|
250
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
251
|
+
expect(set).toContain("http://localhost:1939");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("no DB origin AND nothing else → loopback-only (the regression this guards: no public origin would leak in)", () => {
|
|
255
|
+
// Fresh DB, no row, no env, no expose-state: the boot issuer is undefined
|
|
256
|
+
// and the set is loopback-only. Proves the fix doesn't fabricate an origin.
|
|
257
|
+
const dbOrigin = getHubOrigin(openHubDb(hubDbPath(dir))) ?? undefined;
|
|
258
|
+
const issuer = resolveStartupIssuer(
|
|
259
|
+
{ ...(dbOrigin !== undefined ? { dbHubOrigin: dbOrigin } : {}) },
|
|
260
|
+
{},
|
|
261
|
+
noExpose,
|
|
262
|
+
);
|
|
263
|
+
expect(issuer).toBeUndefined();
|
|
264
|
+
const set = parseHubOrigins(
|
|
265
|
+
buildHubOriginsEnvValue(dir, issuer, {}, join(dir, "expose-state.json")),
|
|
266
|
+
);
|
|
267
|
+
// Loopback-only — no public origin fabricated. (Order is Set-insertion
|
|
268
|
+
// dependent; assert membership + size rather than a brittle exact array.)
|
|
269
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
270
|
+
expect(set).toContain("http://localhost:1939");
|
|
271
|
+
expect(set).toHaveLength(2);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
@@ -3,6 +3,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
_resetBootstrapTokenForTests,
|
|
8
|
+
generateBootstrapToken,
|
|
9
|
+
} from "../bootstrap-token.ts";
|
|
6
10
|
import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
|
|
7
11
|
import { HUB_SVC, hubPortPath } from "../hub-control.ts";
|
|
8
12
|
import { createDbHolder } from "../hub-db-liveness.ts";
|
|
@@ -3676,6 +3680,74 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
|
|
|
3676
3680
|
});
|
|
3677
3681
|
expect(layerOf(r)).toBe("public");
|
|
3678
3682
|
});
|
|
3683
|
+
|
|
3684
|
+
// Caddy/nginx-direct deploy (hub#704). A same-box reverse proxy dials
|
|
3685
|
+
// loopback (peer 127.0.0.1) and stamps NO cf/tailscale header — but it DOES
|
|
3686
|
+
// set a standard reverse-proxy forwarding header. That header is the
|
|
3687
|
+
// discriminator: a loopback peer carrying one is a proxied PUBLIC request and
|
|
3688
|
+
// must NOT be "loopback" (which would leak the bootstrap token + drop the
|
|
3689
|
+
// loopback-exposure cloak for every public visitor on a Caddy-direct box).
|
|
3690
|
+
test("loopback peer + X-Forwarded-For → public (Caddy-direct, NOT loopback) [#704]", () => {
|
|
3691
|
+
const r = req("/", { headers: { "X-Forwarded-For": "203.0.113.7" } });
|
|
3692
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3693
|
+
});
|
|
3694
|
+
|
|
3695
|
+
test("loopback peer + X-Forwarded-Host → public (Caddy-direct) [#704]", () => {
|
|
3696
|
+
const r = req("/", { headers: { "X-Forwarded-Host": "hub.example.com" } });
|
|
3697
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3698
|
+
});
|
|
3699
|
+
|
|
3700
|
+
test("loopback peer + Forwarded (RFC 7239) → public (Caddy-direct) [#704]", () => {
|
|
3701
|
+
const r = req("/", { headers: { Forwarded: "for=203.0.113.7;host=hub.example.com" } });
|
|
3702
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3703
|
+
});
|
|
3704
|
+
|
|
3705
|
+
test("IPv6-mapped loopback peer + X-Forwarded-For → public (Caddy-direct) [#704]", () => {
|
|
3706
|
+
const r = req("/", { headers: { "X-Forwarded-For": "203.0.113.7" } });
|
|
3707
|
+
expect(layerOf(r, "::ffff:127.0.0.1")).toBe("public");
|
|
3708
|
+
});
|
|
3709
|
+
|
|
3710
|
+
// Security edge: an EMPTY / whitespace-only forwarding header (a misconfigured
|
|
3711
|
+
// or stripped-but-present proxy header) must still err toward "public", never
|
|
3712
|
+
// back to "loopback". `layerOf` uses a presence check (`!== null`), not a
|
|
3713
|
+
// trim — keep it that way: the safe direction for a trust classifier is to
|
|
3714
|
+
// DOWNGRADE on ambiguity. A future "tidy up with .trim()" refactor that
|
|
3715
|
+
// flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
|
|
3716
|
+
test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
|
|
3717
|
+
expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
|
|
3718
|
+
expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe("public");
|
|
3719
|
+
});
|
|
3720
|
+
|
|
3721
|
+
// The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
|
|
3722
|
+
// probe `curl 127.0.0.1/admin/setup`, hub self-requests) sets NO forwarding
|
|
3723
|
+
// header and MUST stay loopback. This is the not-broken half of the fix.
|
|
3724
|
+
test("loopback peer + NO forwarding header → loopback (genuine on-box caller) [#704]", () => {
|
|
3725
|
+
expect(layerOf(req("/"), "127.0.0.1")).toBe("loopback");
|
|
3726
|
+
expect(layerOf(req("/"), "::1")).toBe("loopback");
|
|
3727
|
+
});
|
|
3728
|
+
|
|
3729
|
+
// No spoof vector: forwarding headers can only DOWNGRADE a loopback caller.
|
|
3730
|
+
// A NON-loopback peer is already "public" regardless of headers, so adding
|
|
3731
|
+
// X-Forwarded-* never upgrades a network peer to a more-trusted layer.
|
|
3732
|
+
test("non-loopback peer + X-Forwarded-For → public (no upgrade; spoof-proof) [#704]", () => {
|
|
3733
|
+
const r = req("/", { headers: { "X-Forwarded-For": "127.0.0.1" } });
|
|
3734
|
+
expect(layerOf(r, "203.0.113.7")).toBe("public");
|
|
3735
|
+
});
|
|
3736
|
+
|
|
3737
|
+
// CF/TS header checks run FIRST — a Caddy-style forwarding header alongside a
|
|
3738
|
+
// cf/tailscale header doesn't change those (still public/tailnet). Order
|
|
3739
|
+
// preserved.
|
|
3740
|
+
test("CF-Ray + X-Forwarded-For from loopback peer → public (CF check wins, order preserved)", () => {
|
|
3741
|
+
const r = req("/", { headers: { "CF-Ray": "abc123-DEN", "X-Forwarded-For": "203.0.113.7" } });
|
|
3742
|
+
expect(layerOf(r, "127.0.0.1")).toBe("public");
|
|
3743
|
+
});
|
|
3744
|
+
|
|
3745
|
+
test("Tailscale-User-Login + X-Forwarded-For from loopback peer → tailnet (TS check wins)", () => {
|
|
3746
|
+
const r = req("/", {
|
|
3747
|
+
headers: { "Tailscale-User-Login": "alice@example.com", "X-Forwarded-For": "100.64.0.1" },
|
|
3748
|
+
});
|
|
3749
|
+
expect(layerOf(r, "127.0.0.1")).toBe("tailnet");
|
|
3750
|
+
});
|
|
3679
3751
|
});
|
|
3680
3752
|
|
|
3681
3753
|
describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
@@ -3823,6 +3895,74 @@ describe("hubFetch publicExposure layer-gate (proxyToService)", () => {
|
|
|
3823
3895
|
}
|
|
3824
3896
|
});
|
|
3825
3897
|
|
|
3898
|
+
// hub#704 — Caddy/nginx-direct. A same-box reverse proxy dials loopback
|
|
3899
|
+
// (peer 127.0.0.1) and stamps NO cf/tailscale header, but DOES set
|
|
3900
|
+
// X-Forwarded-For. That request is a PUBLIC visitor proxied to the box and
|
|
3901
|
+
// must hit the loopback-exposure cloak (404) — otherwise a Caddy-direct box
|
|
3902
|
+
// leaks every loopback-only service/vault to the network.
|
|
3903
|
+
test("publicExposure: loopback + X-Forwarded-For + loopback peer → 404 (Caddy-direct cloak fires) [#704]", async () => {
|
|
3904
|
+
const h = makeHarness();
|
|
3905
|
+
const upstream = startUpstream("loopback-only");
|
|
3906
|
+
try {
|
|
3907
|
+
writeManifest(
|
|
3908
|
+
{
|
|
3909
|
+
services: [
|
|
3910
|
+
{
|
|
3911
|
+
name: "loopback-only",
|
|
3912
|
+
port: upstream.port,
|
|
3913
|
+
paths: ["/loopback-only"],
|
|
3914
|
+
health: "/loopback-only/health",
|
|
3915
|
+
version: "0.1.0",
|
|
3916
|
+
publicExposure: "loopback",
|
|
3917
|
+
},
|
|
3918
|
+
],
|
|
3919
|
+
},
|
|
3920
|
+
h.manifestPath,
|
|
3921
|
+
);
|
|
3922
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3923
|
+
// Caddy: peer is 127.0.0.1 (reverse_proxy 127.0.0.1:1939) but the request
|
|
3924
|
+
// carries X-Forwarded-For for the real public client.
|
|
3925
|
+
const r = req("/loopback-only/health", { headers: { "X-Forwarded-For": "203.0.113.7" } });
|
|
3926
|
+
const res = await fetcher(r, fakeServer("127.0.0.1"));
|
|
3927
|
+
expect(res.status).toBe(404);
|
|
3928
|
+
} finally {
|
|
3929
|
+
upstream.stop();
|
|
3930
|
+
h.cleanup();
|
|
3931
|
+
}
|
|
3932
|
+
});
|
|
3933
|
+
|
|
3934
|
+
// The not-broken half: a header-less loopback peer (genuine on-box caller)
|
|
3935
|
+
// still reaches the loopback-only service through the cloak.
|
|
3936
|
+
test("publicExposure: loopback + NO forwarding header + loopback peer → reaches upstream [#704]", async () => {
|
|
3937
|
+
const h = makeHarness();
|
|
3938
|
+
const upstream = startUpstream("loopback-only");
|
|
3939
|
+
try {
|
|
3940
|
+
writeManifest(
|
|
3941
|
+
{
|
|
3942
|
+
services: [
|
|
3943
|
+
{
|
|
3944
|
+
name: "loopback-only",
|
|
3945
|
+
port: upstream.port,
|
|
3946
|
+
paths: ["/loopback-only"],
|
|
3947
|
+
health: "/loopback-only/health",
|
|
3948
|
+
version: "0.1.0",
|
|
3949
|
+
publicExposure: "loopback",
|
|
3950
|
+
},
|
|
3951
|
+
],
|
|
3952
|
+
},
|
|
3953
|
+
h.manifestPath,
|
|
3954
|
+
);
|
|
3955
|
+
const fetcher = hubFetch(h.dir, { manifestPath: h.manifestPath });
|
|
3956
|
+
const res = await fetcher(req("/loopback-only/health"), fakeServer("127.0.0.1"));
|
|
3957
|
+
expect(res.status).toBe(200);
|
|
3958
|
+
const body = (await res.json()) as { tag: string };
|
|
3959
|
+
expect(body.tag).toBe("loopback-only");
|
|
3960
|
+
} finally {
|
|
3961
|
+
upstream.stop();
|
|
3962
|
+
h.cleanup();
|
|
3963
|
+
}
|
|
3964
|
+
});
|
|
3965
|
+
|
|
3826
3966
|
test("publicExposure: allowed + tailnet header → reaches upstream (no gate)", async () => {
|
|
3827
3967
|
const h = makeHarness();
|
|
3828
3968
|
const upstream = startUpstream("allowed");
|
|
@@ -5850,3 +5990,103 @@ describe("resolveClientIp (H2)", () => {
|
|
|
5850
5990
|
expect(resolveClientIp(r, null)).toBeNull();
|
|
5851
5991
|
});
|
|
5852
5992
|
});
|
|
5993
|
+
|
|
5994
|
+
describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + Caddy-direct #704)", () => {
|
|
5995
|
+
// The hub#576 token probe: a LOOPBACK caller (the on-box operator's own
|
|
5996
|
+
// shell / `parachute init`) reading GET /admin/setup with Accept:
|
|
5997
|
+
// application/json gets the actual bootstrap token in the JSON envelope; a
|
|
5998
|
+
// public/tailnet caller does not. The hub derives requestIsLoopback from
|
|
5999
|
+
// `layerOf(req, peerAddr) === "loopback"` (hub-server.ts ~2296), so the
|
|
6000
|
+
// Caddy-direct fix (#704) flows straight through: a same-box reverse proxy
|
|
6001
|
+
// carrying X-Forwarded-For now classifies "public" and the token is withheld
|
|
6002
|
+
// even though the peer is 127.0.0.1.
|
|
6003
|
+
|
|
6004
|
+
// Fake Bun Server handle (mirrors the cloak suite) so the fetch fn resolves
|
|
6005
|
+
// the peer address. The on-box operator connects from 127.0.0.1; Caddy
|
|
6006
|
+
// reverse_proxy also dials 127.0.0.1 but stamps X-Forwarded-For.
|
|
6007
|
+
const fakeServer = (address: string) => ({ requestIP: () => ({ address }) });
|
|
6008
|
+
|
|
6009
|
+
test("loopback peer + NO forwarding header → bootstrapToken present (on-box operator) [#576]", async () => {
|
|
6010
|
+
const h = makeHarness();
|
|
6011
|
+
_resetBootstrapTokenForTests();
|
|
6012
|
+
const token = generateBootstrapToken();
|
|
6013
|
+
try {
|
|
6014
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6015
|
+
try {
|
|
6016
|
+
// No admin row → wizard mode → GET /admin/setup JSON probe is live.
|
|
6017
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
6018
|
+
req("/admin/setup", { headers: { accept: "application/json" } }),
|
|
6019
|
+
fakeServer("127.0.0.1"),
|
|
6020
|
+
);
|
|
6021
|
+
expect(res.status).toBe(200);
|
|
6022
|
+
const body = (await res.json()) as {
|
|
6023
|
+
requireBootstrapToken?: boolean;
|
|
6024
|
+
bootstrapToken?: string;
|
|
6025
|
+
};
|
|
6026
|
+
expect(body.requireBootstrapToken).toBe(true);
|
|
6027
|
+
expect(body.bootstrapToken).toBe(token);
|
|
6028
|
+
} finally {
|
|
6029
|
+
db.close();
|
|
6030
|
+
}
|
|
6031
|
+
} finally {
|
|
6032
|
+
_resetBootstrapTokenForTests();
|
|
6033
|
+
h.cleanup();
|
|
6034
|
+
}
|
|
6035
|
+
});
|
|
6036
|
+
|
|
6037
|
+
test("loopback peer + X-Forwarded-For → bootstrapToken WITHHELD (Caddy-direct public visitor) [#704]", async () => {
|
|
6038
|
+
const h = makeHarness();
|
|
6039
|
+
_resetBootstrapTokenForTests();
|
|
6040
|
+
generateBootstrapToken();
|
|
6041
|
+
try {
|
|
6042
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6043
|
+
try {
|
|
6044
|
+
// Caddy: peer 127.0.0.1, but the request carries the public client's
|
|
6045
|
+
// X-Forwarded-For. Pre-fix this leaked the token to any public visitor.
|
|
6046
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
6047
|
+
req("/admin/setup", {
|
|
6048
|
+
headers: { accept: "application/json", "x-forwarded-for": "203.0.113.7" },
|
|
6049
|
+
}),
|
|
6050
|
+
fakeServer("127.0.0.1"),
|
|
6051
|
+
);
|
|
6052
|
+
expect(res.status).toBe(200);
|
|
6053
|
+
const body = (await res.json()) as {
|
|
6054
|
+
requireBootstrapToken?: boolean;
|
|
6055
|
+
bootstrapToken?: string;
|
|
6056
|
+
};
|
|
6057
|
+
// The gate still SAYS a token is required (so the form renders the
|
|
6058
|
+
// field) — it just doesn't hand the value to the public caller.
|
|
6059
|
+
expect(body.requireBootstrapToken).toBe(true);
|
|
6060
|
+
expect(body.bootstrapToken).toBeUndefined();
|
|
6061
|
+
} finally {
|
|
6062
|
+
db.close();
|
|
6063
|
+
}
|
|
6064
|
+
} finally {
|
|
6065
|
+
_resetBootstrapTokenForTests();
|
|
6066
|
+
h.cleanup();
|
|
6067
|
+
}
|
|
6068
|
+
});
|
|
6069
|
+
|
|
6070
|
+
test("non-loopback peer → bootstrapToken WITHHELD (direct public/tailnet)", async () => {
|
|
6071
|
+
const h = makeHarness();
|
|
6072
|
+
_resetBootstrapTokenForTests();
|
|
6073
|
+
generateBootstrapToken();
|
|
6074
|
+
try {
|
|
6075
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
6076
|
+
try {
|
|
6077
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
6078
|
+
req("/admin/setup", { headers: { accept: "application/json" } }),
|
|
6079
|
+
fakeServer("203.0.113.9"),
|
|
6080
|
+
);
|
|
6081
|
+
expect(res.status).toBe(200);
|
|
6082
|
+
const body = (await res.json()) as { bootstrapToken?: string };
|
|
6083
|
+
expect(body.bootstrapToken).toBeUndefined();
|
|
6084
|
+
} finally {
|
|
6085
|
+
db.close();
|
|
6086
|
+
}
|
|
6087
|
+
} finally {
|
|
6088
|
+
_resetBootstrapTokenForTests();
|
|
6089
|
+
h.cleanup();
|
|
6090
|
+
}
|
|
6091
|
+
});
|
|
6092
|
+
});
|