@openparachute/hub 0.7.4-rc.19 → 0.7.4-rc.20
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__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/api-settings-root-redirect.ts +188 -0
- package/src/commands/hub.ts +102 -1
- package/src/hub-server.ts +39 -16
- package/src/hub-settings.ts +163 -1
package/package.json
CHANGED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for `/api/settings/root-redirect`.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - `validateRootRedirect` pure validator (null/empty clear, safe path,
|
|
6
|
+
* open-redirect rejection).
|
|
7
|
+
* - GET response shape (root_redirect + resolved + source).
|
|
8
|
+
* - PUT happy path + open-redirect rejection (the highest-stakes part).
|
|
9
|
+
* - PUT clear (null) reverts to env/default precedence.
|
|
10
|
+
* - Auth gating: 401 missing/empty bearer, 403 wrong scope.
|
|
11
|
+
* - "Change takes effect on the next request" — the GET resolved value
|
|
12
|
+
* reflects the value just written, without restarting.
|
|
13
|
+
*/
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import {
|
|
19
|
+
API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE,
|
|
20
|
+
handleApiSettingsRootRedirect,
|
|
21
|
+
validateRootRedirect,
|
|
22
|
+
} from "../api-settings-root-redirect.ts";
|
|
23
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
24
|
+
import { getRootRedirect, setRootRedirect } from "../hub-settings.ts";
|
|
25
|
+
import { recordTokenMint, signAccessToken } from "../jwt-sign.ts";
|
|
26
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
27
|
+
import { createUser } from "../users.ts";
|
|
28
|
+
|
|
29
|
+
const ISSUER = "http://127.0.0.1:1939";
|
|
30
|
+
|
|
31
|
+
interface Harness {
|
|
32
|
+
dir: string;
|
|
33
|
+
db: ReturnType<typeof openHubDb>;
|
|
34
|
+
userId: string;
|
|
35
|
+
cleanup: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function makeHarness(): Promise<Harness> {
|
|
39
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-api-settings-root-redirect-"));
|
|
40
|
+
const db = openHubDb(hubDbPath(dir));
|
|
41
|
+
rotateSigningKey(db);
|
|
42
|
+
const user = await createUser(db, "owner", "pw");
|
|
43
|
+
return {
|
|
44
|
+
dir,
|
|
45
|
+
db,
|
|
46
|
+
userId: user.id,
|
|
47
|
+
cleanup: () => {
|
|
48
|
+
db.close();
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
55
|
+
const signed = await signAccessToken(h.db, {
|
|
56
|
+
sub: h.userId,
|
|
57
|
+
scopes,
|
|
58
|
+
audience: "parachute-hub",
|
|
59
|
+
clientId: "parachute-hub",
|
|
60
|
+
issuer: ISSUER,
|
|
61
|
+
ttlSeconds: 3600,
|
|
62
|
+
});
|
|
63
|
+
recordTokenMint(h.db, {
|
|
64
|
+
jti: signed.jti,
|
|
65
|
+
createdVia: "operator_mint",
|
|
66
|
+
subject: h.userId,
|
|
67
|
+
clientId: "parachute-hub",
|
|
68
|
+
scopes,
|
|
69
|
+
expiresAt: signed.expiresAt,
|
|
70
|
+
});
|
|
71
|
+
return signed.token;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getReq(headers: Record<string, string> = {}): Request {
|
|
75
|
+
return new Request("http://localhost/api/settings/root-redirect", { method: "GET", headers });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function putReq(body: unknown, headers: Record<string, string> = {}): Request {
|
|
79
|
+
return new Request("http://localhost/api/settings/root-redirect", {
|
|
80
|
+
method: "PUT",
|
|
81
|
+
headers: { "content-type": "application/json", ...headers },
|
|
82
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Empty env so the resolver's env layer is deterministic (the host's real
|
|
87
|
+
// PARACHUTE_HUB_ROOT_REDIRECT must not leak into GET's resolved/source).
|
|
88
|
+
const noEnv: NodeJS.ProcessEnv = {};
|
|
89
|
+
|
|
90
|
+
function deps(
|
|
91
|
+
h: Harness,
|
|
92
|
+
overrides: Partial<Parameters<typeof handleApiSettingsRootRedirect>[1]> = {},
|
|
93
|
+
) {
|
|
94
|
+
return {
|
|
95
|
+
db: h.db,
|
|
96
|
+
issuer: ISSUER,
|
|
97
|
+
env: noEnv,
|
|
98
|
+
...overrides,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
describe("validateRootRedirect — pure validator", () => {
|
|
103
|
+
test("null → normalized null (clear)", () => {
|
|
104
|
+
expect(validateRootRedirect(null)).toEqual({ ok: true, normalized: null });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("empty string → normalized null (clear footgun guard)", () => {
|
|
108
|
+
expect(validateRootRedirect("")).toEqual({ ok: true, normalized: null });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("safe same-origin path → normalized verbatim", () => {
|
|
112
|
+
expect(validateRootRedirect("/surface/reading-room")).toEqual({
|
|
113
|
+
ok: true,
|
|
114
|
+
normalized: "/surface/reading-room",
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("rejects off-origin + scheme shapes", () => {
|
|
119
|
+
for (const bad of [
|
|
120
|
+
"//evil.com",
|
|
121
|
+
"/\\evil.com",
|
|
122
|
+
"https://evil.com",
|
|
123
|
+
"javascript:alert(1)",
|
|
124
|
+
"admin",
|
|
125
|
+
"/",
|
|
126
|
+
]) {
|
|
127
|
+
const r = validateRootRedirect(bad);
|
|
128
|
+
expect(r.ok).toBe(false);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("rejects non-string non-null", () => {
|
|
133
|
+
expect(validateRootRedirect(42).ok).toBe(false);
|
|
134
|
+
expect(validateRootRedirect({}).ok).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("auth gating", () => {
|
|
139
|
+
let h: Harness;
|
|
140
|
+
beforeEach(async () => {
|
|
141
|
+
h = await makeHarness();
|
|
142
|
+
});
|
|
143
|
+
afterEach(() => h.cleanup());
|
|
144
|
+
|
|
145
|
+
test("405 on non-GET/PUT", async () => {
|
|
146
|
+
const res = await handleApiSettingsRootRedirect(
|
|
147
|
+
new Request("http://localhost/api/settings/root-redirect", { method: "POST" }),
|
|
148
|
+
deps(h),
|
|
149
|
+
);
|
|
150
|
+
expect(res.status).toBe(405);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("401 when Authorization header is missing", async () => {
|
|
154
|
+
const res = await handleApiSettingsRootRedirect(getReq(), deps(h));
|
|
155
|
+
expect(res.status).toBe(401);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("401 on empty bearer", async () => {
|
|
159
|
+
const res = await handleApiSettingsRootRedirect(getReq({ authorization: "Bearer " }), deps(h));
|
|
160
|
+
expect(res.status).toBe(401);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("403 when the bearer lacks the required scope", async () => {
|
|
164
|
+
const bearer = await mintBearer(h, ["parachute:host:auth"]);
|
|
165
|
+
const resGet = await handleApiSettingsRootRedirect(
|
|
166
|
+
getReq({ authorization: `Bearer ${bearer}` }),
|
|
167
|
+
deps(h),
|
|
168
|
+
);
|
|
169
|
+
expect(resGet.status).toBe(403);
|
|
170
|
+
const resPut = await handleApiSettingsRootRedirect(
|
|
171
|
+
putReq({ root_redirect: "/surface/x" }, { authorization: `Bearer ${bearer}` }),
|
|
172
|
+
deps(h),
|
|
173
|
+
);
|
|
174
|
+
expect(resPut.status).toBe(403);
|
|
175
|
+
// Nothing was written.
|
|
176
|
+
expect(getRootRedirect(h.db)).toBeNull();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("GET /api/settings/root-redirect", () => {
|
|
181
|
+
let h: Harness;
|
|
182
|
+
beforeEach(async () => {
|
|
183
|
+
h = await makeHarness();
|
|
184
|
+
});
|
|
185
|
+
afterEach(() => h.cleanup());
|
|
186
|
+
|
|
187
|
+
test("default shape when unset: /admin from the default layer", async () => {
|
|
188
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
189
|
+
const res = await handleApiSettingsRootRedirect(
|
|
190
|
+
getReq({ authorization: `Bearer ${bearer}` }),
|
|
191
|
+
deps(h),
|
|
192
|
+
);
|
|
193
|
+
expect(res.status).toBe(200);
|
|
194
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
195
|
+
expect(body).toEqual({ root_redirect: null, resolved: "/admin", source: "default" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("reflects a stored value with source=db", async () => {
|
|
199
|
+
setRootRedirect(h.db, "/surface/reading-room");
|
|
200
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
201
|
+
const res = await handleApiSettingsRootRedirect(
|
|
202
|
+
getReq({ authorization: `Bearer ${bearer}` }),
|
|
203
|
+
deps(h),
|
|
204
|
+
);
|
|
205
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
206
|
+
expect(body).toEqual({
|
|
207
|
+
root_redirect: "/surface/reading-room",
|
|
208
|
+
resolved: "/surface/reading-room",
|
|
209
|
+
source: "db",
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("surfaces an env-sourced resolved value while the stored row is null", async () => {
|
|
214
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
215
|
+
const res = await handleApiSettingsRootRedirect(
|
|
216
|
+
getReq({ authorization: `Bearer ${bearer}` }),
|
|
217
|
+
deps(h, { env: { PARACHUTE_HUB_ROOT_REDIRECT: "/surface/from-env" } }),
|
|
218
|
+
);
|
|
219
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
220
|
+
expect(body).toEqual({
|
|
221
|
+
root_redirect: null,
|
|
222
|
+
resolved: "/surface/from-env",
|
|
223
|
+
source: "env",
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("PUT /api/settings/root-redirect", () => {
|
|
229
|
+
let h: Harness;
|
|
230
|
+
beforeEach(async () => {
|
|
231
|
+
h = await makeHarness();
|
|
232
|
+
});
|
|
233
|
+
afterEach(() => h.cleanup());
|
|
234
|
+
|
|
235
|
+
test("stores a safe path + GET reflects it on the next request (no restart)", async () => {
|
|
236
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
237
|
+
const put = await handleApiSettingsRootRedirect(
|
|
238
|
+
putReq({ root_redirect: "/surface/reading-room" }, { authorization: `Bearer ${bearer}` }),
|
|
239
|
+
deps(h),
|
|
240
|
+
);
|
|
241
|
+
expect(put.status).toBe(200);
|
|
242
|
+
expect((await put.json()) as unknown).toEqual({ root_redirect: "/surface/reading-room" });
|
|
243
|
+
expect(getRootRedirect(h.db)).toBe("/surface/reading-room");
|
|
244
|
+
|
|
245
|
+
const get = await handleApiSettingsRootRedirect(
|
|
246
|
+
getReq({ authorization: `Bearer ${bearer}` }),
|
|
247
|
+
deps(h),
|
|
248
|
+
);
|
|
249
|
+
const body = (await get.json()) as Record<string, unknown>;
|
|
250
|
+
expect(body.resolved).toBe("/surface/reading-room");
|
|
251
|
+
expect(body.source).toBe("db");
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("null clears the row", async () => {
|
|
255
|
+
setRootRedirect(h.db, "/surface/x");
|
|
256
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
257
|
+
const res = await handleApiSettingsRootRedirect(
|
|
258
|
+
putReq({ root_redirect: null }, { authorization: `Bearer ${bearer}` }),
|
|
259
|
+
deps(h),
|
|
260
|
+
);
|
|
261
|
+
expect(res.status).toBe(200);
|
|
262
|
+
expect(getRootRedirect(h.db)).toBeNull();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("rejects open-redirect payloads with 400 and writes nothing", async () => {
|
|
266
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
267
|
+
for (const bad of [
|
|
268
|
+
"//evil.com",
|
|
269
|
+
"https://evil.com",
|
|
270
|
+
"javascript:alert(1)",
|
|
271
|
+
"/\\evil.com",
|
|
272
|
+
"/",
|
|
273
|
+
]) {
|
|
274
|
+
const res = await handleApiSettingsRootRedirect(
|
|
275
|
+
putReq({ root_redirect: bad }, { authorization: `Bearer ${bearer}` }),
|
|
276
|
+
deps(h),
|
|
277
|
+
);
|
|
278
|
+
expect(res.status).toBe(400);
|
|
279
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
280
|
+
expect(body.error).toBe("invalid_root_redirect");
|
|
281
|
+
expect(getRootRedirect(h.db)).toBeNull();
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("400 on a body without a root_redirect field", async () => {
|
|
286
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
287
|
+
const res = await handleApiSettingsRootRedirect(
|
|
288
|
+
putReq({ wrong: "x" }, { authorization: `Bearer ${bearer}` }),
|
|
289
|
+
deps(h),
|
|
290
|
+
);
|
|
291
|
+
expect(res.status).toBe(400);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("400 on non-JSON body", async () => {
|
|
295
|
+
const bearer = await mintBearer(h, [API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE]);
|
|
296
|
+
const res = await handleApiSettingsRootRedirect(
|
|
297
|
+
putReq("not json{", { authorization: `Bearer ${bearer}` }),
|
|
298
|
+
deps(h),
|
|
299
|
+
);
|
|
300
|
+
expect(res.status).toBe(400);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -12,9 +12,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
12
12
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { hub, hubSetOrigin, rewriteCaddyfileHost } from "../commands/hub.ts";
|
|
15
|
+
import { hub, hubSetOrigin, hubSetRootRedirect, rewriteCaddyfileHost } from "../commands/hub.ts";
|
|
16
16
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
17
|
-
import { getHubOrigin } from "../hub-settings.ts";
|
|
17
|
+
import { getHubOrigin, getRootRedirect } from "../hub-settings.ts";
|
|
18
18
|
import type { CommandResult } from "../tailscale/run.ts";
|
|
19
19
|
|
|
20
20
|
describe("parachute hub set-origin", () => {
|
|
@@ -431,3 +431,70 @@ describe("parachute hub set-origin — Caddy automation", () => {
|
|
|
431
431
|
expect(log.join("\n")).toContain("already points at old.example.com");
|
|
432
432
|
});
|
|
433
433
|
});
|
|
434
|
+
|
|
435
|
+
describe("parachute hub set-root-redirect", () => {
|
|
436
|
+
let dir: string;
|
|
437
|
+
let log: string[];
|
|
438
|
+
const collect = (line: string) => log.push(line);
|
|
439
|
+
|
|
440
|
+
beforeEach(() => {
|
|
441
|
+
dir = mkdtempSync(join(tmpdir(), "hub-set-root-redirect-"));
|
|
442
|
+
log = [];
|
|
443
|
+
});
|
|
444
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
445
|
+
|
|
446
|
+
/** Open the configDir's hub.db and read the persisted root_redirect. */
|
|
447
|
+
function persisted(): string | null {
|
|
448
|
+
const db = openHubDb(hubDbPath(dir));
|
|
449
|
+
try {
|
|
450
|
+
return getRootRedirect(db);
|
|
451
|
+
} finally {
|
|
452
|
+
db.close();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
test("persists a safe same-origin path to hub_settings.root_redirect", async () => {
|
|
457
|
+
const code = await hubSetRootRedirect(["/surface/reading-room"], {
|
|
458
|
+
configDir: dir,
|
|
459
|
+
log: collect,
|
|
460
|
+
});
|
|
461
|
+
expect(code).toBe(0);
|
|
462
|
+
expect(persisted()).toBe("/surface/reading-room");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("--clear deletes the row", async () => {
|
|
466
|
+
await hubSetRootRedirect(["/surface/x"], { configDir: dir, log: collect });
|
|
467
|
+
const code = await hubSetRootRedirect(["--clear"], { configDir: dir, log: collect });
|
|
468
|
+
expect(code).toBe(0);
|
|
469
|
+
expect(persisted()).toBeNull();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("rejects an open-redirect path without writing", async () => {
|
|
473
|
+
for (const bad of ["//evil.com", "https://evil.com", "/\\evil.com", "/"]) {
|
|
474
|
+
const code = await hubSetRootRedirect([bad], { configDir: dir, log: collect });
|
|
475
|
+
expect(code).toBe(1);
|
|
476
|
+
expect(persisted()).toBeNull();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("rejects a path with no leading slash without writing", async () => {
|
|
481
|
+
const code = await hubSetRootRedirect(["surface/x"], { configDir: dir, log: collect });
|
|
482
|
+
expect(code).toBe(1);
|
|
483
|
+
expect(persisted()).toBeNull();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("usage error (exit 1) when no path + no --clear", async () => {
|
|
487
|
+
const code = await hubSetRootRedirect([], { configDir: dir, log: collect });
|
|
488
|
+
expect(code).toBe(1);
|
|
489
|
+
expect(persisted()).toBeNull();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("routed through the `hub` dispatcher", async () => {
|
|
493
|
+
const code = await hub(["set-root-redirect", "/surface/via-dispatcher"], {
|
|
494
|
+
configDir: dir,
|
|
495
|
+
log: collect,
|
|
496
|
+
});
|
|
497
|
+
expect(code).toBe(0);
|
|
498
|
+
expect(persisted()).toBe("/surface/via-dispatcher");
|
|
499
|
+
});
|
|
500
|
+
});
|
|
@@ -12,8 +12,10 @@ import { join } from "node:path";
|
|
|
12
12
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
13
|
import {
|
|
14
14
|
DEFAULT_MODULE_INSTALL_CHANNEL,
|
|
15
|
+
DEFAULT_ROOT_REDIRECT,
|
|
15
16
|
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
16
17
|
MODULE_INSTALL_CHANNELS,
|
|
18
|
+
PARACHUTE_HUB_ROOT_REDIRECT_ENV,
|
|
17
19
|
PARACHUTE_INSTALL_CHANNEL_ENV,
|
|
18
20
|
PARACHUTE_MODULE_CHANNEL_ENV,
|
|
19
21
|
SETUP_EXPOSE_MODES,
|
|
@@ -21,15 +23,20 @@ import {
|
|
|
21
23
|
deleteSetting,
|
|
22
24
|
getHubOrigin,
|
|
23
25
|
getModuleInstallChannel,
|
|
26
|
+
getRootRedirect,
|
|
24
27
|
getSetting,
|
|
25
28
|
isFirstClientAutoApproveWindowOpen,
|
|
26
29
|
isModuleInstallChannel,
|
|
27
30
|
isNotesRedirectDisabled,
|
|
31
|
+
isSafeRedirectPath,
|
|
28
32
|
isSetupExposeMode,
|
|
29
33
|
openFirstClientAutoApproveWindow,
|
|
34
|
+
resolveRootRedirect,
|
|
35
|
+
resolveRootRedirectDetailed,
|
|
30
36
|
setHubOrigin,
|
|
31
37
|
setModuleInstallChannel,
|
|
32
38
|
setNotesRedirectDisabled,
|
|
39
|
+
setRootRedirect,
|
|
33
40
|
setSetting,
|
|
34
41
|
} from "../hub-settings.ts";
|
|
35
42
|
|
|
@@ -613,3 +620,184 @@ describe("hub-settings — notes_redirect_disabled (parachute-app §16)", () =>
|
|
|
613
620
|
}
|
|
614
621
|
});
|
|
615
622
|
});
|
|
623
|
+
|
|
624
|
+
describe("hub-settings — isSafeRedirectPath (open-redirect guard)", () => {
|
|
625
|
+
test("accepts plain same-origin relative paths", () => {
|
|
626
|
+
expect(isSafeRedirectPath("/admin")).toBe(true);
|
|
627
|
+
expect(isSafeRedirectPath("/surface/reading-room")).toBe(true);
|
|
628
|
+
expect(isSafeRedirectPath("/vault/default/")).toBe(true);
|
|
629
|
+
// Query + fragment stay same-origin → allowed.
|
|
630
|
+
expect(isSafeRedirectPath("/surface/x?view=reading#top")).toBe(true);
|
|
631
|
+
// Deep paths with hyphens/dots/underscores (regression: a botched
|
|
632
|
+
// whitespace regex once rejected `-`).
|
|
633
|
+
expect(isSafeRedirectPath("/a-b_c.d/e")).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("rejects protocol-relative + backslash authority tricks", () => {
|
|
637
|
+
expect(isSafeRedirectPath("//evil.com")).toBe(false);
|
|
638
|
+
expect(isSafeRedirectPath("//evil.com/path")).toBe(false);
|
|
639
|
+
expect(isSafeRedirectPath("/\\evil.com")).toBe(false);
|
|
640
|
+
expect(isSafeRedirectPath("/\\\\evil.com")).toBe(false);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("rejects absolute URLs + scheme payloads", () => {
|
|
644
|
+
expect(isSafeRedirectPath("https://evil.com")).toBe(false);
|
|
645
|
+
expect(isSafeRedirectPath("http://evil.com/x")).toBe(false);
|
|
646
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing the runtime guard with a hostile string
|
|
647
|
+
expect(isSafeRedirectPath("javascript:alert(1)" as any)).toBe(false);
|
|
648
|
+
expect(isSafeRedirectPath("data:text/html,<script>1</script>")).toBe(false);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("rejects values missing a leading slash", () => {
|
|
652
|
+
expect(isSafeRedirectPath("admin")).toBe(false);
|
|
653
|
+
expect(isSafeRedirectPath("evil.com")).toBe(false);
|
|
654
|
+
expect(isSafeRedirectPath("")).toBe(false);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("rejects pathname-`/` targets (would 302-loop the bare-`/` route)", () => {
|
|
658
|
+
expect(isSafeRedirectPath("/")).toBe(false);
|
|
659
|
+
expect(isSafeRedirectPath("/?next=x")).toBe(false);
|
|
660
|
+
expect(isSafeRedirectPath("/#frag")).toBe(false);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("rejects whitespace + control chars (header-injection / normalization)", () => {
|
|
664
|
+
expect(isSafeRedirectPath("/admin\r\nSet-Cookie: x=1")).toBe(false);
|
|
665
|
+
expect(isSafeRedirectPath("/ad min")).toBe(false);
|
|
666
|
+
expect(isSafeRedirectPath("/admin\t")).toBe(false);
|
|
667
|
+
expect(isSafeRedirectPath("/\tadmin")).toBe(false);
|
|
668
|
+
expect(isSafeRedirectPath("/admin ")).toBe(false);
|
|
669
|
+
// U+2028 line separator (stripped by some parsers) — built via charCode so
|
|
670
|
+
// the source file carries no irregular-whitespace literal.
|
|
671
|
+
expect(isSafeRedirectPath(`/admin${String.fromCharCode(0x2028)}x`)).toBe(false);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("rejects non-string inputs", () => {
|
|
675
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
676
|
+
expect(isSafeRedirectPath(null as any)).toBe(false);
|
|
677
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
678
|
+
expect(isSafeRedirectPath(undefined as any)).toBe(false);
|
|
679
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
680
|
+
expect(isSafeRedirectPath(42 as any)).toBe(false);
|
|
681
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
682
|
+
expect(isSafeRedirectPath({} as any)).toBe(false);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
describe("hub-settings — root_redirect storage + resolution", () => {
|
|
687
|
+
let dir: string;
|
|
688
|
+
beforeEach(() => {
|
|
689
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-root-redirect-"));
|
|
690
|
+
});
|
|
691
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
692
|
+
|
|
693
|
+
// An empty env so the resolver's env layer is deterministic (the host's real
|
|
694
|
+
// PARACHUTE_HUB_ROOT_REDIRECT, if any, must not leak in).
|
|
695
|
+
const noEnv: NodeJS.ProcessEnv = {};
|
|
696
|
+
const silent = () => {};
|
|
697
|
+
|
|
698
|
+
test("getRootRedirect round-trips via setRootRedirect", () => {
|
|
699
|
+
const db = openHubDb(hubDbPath(dir));
|
|
700
|
+
try {
|
|
701
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
702
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
703
|
+
expect(getRootRedirect(db)).toBe("/surface/reading-room");
|
|
704
|
+
} finally {
|
|
705
|
+
db.close();
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("setRootRedirect(null) / empty clears the row", () => {
|
|
710
|
+
const db = openHubDb(hubDbPath(dir));
|
|
711
|
+
try {
|
|
712
|
+
setRootRedirect(db, "/surface/x");
|
|
713
|
+
setRootRedirect(db, null);
|
|
714
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
715
|
+
setRootRedirect(db, "/surface/x");
|
|
716
|
+
setRootRedirect(db, "");
|
|
717
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
718
|
+
} finally {
|
|
719
|
+
db.close();
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("resolves to /admin default when neither DB nor env is set", () => {
|
|
724
|
+
const db = openHubDb(hubDbPath(dir));
|
|
725
|
+
try {
|
|
726
|
+
expect(resolveRootRedirect(db, { env: noEnv })).toBe(DEFAULT_ROOT_REDIRECT);
|
|
727
|
+
expect(resolveRootRedirectDetailed(db, { env: noEnv })).toEqual({
|
|
728
|
+
value: "/admin",
|
|
729
|
+
source: "default",
|
|
730
|
+
});
|
|
731
|
+
} finally {
|
|
732
|
+
db.close();
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("env override applies when no DB row", () => {
|
|
737
|
+
const db = openHubDb(hubDbPath(dir));
|
|
738
|
+
try {
|
|
739
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
740
|
+
expect(resolveRootRedirectDetailed(db, { env })).toEqual({
|
|
741
|
+
value: "/surface/from-env",
|
|
742
|
+
source: "env",
|
|
743
|
+
});
|
|
744
|
+
} finally {
|
|
745
|
+
db.close();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("DB row overrides env (DB is tier-1)", () => {
|
|
750
|
+
const db = openHubDb(hubDbPath(dir));
|
|
751
|
+
try {
|
|
752
|
+
setRootRedirect(db, "/surface/from-db");
|
|
753
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
754
|
+
expect(resolveRootRedirectDetailed(db, { env })).toEqual({
|
|
755
|
+
value: "/surface/from-db",
|
|
756
|
+
source: "db",
|
|
757
|
+
});
|
|
758
|
+
} finally {
|
|
759
|
+
db.close();
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("an unsafe DB row is ignored → falls through to env", () => {
|
|
764
|
+
const db = openHubDb(hubDbPath(dir));
|
|
765
|
+
try {
|
|
766
|
+
// Simulate a hand-edited sqlite row that bypassed write-side validation.
|
|
767
|
+
setSetting(db, "root_redirect", "//evil.com");
|
|
768
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
769
|
+
expect(resolveRootRedirect(db, { env, warn: silent })).toBe("/surface/from-env");
|
|
770
|
+
} finally {
|
|
771
|
+
db.close();
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("an unsafe DB row with no env → falls all the way back to /admin", () => {
|
|
776
|
+
const db = openHubDb(hubDbPath(dir));
|
|
777
|
+
try {
|
|
778
|
+
setSetting(db, "root_redirect", "https://evil.com");
|
|
779
|
+
expect(resolveRootRedirect(db, { env: noEnv, warn: silent })).toBe("/admin");
|
|
780
|
+
} finally {
|
|
781
|
+
db.close();
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("an unsafe env value is ignored → falls back to /admin", () => {
|
|
786
|
+
const db = openHubDb(hubDbPath(dir));
|
|
787
|
+
try {
|
|
788
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "//evil.com" };
|
|
789
|
+
expect(resolveRootRedirectDetailed(db, { env, warn: silent })).toEqual({
|
|
790
|
+
value: "/admin",
|
|
791
|
+
source: "default",
|
|
792
|
+
});
|
|
793
|
+
} finally {
|
|
794
|
+
db.close();
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("a null db (no state) resolves from env / default only", () => {
|
|
799
|
+
expect(resolveRootRedirect(null, { env: noEnv })).toBe("/admin");
|
|
800
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
801
|
+
expect(resolveRootRedirect(null, { env })).toBe("/surface/from-env");
|
|
802
|
+
});
|
|
803
|
+
});
|
|
@@ -31,6 +31,7 @@ import { tmpdir } from "node:os";
|
|
|
31
31
|
import { join } from "node:path";
|
|
32
32
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
33
33
|
import { hubFetch } from "../hub-server.ts";
|
|
34
|
+
import { setRootRedirect, setSetting } from "../hub-settings.ts";
|
|
34
35
|
import { writeManifest } from "../services-manifest.ts";
|
|
35
36
|
import { createUser } from "../users.ts";
|
|
36
37
|
|
|
@@ -101,9 +102,7 @@ describe("setup gate (no admin yet)", () => {
|
|
|
101
102
|
test("/login POST still 503s setup_required when no admin exists (hub#644)", async () => {
|
|
102
103
|
const db = openHubDb(hubDbPath(h.dir));
|
|
103
104
|
try {
|
|
104
|
-
const res = await hubFetch(h.dir, { getDb: () => db })(
|
|
105
|
-
req("/login", { method: "POST" }),
|
|
106
|
-
);
|
|
105
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/login", { method: "POST" }));
|
|
107
106
|
expect(res.status).toBe(503);
|
|
108
107
|
const body = (await res.json()) as Record<string, unknown>;
|
|
109
108
|
expect(body.error).toBe("setup_required");
|
|
@@ -368,3 +367,112 @@ describe("setup gate (admin exists)", () => {
|
|
|
368
367
|
}
|
|
369
368
|
});
|
|
370
369
|
});
|
|
370
|
+
|
|
371
|
+
describe("configurable bare-`/` redirect target", () => {
|
|
372
|
+
let h: Harness;
|
|
373
|
+
beforeEach(() => {
|
|
374
|
+
h = makeHarness();
|
|
375
|
+
});
|
|
376
|
+
afterEach(() => h.cleanup());
|
|
377
|
+
|
|
378
|
+
/** A set-up hub (admin + vault) so the bare-`/` redirect is reached. */
|
|
379
|
+
function setUpHub(db: ReturnType<typeof openHubDb>): void {
|
|
380
|
+
writeManifest(
|
|
381
|
+
{
|
|
382
|
+
services: [
|
|
383
|
+
{
|
|
384
|
+
name: "parachute-vault",
|
|
385
|
+
version: "0.1.0",
|
|
386
|
+
port: 1940,
|
|
387
|
+
paths: ["/vault/default"],
|
|
388
|
+
health: "/health",
|
|
389
|
+
},
|
|
390
|
+
],
|
|
391
|
+
},
|
|
392
|
+
join(h.dir, "services.json"),
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function handler(db: ReturnType<typeof openHubDb>) {
|
|
397
|
+
return hubFetch(h.dir, { getDb: () => db, manifestPath: join(h.dir, "services.json") });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
test("a configured root_redirect retargets the bare-`/` 302", async () => {
|
|
401
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
402
|
+
try {
|
|
403
|
+
await createUser(db, "owner", "pw");
|
|
404
|
+
setUpHub(db);
|
|
405
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
406
|
+
const res = await handler(db)(req("/"));
|
|
407
|
+
expect(res.status).toBe(302);
|
|
408
|
+
expect(res.headers.get("location")).toBe("/surface/reading-room");
|
|
409
|
+
} finally {
|
|
410
|
+
db.close();
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test("an unsafe stored root_redirect falls back to /admin (never an open redirect)", async () => {
|
|
415
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
416
|
+
try {
|
|
417
|
+
await createUser(db, "owner", "pw");
|
|
418
|
+
setUpHub(db);
|
|
419
|
+
// A hand-edited sqlite row that bypassed write-side validation.
|
|
420
|
+
setSetting(db, "root_redirect", "//evil.com");
|
|
421
|
+
const res = await handler(db)(req("/"));
|
|
422
|
+
expect(res.status).toBe(302);
|
|
423
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
424
|
+
} finally {
|
|
425
|
+
db.close();
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("PARACHUTE_HUB_ROOT_REDIRECT env retargets the bare-`/` 302 (no DB row)", async () => {
|
|
430
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
431
|
+
const prev = process.env.PARACHUTE_HUB_ROOT_REDIRECT;
|
|
432
|
+
process.env.PARACHUTE_HUB_ROOT_REDIRECT = "/surface/from-env";
|
|
433
|
+
try {
|
|
434
|
+
await createUser(db, "owner", "pw");
|
|
435
|
+
setUpHub(db);
|
|
436
|
+
const res = await handler(db)(req("/"));
|
|
437
|
+
expect(res.status).toBe(302);
|
|
438
|
+
expect(res.headers.get("location")).toBe("/surface/from-env");
|
|
439
|
+
} finally {
|
|
440
|
+
// Restore process.env to its pre-test state. `delete` (not assign-undefined,
|
|
441
|
+
// which would coerce to the string "undefined") removes a key we added.
|
|
442
|
+
if (prev === undefined) {
|
|
443
|
+
// biome-ignore lint/performance/noDelete: env-key cleanup, not a hot path
|
|
444
|
+
delete process.env.PARACHUTE_HUB_ROOT_REDIRECT;
|
|
445
|
+
} else {
|
|
446
|
+
process.env.PARACHUTE_HUB_ROOT_REDIRECT = prev;
|
|
447
|
+
}
|
|
448
|
+
db.close();
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
test("wizard funnel WINS: a configured root_redirect does NOT bypass setup on a fresh hub", async () => {
|
|
453
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
454
|
+
try {
|
|
455
|
+
// No admin yet → not-set-up hub. Even with a surface configured, the
|
|
456
|
+
// bare-`/` must funnel to the wizard, not a surface that can't work yet.
|
|
457
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
458
|
+
const res = await handler(db)(req("/"));
|
|
459
|
+
expect(res.status).toBe(302);
|
|
460
|
+
expect(res.headers.get("location")).toBe("/admin/setup");
|
|
461
|
+
} finally {
|
|
462
|
+
db.close();
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("default is unchanged: bare-`/` → /admin when nothing is configured", async () => {
|
|
467
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
468
|
+
try {
|
|
469
|
+
await createUser(db, "owner", "pw");
|
|
470
|
+
setUpHub(db);
|
|
471
|
+
const res = await handler(db)(req("/"));
|
|
472
|
+
expect(res.status).toBe(302);
|
|
473
|
+
expect(res.headers.get("location")).toBe("/admin");
|
|
474
|
+
} finally {
|
|
475
|
+
db.close();
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `GET|PUT /api/settings/root-redirect` — operator-settable target for the
|
|
3
|
+
* bare-`/` 302.
|
|
4
|
+
*
|
|
5
|
+
* The hub's root (`/`) redirects to `/admin` by default. This endpoint lets an
|
|
6
|
+
* operator point it at a surface instead (e.g. a custom-domain hub fronting a
|
|
7
|
+
* team reading-room surface) without redeploying. The stored value resolves
|
|
8
|
+
* tier-1 in `resolveRootRedirect` (hub-settings.ts):
|
|
9
|
+
*
|
|
10
|
+
* 1. hub_settings.root_redirect (this endpoint writes here)
|
|
11
|
+
* 2. PARACHUTE_HUB_ROOT_REDIRECT env
|
|
12
|
+
* 3. `/admin` default (unchanged behavior)
|
|
13
|
+
*
|
|
14
|
+
* The endpoint surfaces both the stored value *and* the resolved value + source
|
|
15
|
+
* so the SPA can render "current: /surface/x (from env)" while the input shows
|
|
16
|
+
* the empty stored row — same separation rationale as `/api/settings/hub-origin`.
|
|
17
|
+
*
|
|
18
|
+
* OPEN-REDIRECT SAFETY is the highest-stakes part: the resolved value lands in a
|
|
19
|
+
* `Location:` header, so an off-origin value would be a textbook open redirect.
|
|
20
|
+
* PUT validation (and the read-time resolver) require a SAME-ORIGIN relative
|
|
21
|
+
* path via `isSafeRedirectPath` — must start with a single `/`, never `//` /
|
|
22
|
+
* `/\` / a scheme, no control chars / whitespace, and must not resolve back to
|
|
23
|
+
* `/` (redirect loop). Anything else is rejected (PUT 400 / resolver fallback to
|
|
24
|
+
* `/admin`).
|
|
25
|
+
*
|
|
26
|
+
* Bearer-gated on `parachute:host:admin`, mirroring `handleApiSettingsHubOrigin`
|
|
27
|
+
* — same Bearer parsing, scope-check posture, and error vocabulary.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import type { Database } from "bun:sqlite";
|
|
31
|
+
import {
|
|
32
|
+
type RootRedirectSource,
|
|
33
|
+
getRootRedirect,
|
|
34
|
+
isSafeRedirectPath,
|
|
35
|
+
resolveRootRedirectDetailed,
|
|
36
|
+
setRootRedirect,
|
|
37
|
+
} from "./hub-settings.ts";
|
|
38
|
+
import { validateAccessToken } from "./jwt-sign.ts";
|
|
39
|
+
|
|
40
|
+
/** Scope required on the bearer token to call either endpoint. */
|
|
41
|
+
export const API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE = "parachute:host:admin";
|
|
42
|
+
|
|
43
|
+
export interface ApiSettingsRootRedirectDeps {
|
|
44
|
+
db: Database;
|
|
45
|
+
/** Issuer the bearer token must validate against (the hub's resolved issuer). */
|
|
46
|
+
issuer: string;
|
|
47
|
+
/**
|
|
48
|
+
* Env seam for the resolver's env layer. Defaults to `process.env`. Threaded
|
|
49
|
+
* so the dispatcher (and tests) can resolve `PARACHUTE_HUB_ROOT_REDIRECT`
|
|
50
|
+
* deterministically.
|
|
51
|
+
*/
|
|
52
|
+
env?: NodeJS.ProcessEnv;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface GetResponseBody {
|
|
56
|
+
/** Raw stored value from hub_settings.root_redirect, or null. */
|
|
57
|
+
root_redirect: string | null;
|
|
58
|
+
/** Resolved target applied to the bare-`/` 302 (precedence-aware, guarded). */
|
|
59
|
+
resolved: string;
|
|
60
|
+
/** Which precedence layer the resolved value came from. */
|
|
61
|
+
source: RootRedirectSource;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface PutResponseBody {
|
|
65
|
+
/** Echo of the now-stored value (null if cleared). */
|
|
66
|
+
root_redirect: string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Validation outcome. The "normalized" branch is what gets passed to
|
|
71
|
+
* setRootRedirect — string (a safe path) or null (clear the row).
|
|
72
|
+
*/
|
|
73
|
+
type ValidateOutcome = { ok: true; normalized: string | null } | { ok: false; description: string };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Validate the body's `root_redirect` field. Accepts:
|
|
77
|
+
* - `null` (or empty string) → clear the stored value, revert to env/default.
|
|
78
|
+
* - A safe SAME-ORIGIN relative path per `isSafeRedirectPath`.
|
|
79
|
+
* Everything else → 400 with an operator-friendly description.
|
|
80
|
+
*/
|
|
81
|
+
export function validateRootRedirect(value: unknown): ValidateOutcome {
|
|
82
|
+
if (value === null) return { ok: true, normalized: null };
|
|
83
|
+
if (typeof value !== "string") {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
description: `root_redirect must be a string or null (got ${typeof value})`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Empty string is the canonical "clear" shape — store as null (mirrors
|
|
90
|
+
// setHubOrigin's footgun guard; an empty Location would be meaningless).
|
|
91
|
+
if (value.length === 0) return { ok: true, normalized: null };
|
|
92
|
+
if (!isSafeRedirectPath(value)) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
description:
|
|
96
|
+
"root_redirect must be a same-origin relative path (start with a single `/`, no `//`/`/\\`/scheme, no whitespace, and not `/` itself)",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, normalized: value };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function handleApiSettingsRootRedirect(
|
|
103
|
+
req: Request,
|
|
104
|
+
deps: ApiSettingsRootRedirectDeps,
|
|
105
|
+
): Promise<Response> {
|
|
106
|
+
if (req.method !== "GET" && req.method !== "PUT") {
|
|
107
|
+
return jsonError(405, "method_not_allowed", "use GET or PUT");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Bearer presence + parsing — identical shape to api-settings-hub-origin
|
|
111
|
+
// for consistency across hub-internal admin endpoints.
|
|
112
|
+
const auth = req.headers.get("authorization");
|
|
113
|
+
if (!auth || !auth.startsWith("Bearer ")) {
|
|
114
|
+
return jsonError(401, "unauthenticated", "Authorization: Bearer <token> required");
|
|
115
|
+
}
|
|
116
|
+
const bearer = auth.slice("Bearer ".length).trim();
|
|
117
|
+
if (!bearer) {
|
|
118
|
+
return jsonError(401, "unauthenticated", "empty bearer token");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Bearer validation + scope check.
|
|
122
|
+
try {
|
|
123
|
+
const validated = await validateAccessToken(deps.db, bearer, deps.issuer);
|
|
124
|
+
if (typeof validated.payload.sub !== "string" || validated.payload.sub.length === 0) {
|
|
125
|
+
return jsonError(401, "unauthenticated", "bearer token has no sub claim");
|
|
126
|
+
}
|
|
127
|
+
const scopes =
|
|
128
|
+
typeof validated.payload.scope === "string"
|
|
129
|
+
? validated.payload.scope.split(/\s+/).filter((s) => s.length > 0)
|
|
130
|
+
: [];
|
|
131
|
+
if (!scopes.includes(API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE)) {
|
|
132
|
+
return jsonError(
|
|
133
|
+
403,
|
|
134
|
+
"insufficient_scope",
|
|
135
|
+
`bearer token lacks ${API_SETTINGS_ROOT_REDIRECT_REQUIRED_SCOPE}`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
140
|
+
return jsonError(401, "unauthenticated", `bearer token invalid — ${msg}`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (req.method === "GET") {
|
|
144
|
+
const resolved = resolveRootRedirectDetailed(deps.db, { env: deps.env });
|
|
145
|
+
const body: GetResponseBody = {
|
|
146
|
+
root_redirect: getRootRedirect(deps.db),
|
|
147
|
+
resolved: resolved.value,
|
|
148
|
+
source: resolved.source,
|
|
149
|
+
};
|
|
150
|
+
return new Response(JSON.stringify(body), {
|
|
151
|
+
status: 200,
|
|
152
|
+
headers: { "content-type": "application/json" },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// PUT — parse + validate body.
|
|
157
|
+
let parsed: unknown;
|
|
158
|
+
try {
|
|
159
|
+
parsed = await req.json();
|
|
160
|
+
} catch {
|
|
161
|
+
return jsonError(400, "invalid_request", "request body must be JSON");
|
|
162
|
+
}
|
|
163
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
164
|
+
return jsonError(400, "invalid_request", "request body must be a JSON object");
|
|
165
|
+
}
|
|
166
|
+
if (!("root_redirect" in parsed)) {
|
|
167
|
+
return jsonError(400, "invalid_request", "request body must include a `root_redirect` field");
|
|
168
|
+
}
|
|
169
|
+
const result = validateRootRedirect((parsed as { root_redirect: unknown }).root_redirect);
|
|
170
|
+
if (!result.ok) {
|
|
171
|
+
return jsonError(400, "invalid_root_redirect", result.description);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
setRootRedirect(deps.db, result.normalized);
|
|
175
|
+
|
|
176
|
+
const body: PutResponseBody = { root_redirect: result.normalized };
|
|
177
|
+
return new Response(JSON.stringify(body), {
|
|
178
|
+
status: 200,
|
|
179
|
+
headers: { "content-type": "application/json" },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function jsonError(status: number, code: string, description: string): Response {
|
|
184
|
+
return new Response(JSON.stringify({ error: code, error_description: description }), {
|
|
185
|
+
status,
|
|
186
|
+
headers: { "content-type": "application/json" },
|
|
187
|
+
});
|
|
188
|
+
}
|
package/src/commands/hub.ts
CHANGED
|
@@ -32,7 +32,12 @@ import { validateHubOrigin } from "../api-settings-hub-origin.ts";
|
|
|
32
32
|
import { restart } from "../commands/lifecycle.ts";
|
|
33
33
|
import { CONFIG_DIR } from "../config.ts";
|
|
34
34
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
35
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
DEFAULT_ROOT_REDIRECT,
|
|
37
|
+
isSafeRedirectPath,
|
|
38
|
+
setHubOrigin,
|
|
39
|
+
setRootRedirect,
|
|
40
|
+
} from "../hub-settings.ts";
|
|
36
41
|
import { type CommandResult, type Runner, defaultRunner } from "../tailscale/run.ts";
|
|
37
42
|
import { isLoopbackOrigin } from "../vault-hub-origin-env.ts";
|
|
38
43
|
|
|
@@ -347,6 +352,81 @@ async function runCaddyReload(run: Runner): Promise<CommandResult> {
|
|
|
347
352
|
return run(["systemctl", "reload", "caddy"]);
|
|
348
353
|
}
|
|
349
354
|
|
|
355
|
+
/**
|
|
356
|
+
* `parachute hub set-root-redirect <path>` — persist the operator's bare-`/`
|
|
357
|
+
* redirect target into `hub_settings.root_redirect` (tier-1 in
|
|
358
|
+
* `resolveRootRedirect`). Lets a headless box (the canonical use case is a
|
|
359
|
+
* custom-domain hub fronting a team surface) flip its landing page from `/admin`
|
|
360
|
+
* to a surface without a browser session OR a redeploy.
|
|
361
|
+
*
|
|
362
|
+
* `--clear` deletes the row, reverting to the env / `/admin` default.
|
|
363
|
+
*
|
|
364
|
+
* The path is validated through `isSafeRedirectPath` — the SAME open-redirect
|
|
365
|
+
* guard the admin PUT enforces — so the CLI can never plant an off-origin
|
|
366
|
+
* `Location` target either. Returns 0 on success, 1 on a usage / validation /
|
|
367
|
+
* DB-write failure.
|
|
368
|
+
*/
|
|
369
|
+
export async function hubSetRootRedirect(
|
|
370
|
+
args: readonly string[],
|
|
371
|
+
deps: HubCommandDeps = {},
|
|
372
|
+
): Promise<number> {
|
|
373
|
+
const configDir = deps.configDir ?? CONFIG_DIR;
|
|
374
|
+
const log = deps.log ?? ((line) => console.log(line));
|
|
375
|
+
const err = (line: string) => console.error(line);
|
|
376
|
+
const openDb = deps.openDb ?? ((dir: string) => openHubDb(hubDbPath(dir)));
|
|
377
|
+
|
|
378
|
+
const clear = args.includes("--clear");
|
|
379
|
+
const positional = args.filter((a) => !a.startsWith("-"));
|
|
380
|
+
|
|
381
|
+
if (clear) {
|
|
382
|
+
if (positional.length > 0) {
|
|
383
|
+
err("parachute hub set-root-redirect: --clear takes no path argument");
|
|
384
|
+
return 1;
|
|
385
|
+
}
|
|
386
|
+
const db = openDb(configDir);
|
|
387
|
+
try {
|
|
388
|
+
setRootRedirect(db, null);
|
|
389
|
+
} finally {
|
|
390
|
+
db.close();
|
|
391
|
+
}
|
|
392
|
+
log(
|
|
393
|
+
`✓ Cleared the root redirect — \`/\` reverts to env / the ${DEFAULT_ROOT_REDIRECT} default.`,
|
|
394
|
+
);
|
|
395
|
+
return 0;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const raw = positional[0];
|
|
399
|
+
if (raw === undefined) {
|
|
400
|
+
err("usage: parachute hub set-root-redirect <path> (or --clear)");
|
|
401
|
+
err("example: parachute hub set-root-redirect /surface/reading-room");
|
|
402
|
+
return 1;
|
|
403
|
+
}
|
|
404
|
+
if (positional.length > 1) {
|
|
405
|
+
err(`parachute hub set-root-redirect: unexpected argument "${positional[1]}"`);
|
|
406
|
+
err("usage: parachute hub set-root-redirect <path> (or --clear)");
|
|
407
|
+
return 1;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!isSafeRedirectPath(raw)) {
|
|
411
|
+
err(`parachute hub set-root-redirect: "${raw}" is not a safe same-origin path`);
|
|
412
|
+
err(" It must start with a single `/` (no `//`, `/\\`, scheme, or whitespace) and");
|
|
413
|
+
err(" not be `/` itself. Example: /surface/reading-room");
|
|
414
|
+
return 1;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const db = openDb(configDir);
|
|
418
|
+
try {
|
|
419
|
+
setRootRedirect(db, raw);
|
|
420
|
+
} finally {
|
|
421
|
+
db.close();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
log(`✓ Bare \`/\` now redirects to ${raw}.`);
|
|
425
|
+
log(" Stored in hub_settings.root_redirect — takes effect on the next request,");
|
|
426
|
+
log(" no restart needed. Clear it with: parachute hub set-root-redirect --clear");
|
|
427
|
+
return 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
350
430
|
/**
|
|
351
431
|
* `parachute hub <subcommand>` dispatcher. Mirrors `auth`'s shape (a thin
|
|
352
432
|
* router over subcommand handlers, each catching its own errors).
|
|
@@ -367,6 +447,16 @@ export async function hub(args: readonly string[], deps: HubCommandDeps = {}): P
|
|
|
367
447
|
return 1;
|
|
368
448
|
}
|
|
369
449
|
}
|
|
450
|
+
if (sub === "set-root-redirect") {
|
|
451
|
+
try {
|
|
452
|
+
return await hubSetRootRedirect(args.slice(1), deps);
|
|
453
|
+
} catch (err) {
|
|
454
|
+
console.error(
|
|
455
|
+
`parachute hub set-root-redirect: ${err instanceof Error ? err.message : String(err)}`,
|
|
456
|
+
);
|
|
457
|
+
return 1;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
370
460
|
console.error(`parachute hub: unknown subcommand "${sub}"`);
|
|
371
461
|
console.error("");
|
|
372
462
|
console.error(hubHelp());
|
|
@@ -378,6 +468,7 @@ export function hubHelp(): string {
|
|
|
378
468
|
|
|
379
469
|
Usage:
|
|
380
470
|
parachute hub set-origin <url> [--no-caddy] [--no-restart]
|
|
471
|
+
parachute hub set-root-redirect <path> | --clear
|
|
381
472
|
|
|
382
473
|
Subcommands:
|
|
383
474
|
set-origin <url> Persist the canonical public origin (OAuth issuer) to the
|
|
@@ -401,9 +492,19 @@ Subcommands:
|
|
|
401
492
|
Caddyfile rewrite + reload, or --no-restart to skip the
|
|
402
493
|
module restart.
|
|
403
494
|
|
|
495
|
+
set-root-redirect <path>
|
|
496
|
+
Point the bare \`/\` 302 at a same-origin path instead of the
|
|
497
|
+
default /admin (e.g. a team surface). Stored in
|
|
498
|
+
hub_settings.root_redirect; takes effect on the next request,
|
|
499
|
+
no restart. The path must start with a single \`/\` (no \`//\`,
|
|
500
|
+
\`/\\\`, scheme, or whitespace). Pass --clear to revert to the
|
|
501
|
+
env / /admin default. (Env equivalent: PARACHUTE_HUB_ROOT_REDIRECT.)
|
|
502
|
+
|
|
404
503
|
Examples:
|
|
405
504
|
parachute hub set-origin https://box.sslip.io
|
|
406
505
|
parachute hub set-origin https://parachute.example.com
|
|
407
506
|
parachute hub set-origin https://parachute.example.com --no-caddy
|
|
507
|
+
parachute hub set-root-redirect /surface/reading-room
|
|
508
|
+
parachute hub set-root-redirect --clear
|
|
408
509
|
`;
|
|
409
510
|
}
|
package/src/hub-server.ts
CHANGED
|
@@ -87,6 +87,7 @@
|
|
|
87
87
|
* /api/modules/:short/uninstall (POST) → stop child + bun remove + drop row (sync)
|
|
88
88
|
* /api/modules/operations/:id (GET) → poll async op status
|
|
89
89
|
* /api/settings/hub-origin (GET + PUT) → canonical hub URL (host:admin)
|
|
90
|
+
* /api/settings/root-redirect (GET + PUT) → bare-`/` redirect target (host:admin)
|
|
90
91
|
* /api/auth/mint-token (POST) → CLI/automation token mint (bearer)
|
|
91
92
|
* /api/auth/revoke-token (POST) → revoke registry-row token by jti
|
|
92
93
|
* /api/auth/tokens (GET) → paginated registry list
|
|
@@ -217,6 +218,7 @@ import { handleApiReady } from "./api-ready.ts";
|
|
|
217
218
|
import { REVOCATION_LIST_MOUNT, handleRevocationList } from "./api-revocation-list.ts";
|
|
218
219
|
import { handleApiRevokeToken } from "./api-revoke-token.ts";
|
|
219
220
|
import { handleApiSettingsHubOrigin } from "./api-settings-hub-origin.ts";
|
|
221
|
+
import { handleApiSettingsRootRedirect } from "./api-settings-root-redirect.ts";
|
|
220
222
|
import { handleApiTokens } from "./api-tokens.ts";
|
|
221
223
|
import {
|
|
222
224
|
handleCreateUser,
|
|
@@ -246,7 +248,7 @@ import {
|
|
|
246
248
|
startDbPathLivenessTimer,
|
|
247
249
|
} from "./hub-db-liveness.ts";
|
|
248
250
|
import { hubDbPath, openHubDb } from "./hub-db.ts";
|
|
249
|
-
import { getHubOrigin } from "./hub-settings.ts";
|
|
251
|
+
import { getHubOrigin, resolveRootRedirect } from "./hub-settings.ts";
|
|
250
252
|
import { type RenderHubOpts, renderHub } from "./hub.ts";
|
|
251
253
|
import { pemToJwk } from "./jwks.ts";
|
|
252
254
|
import {
|
|
@@ -2479,23 +2481,32 @@ export function hubFetch(
|
|
|
2479
2481
|
);
|
|
2480
2482
|
}
|
|
2481
2483
|
|
|
2482
|
-
// Bare `/` → `/admin
|
|
2483
|
-
// SPA used to be two disconnected surfaces;
|
|
2484
|
-
// the single coherent admin shell, whose
|
|
2485
|
-
// discovery content (hub-native sections,
|
|
2486
|
-
// used to live here.
|
|
2484
|
+
// Bare `/` → configurable target (default `/admin`, the admin-shell IA).
|
|
2485
|
+
// The home page and the admin SPA used to be two disconnected surfaces;
|
|
2486
|
+
// `/` funnels straight into the single coherent admin shell, whose
|
|
2487
|
+
// Home/Overview carries the discovery content (hub-native sections,
|
|
2488
|
+
// modules, user surfaces) that used to live here.
|
|
2489
|
+
//
|
|
2490
|
+
// The target is operator-configurable (resolveRootRedirect): a hub_settings
|
|
2491
|
+
// `root_redirect` row → `PARACHUTE_HUB_ROOT_REDIRECT` env → `/admin`
|
|
2492
|
+
// default. Lets an operator point their hub's root at a surface (e.g. a
|
|
2493
|
+
// team reading-room) instead of the admin shell, without redeploying. The
|
|
2494
|
+
// resolver re-validates every layer through the same-origin guard
|
|
2495
|
+
// (`isSafeRedirectPath`) so a stored/env value can NEVER produce an open
|
|
2496
|
+
// redirect — an unsafe value is ignored and falls back to `/admin`.
|
|
2487
2497
|
//
|
|
2488
2498
|
// Ordering matters: this sits AFTER the fresh-hub wizard funnel above
|
|
2489
|
-
// (so a brand-new operator still lands on `/admin/setup`, not a
|
|
2490
|
-
//
|
|
2491
|
-
// 503s API callers correctly). 302 (not 301) —
|
|
2492
|
-
//
|
|
2493
|
-
//
|
|
2499
|
+
// (so a brand-new operator still lands on `/admin/setup`, not a surface
|
|
2500
|
+
// that can't work yet) and AFTER the pre-admin lockout (so an admin-less
|
|
2501
|
+
// hub still 503s API callers correctly). 302 (not 301) — the target is
|
|
2502
|
+
// operator-mutable, so a permanent/cached redirect would strand visitors
|
|
2503
|
+
// on a stale destination after the operator flips it.
|
|
2494
2504
|
//
|
|
2495
|
-
// The signed-out path is preserved
|
|
2496
|
-
// `/admin`, where the SPA's AuthIndicator
|
|
2497
|
-
// round-trips through `/login?next=/admin/...`
|
|
2498
|
-
// redirect on session state — the shell
|
|
2505
|
+
// The signed-out path is preserved when the target is `/admin`: a
|
|
2506
|
+
// signed-out visitor lands on `/admin`, where the SPA's AuthIndicator
|
|
2507
|
+
// shows a "Sign in" link that round-trips through `/login?next=/admin/...`
|
|
2508
|
+
// and back. We don't pin the redirect on session state — the shell
|
|
2509
|
+
// handles both auth states itself.
|
|
2499
2510
|
//
|
|
2500
2511
|
// `/hub.html` is INTENTIONALLY excluded: it still renders the discovery
|
|
2501
2512
|
// page (used by the static `parachute expose --set-path=/` disk file and
|
|
@@ -2503,7 +2514,7 @@ export function hubFetch(
|
|
|
2503
2514
|
if (pathname === "/") {
|
|
2504
2515
|
return new Response(null, {
|
|
2505
2516
|
status: 302,
|
|
2506
|
-
headers: { location:
|
|
2517
|
+
headers: { location: resolveRootRedirect(getDb ? getDb() : null) },
|
|
2507
2518
|
});
|
|
2508
2519
|
}
|
|
2509
2520
|
|
|
@@ -3178,6 +3189,18 @@ export function hubFetch(
|
|
|
3178
3189
|
});
|
|
3179
3190
|
}
|
|
3180
3191
|
|
|
3192
|
+
// Bare-`/` redirect target (configurable; default `/admin`). Admin SPA /
|
|
3193
|
+
// CLI reads + writes the operator-set landing page. Same Bearer/scope
|
|
3194
|
+
// posture as hub-origin; the open-redirect guard lives in the handler +
|
|
3195
|
+
// resolver.
|
|
3196
|
+
if (pathname === "/api/settings/root-redirect") {
|
|
3197
|
+
if (!getDb) return dbNotConfigured();
|
|
3198
|
+
return handleApiSettingsRootRedirect(req, {
|
|
3199
|
+
db: getDb(),
|
|
3200
|
+
issuer: oauthDeps(req).issuer,
|
|
3201
|
+
});
|
|
3202
|
+
}
|
|
3203
|
+
|
|
3181
3204
|
// Module operation poll surface — pre-empts the /api/modules/:short/*
|
|
3182
3205
|
// routes below so `/api/modules/operations/<uuid>` doesn't accidentally
|
|
3183
3206
|
// match a parseModulesPath("/operations") and fall through.
|
package/src/hub-settings.ts
CHANGED
|
@@ -106,7 +106,23 @@ export type HubSettingKey =
|
|
|
106
106
|
// Idle timeout for the admin screen-lock, in seconds. Optional override of
|
|
107
107
|
// the built-in default (DEFAULT_ADMIN_LOCK_IDLE_SECONDS). Stored as a
|
|
108
108
|
// stringified integer; absent / unparseable falls back to the default.
|
|
109
|
-
| "admin_lock_idle_seconds"
|
|
109
|
+
| "admin_lock_idle_seconds"
|
|
110
|
+
// hub: operator-settable target for the bare-`/` 302. Lets an operator
|
|
111
|
+
// point their hub's root at a surface (e.g. a team reading-room surface)
|
|
112
|
+
// instead of the default `/admin`. Stored as a SAME-ORIGIN relative path
|
|
113
|
+
// (must start with a single `/`, never `//` / `/\` / a scheme — see
|
|
114
|
+
// `isSafeRedirectPath`); validated on write (admin PUT + CLI) AND re-checked
|
|
115
|
+
// on read so a hand-edited sqlite row can never produce an open redirect.
|
|
116
|
+
//
|
|
117
|
+
// Precedence on each request (resolveRootRedirect): this row, then
|
|
118
|
+
// `PARACHUTE_HUB_ROOT_REDIRECT` env, then the `/admin` default. DB-first
|
|
119
|
+
// (unlike `module_install_channel`'s env-first) so an operator can flip the
|
|
120
|
+
// landing page from the admin SPA / CLI without a redeploy — the headline
|
|
121
|
+
// use case (custom-domain hub fronting a team surface). The fresh-hub
|
|
122
|
+
// wizard funnel + pre-admin 503 lockout run BEFORE this redirect, so a
|
|
123
|
+
// not-yet-set-up hub still lands on setup, not a surface that can't work
|
|
124
|
+
// yet.
|
|
125
|
+
| "root_redirect";
|
|
110
126
|
|
|
111
127
|
export type SetupExposeMode = "localhost" | "tailnet" | "public";
|
|
112
128
|
|
|
@@ -431,3 +447,149 @@ export function setNotesRedirectDisabled(db: Database, value: boolean): void {
|
|
|
431
447
|
deleteSetting(db, "notes_redirect_disabled");
|
|
432
448
|
}
|
|
433
449
|
}
|
|
450
|
+
|
|
451
|
+
// --- domain helpers: configurable bare-`/` redirect target ----------------
|
|
452
|
+
|
|
453
|
+
/** Env override for the bare-`/` redirect target. Below the DB row, above the default. */
|
|
454
|
+
export const PARACHUTE_HUB_ROOT_REDIRECT_ENV = "PARACHUTE_HUB_ROOT_REDIRECT";
|
|
455
|
+
|
|
456
|
+
/** Fallback when neither DB row nor env is set — the admin shell (unchanged behavior). */
|
|
457
|
+
export const DEFAULT_ROOT_REDIRECT = "/admin";
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Open-redirect guard for the configurable bare-`/` redirect target.
|
|
461
|
+
*
|
|
462
|
+
* The resolved value lands verbatim in a `Location:` header on the `/` 302,
|
|
463
|
+
* so an off-origin value would be a textbook open redirect. To be accepted it
|
|
464
|
+
* must be a SAME-ORIGIN relative path:
|
|
465
|
+
*
|
|
466
|
+
* - starts with a single `/` (a site-relative path). This alone rejects
|
|
467
|
+
* `https://evil.com`, `javascript:…`, and bare hostnames.
|
|
468
|
+
* - second char is NOT `/` (a protocol-relative `//evil.com` sends the
|
|
469
|
+
* browser to another origin) and NOT `\` (browsers normalize the
|
|
470
|
+
* backslash, so `/\evil.com` resolves like `//evil.com`).
|
|
471
|
+
* - contains no ASCII control chars or whitespace — a CR/LF would enable
|
|
472
|
+
* header injection, and tab/newline are stripped by some browsers which
|
|
473
|
+
* could re-expose a hidden `//` authority.
|
|
474
|
+
* - resolves same-origin against a placeholder base (belt-and-suspenders:
|
|
475
|
+
* `new URL(value, base).origin === base`) — catches any scheme/authority
|
|
476
|
+
* shape the prefix checks missed.
|
|
477
|
+
* - does NOT resolve to pathname `/` — that would re-enter this very route
|
|
478
|
+
* and 302-loop forever (`/`, `/?x`, `/#y` all rejected).
|
|
479
|
+
*
|
|
480
|
+
* A query string / fragment on a real path is allowed (stays same-origin).
|
|
481
|
+
* Returns false for non-strings, empty, and every off-origin shape.
|
|
482
|
+
*/
|
|
483
|
+
export function isSafeRedirectPath(value: unknown): value is string {
|
|
484
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
485
|
+
if (value[0] !== "/") return false;
|
|
486
|
+
if (value[1] === "/" || value[1] === "\\") return false;
|
|
487
|
+
// Reject whitespace (\t \n \r space + Unicode separators U+2028/U+2029) and
|
|
488
|
+
// ASCII control chars. A CR/LF would enable header injection; stripped
|
|
489
|
+
// whitespace could re-expose a hidden `//` authority. `\s` covers the
|
|
490
|
+
// whitespace family (incl. Unicode); the charCode scan covers the remaining
|
|
491
|
+
// non-whitespace control chars (0x00-0x1f, 0x7f) without a control-char
|
|
492
|
+
// regex literal.
|
|
493
|
+
if (/\s/u.test(value)) return false;
|
|
494
|
+
for (let i = 0; i < value.length; i++) {
|
|
495
|
+
const c = value.charCodeAt(i);
|
|
496
|
+
if (c < 0x20 || c === 0x7f) return false;
|
|
497
|
+
}
|
|
498
|
+
try {
|
|
499
|
+
const base = "http://parachute.invalid";
|
|
500
|
+
const resolved = new URL(value, base);
|
|
501
|
+
if (resolved.origin !== base) return false;
|
|
502
|
+
// pathname "/" would match the bare-`/` route again -> infinite redirect.
|
|
503
|
+
if (resolved.pathname === "/") return false;
|
|
504
|
+
} catch {
|
|
505
|
+
return false;
|
|
506
|
+
}
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Read the operator-set bare-`/` redirect target from hub_settings. Returns
|
|
512
|
+
* the raw stored value (or `null` when absent) WITHOUT re-validating — callers
|
|
513
|
+
* that need a safe value go through `resolveRootRedirect`, which re-checks the
|
|
514
|
+
* guard. The raw read is what the admin GET surfaces so the operator sees
|
|
515
|
+
* exactly what's stored (even if a hand-edit made it unsafe → ignored on use).
|
|
516
|
+
*/
|
|
517
|
+
export function getRootRedirect(db: Database): string | null {
|
|
518
|
+
return getSetting(db, "root_redirect") ?? null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Write or clear the bare-`/` redirect target. Passing `null`/empty deletes
|
|
523
|
+
* the row, reverting to env / default precedence (mirrors `setHubOrigin`).
|
|
524
|
+
* The caller MUST have validated via `isSafeRedirectPath` — this trusts the
|
|
525
|
+
* input (typed-callsite contract); `resolveRootRedirect` re-guards on read as
|
|
526
|
+
* defense-in-depth regardless.
|
|
527
|
+
*/
|
|
528
|
+
export function setRootRedirect(db: Database, value: string | null): void {
|
|
529
|
+
if (value === null || value === "") {
|
|
530
|
+
deleteSetting(db, "root_redirect");
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
setSetting(db, "root_redirect", value);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Which precedence layer the resolved redirect came from. */
|
|
537
|
+
export type RootRedirectSource = "db" | "env" | "default";
|
|
538
|
+
|
|
539
|
+
export interface ResolvedRootRedirect {
|
|
540
|
+
/** The safe same-origin path the `/` 302 should target. */
|
|
541
|
+
value: string;
|
|
542
|
+
/** Which layer it came from (for admin-UI attribution). */
|
|
543
|
+
source: RootRedirectSource;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Resolve the bare-`/` redirect target with source attribution.
|
|
548
|
+
*
|
|
549
|
+
* Precedence: hub_settings.root_redirect → `PARACHUTE_HUB_ROOT_REDIRECT` env
|
|
550
|
+
* → `/admin` default. Every layer is re-validated through `isSafeRedirectPath`;
|
|
551
|
+
* an unsafe value at any layer is warned + skipped so the chain can never
|
|
552
|
+
* produce an open redirect (worst case falls all the way to `/admin`).
|
|
553
|
+
*
|
|
554
|
+
* `db` may be `null` (hub-server running without state) — the DB layer is then
|
|
555
|
+
* skipped and resolution starts from env. The `env` / `warn` knobs are test
|
|
556
|
+
* seams (production uses `process.env` + `console.warn`).
|
|
557
|
+
*/
|
|
558
|
+
export function resolveRootRedirectDetailed(
|
|
559
|
+
db: Database | null,
|
|
560
|
+
opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
|
|
561
|
+
): ResolvedRootRedirect {
|
|
562
|
+
const env = opts.env ?? process.env;
|
|
563
|
+
const warn = opts.warn ?? ((msg: string) => console.warn(msg));
|
|
564
|
+
|
|
565
|
+
// 1. DB row (operator-set via the admin PUT / `parachute hub set-root-redirect`).
|
|
566
|
+
if (db) {
|
|
567
|
+
const fromDb = getSetting(db, "root_redirect");
|
|
568
|
+
if (fromDb !== undefined) {
|
|
569
|
+
if (isSafeRedirectPath(fromDb)) return { value: fromDb, source: "db" };
|
|
570
|
+
warn(
|
|
571
|
+
`[hub-settings] root_redirect="${fromDb}" in hub_settings is not a safe same-origin path — ignoring (falling through to env/default).`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// 2. Env override.
|
|
577
|
+
const fromEnv = env[PARACHUTE_HUB_ROOT_REDIRECT_ENV];
|
|
578
|
+
if (typeof fromEnv === "string" && fromEnv.length > 0) {
|
|
579
|
+
if (isSafeRedirectPath(fromEnv)) return { value: fromEnv, source: "env" };
|
|
580
|
+
warn(
|
|
581
|
+
`[hub-settings] ${PARACHUTE_HUB_ROOT_REDIRECT_ENV}="${fromEnv}" is not a safe same-origin path — falling back to "${DEFAULT_ROOT_REDIRECT}".`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// 3. Default — unchanged behavior.
|
|
586
|
+
return { value: DEFAULT_ROOT_REDIRECT, source: "default" };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Convenience: just the resolved path (see `resolveRootRedirectDetailed`). */
|
|
590
|
+
export function resolveRootRedirect(
|
|
591
|
+
db: Database | null,
|
|
592
|
+
opts: { env?: NodeJS.ProcessEnv; warn?: (msg: string) => void } = {},
|
|
593
|
+
): string {
|
|
594
|
+
return resolveRootRedirectDetailed(db, opts).value;
|
|
595
|
+
}
|