@openparachute/hub 0.5.10-rc.5 → 0.5.10-rc.6
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__/csrf.test.ts +40 -1
- package/src/__tests__/hub-server.test.ts +11 -3
- package/src/__tests__/request-protocol.test.ts +54 -0
- package/src/__tests__/sessions.test.ts +25 -2
- package/src/__tests__/setup-gate.test.ts +19 -3
- package/src/admin-handlers.ts +7 -2
- package/src/csrf.ts +30 -12
- package/src/hub-server.ts +22 -2
- package/src/oauth-handlers.ts +5 -2
- package/src/request-protocol.ts +48 -0
- package/src/sessions.ts +29 -17
- package/src/setup-wizard.ts +4 -1
package/package.json
CHANGED
|
@@ -29,7 +29,7 @@ describe("generateCsrfToken", () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
describe("buildCsrfCookie", () => {
|
|
32
|
-
test("emits the expected attributes", () => {
|
|
32
|
+
test("emits the expected attributes (default secure)", () => {
|
|
33
33
|
const v = buildCsrfCookie("abc");
|
|
34
34
|
expect(v).toContain(`${CSRF_COOKIE_NAME}=abc`);
|
|
35
35
|
expect(v).toContain("HttpOnly");
|
|
@@ -38,6 +38,19 @@ describe("buildCsrfCookie", () => {
|
|
|
38
38
|
expect(v).toContain("Path=/");
|
|
39
39
|
expect(v).toContain("Max-Age=");
|
|
40
40
|
});
|
|
41
|
+
|
|
42
|
+
test("omits Secure when secure: false (HTTP localhost)", () => {
|
|
43
|
+
const v = buildCsrfCookie("abc", { secure: false });
|
|
44
|
+
expect(v).toContain(`${CSRF_COOKIE_NAME}=abc`);
|
|
45
|
+
expect(v).toContain("HttpOnly");
|
|
46
|
+
expect(v).not.toContain("Secure");
|
|
47
|
+
expect(v).toContain("SameSite=Lax");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("keeps Secure when secure: true (explicit)", () => {
|
|
51
|
+
const v = buildCsrfCookie("abc", { secure: true });
|
|
52
|
+
expect(v).toContain("Secure");
|
|
53
|
+
});
|
|
41
54
|
});
|
|
42
55
|
|
|
43
56
|
describe("parseCsrfCookie", () => {
|
|
@@ -71,6 +84,32 @@ describe("ensureCsrfToken", () => {
|
|
|
71
84
|
expect(result.token.length).toBeGreaterThan(0);
|
|
72
85
|
expect(result.setCookie).toBeDefined();
|
|
73
86
|
});
|
|
87
|
+
|
|
88
|
+
// Bug 1 (rc.5 → rc.6) regression: cookies minted over plain HTTP must
|
|
89
|
+
// NOT carry the Secure attribute or browsers silently drop them on
|
|
90
|
+
// http://localhost:1939, breaking the very next double-submit POST.
|
|
91
|
+
test("sets Secure when the request URL is https://", () => {
|
|
92
|
+
const req = new Request("https://hub.example/admin/setup");
|
|
93
|
+
const result = ensureCsrfToken(req);
|
|
94
|
+
expect(result.setCookie).toContain("Secure");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("omits Secure when the request URL is http://localhost", () => {
|
|
98
|
+
const req = new Request("http://localhost:1939/admin/setup");
|
|
99
|
+
const result = ensureCsrfToken(req);
|
|
100
|
+
expect(result.setCookie).not.toContain("Secure");
|
|
101
|
+
// The rest of the cookie shape stays intact — only Secure flips.
|
|
102
|
+
expect(result.setCookie).toContain("HttpOnly");
|
|
103
|
+
expect(result.setCookie).toContain("SameSite=Lax");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("sets Secure when http:// request carries X-Forwarded-Proto: https", () => {
|
|
107
|
+
const req = new Request("http://hub.internal/admin/setup", {
|
|
108
|
+
headers: { "x-forwarded-proto": "https" },
|
|
109
|
+
});
|
|
110
|
+
const result = ensureCsrfToken(req);
|
|
111
|
+
expect(result.setCookie).toContain("Secure");
|
|
112
|
+
});
|
|
74
113
|
});
|
|
75
114
|
|
|
76
115
|
describe("verifyCsrfToken", () => {
|
|
@@ -74,10 +74,16 @@ describe("hubFetch routing", () => {
|
|
|
74
74
|
// The dynamic path takes over from the static disk file the moment a
|
|
75
75
|
// DB is configured. With no session cookie, we still render — just
|
|
76
76
|
// with the "Sign in" affordance.
|
|
77
|
+
//
|
|
78
|
+
// hub#259 rc.6: requires an admin row to bypass the fresh-hub
|
|
79
|
+
// funnel redirect to /admin/setup (Bug 2 fix). Seed one so this
|
|
80
|
+
// test continues to exercise the signed-out-but-setup-done branch.
|
|
77
81
|
const h = makeHarness();
|
|
78
82
|
try {
|
|
79
83
|
const db = openHubDb(hubDbPath(h.dir));
|
|
80
84
|
try {
|
|
85
|
+
const { createUser } = await import("../users.ts");
|
|
86
|
+
await createUser(db, "owner", "pw");
|
|
81
87
|
const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
|
|
82
88
|
expect(res.status).toBe(200);
|
|
83
89
|
expect(res.headers.get("content-type")).toBe("text/html; charset=utf-8");
|
|
@@ -1083,7 +1089,7 @@ describe("hubFetch routing", () => {
|
|
|
1083
1089
|
}
|
|
1084
1090
|
});
|
|
1085
1091
|
|
|
1086
|
-
test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open", async () => {
|
|
1092
|
+
test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
|
|
1087
1093
|
const h = makeHarness();
|
|
1088
1094
|
try {
|
|
1089
1095
|
writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
|
|
@@ -1100,9 +1106,11 @@ describe("hubFetch routing", () => {
|
|
|
1100
1106
|
// /health open
|
|
1101
1107
|
const healthRes = await handler(req("/health"));
|
|
1102
1108
|
expect(healthRes.status).toBe(200);
|
|
1103
|
-
// /
|
|
1109
|
+
// / funnels to the wizard (hub#259 rc.6 fix for Bug 2 — the
|
|
1110
|
+
// static portal pre-setup is useless; redirect to setup).
|
|
1104
1111
|
const rootRes = await handler(req("/"));
|
|
1105
|
-
expect(rootRes.status).toBe(
|
|
1112
|
+
expect(rootRes.status).toBe(302);
|
|
1113
|
+
expect(rootRes.headers.get("location")).toBe("/admin/setup");
|
|
1106
1114
|
// /.well-known/parachute.json open
|
|
1107
1115
|
const wkRes = await handler(req("/.well-known/parachute.json"));
|
|
1108
1116
|
expect(wkRes.status).toBe(200);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `isHttpsRequest` is the single signal cookie-mint helpers use to
|
|
3
|
+
* decide whether to set the `Secure` attribute. Wrong answer here
|
|
4
|
+
* causes browsers to silently drop cookies on HTTP localhost (Bug 1
|
|
5
|
+
* from the rc.5 fresh-machine test) OR mint Secure-less cookies behind
|
|
6
|
+
* a TLS-terminating reverse proxy (the opposite failure mode).
|
|
7
|
+
*
|
|
8
|
+
* Three signals tested, in priority order: direct URL scheme,
|
|
9
|
+
* X-Forwarded-Proto header, plain HTTP default.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, expect, test } from "bun:test";
|
|
12
|
+
import { isHttpsRequest } from "../request-protocol.ts";
|
|
13
|
+
|
|
14
|
+
describe("isHttpsRequest", () => {
|
|
15
|
+
test("returns true for https:// request URL", () => {
|
|
16
|
+
expect(isHttpsRequest(new Request("https://hub.example/x"))).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("returns false for http:// request URL", () => {
|
|
20
|
+
expect(isHttpsRequest(new Request("http://localhost:1939/x"))).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("returns true when X-Forwarded-Proto: https on an http:// request", () => {
|
|
24
|
+
const req = new Request("http://hub.internal/x", {
|
|
25
|
+
headers: { "x-forwarded-proto": "https" },
|
|
26
|
+
});
|
|
27
|
+
expect(isHttpsRequest(req)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("returns false when X-Forwarded-Proto: http", () => {
|
|
31
|
+
const req = new Request("http://hub.internal/x", {
|
|
32
|
+
headers: { "x-forwarded-proto": "http" },
|
|
33
|
+
});
|
|
34
|
+
expect(isHttpsRequest(req)).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("tolerates uppercase / whitespace / list shape in X-Forwarded-Proto", () => {
|
|
38
|
+
// Some proxies emit `https,http` (chain of two hops) or " HTTPS "
|
|
39
|
+
// with whitespace. The first token is what we honor.
|
|
40
|
+
expect(
|
|
41
|
+
isHttpsRequest(new Request("http://hub/x", { headers: { "x-forwarded-proto": " HTTPS " } })),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
expect(
|
|
44
|
+
isHttpsRequest(
|
|
45
|
+
new Request("http://hub/x", { headers: { "x-forwarded-proto": "https,http" } }),
|
|
46
|
+
),
|
|
47
|
+
).toBe(true);
|
|
48
|
+
expect(
|
|
49
|
+
isHttpsRequest(
|
|
50
|
+
new Request("http://hub/x", { headers: { "x-forwarded-proto": "http,https" } }),
|
|
51
|
+
),
|
|
52
|
+
).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -81,7 +81,7 @@ describe("deleteSession", () => {
|
|
|
81
81
|
});
|
|
82
82
|
|
|
83
83
|
describe("buildSessionCookie", () => {
|
|
84
|
-
test("emits the expected attributes", () => {
|
|
84
|
+
test("emits the expected attributes (default secure)", () => {
|
|
85
85
|
const v = buildSessionCookie("abc", 86400);
|
|
86
86
|
expect(v).toContain(`${SESSION_COOKIE_NAME}=abc`);
|
|
87
87
|
expect(v).toContain("HttpOnly");
|
|
@@ -91,16 +91,39 @@ describe("buildSessionCookie", () => {
|
|
|
91
91
|
expect(v).not.toContain("Path=/oauth");
|
|
92
92
|
expect(v).toContain("Max-Age=86400");
|
|
93
93
|
});
|
|
94
|
+
|
|
95
|
+
// Bug 1 (rc.5 → rc.6) regression: session cookies minted over plain
|
|
96
|
+
// HTTP must NOT carry Secure or browsers drop them, leaving the
|
|
97
|
+
// operator un-signed-in on the very next request.
|
|
98
|
+
test("omits Secure when secure: false (HTTP localhost)", () => {
|
|
99
|
+
const v = buildSessionCookie("abc", 86400, { secure: false });
|
|
100
|
+
expect(v).toContain(`${SESSION_COOKIE_NAME}=abc`);
|
|
101
|
+
expect(v).toContain("HttpOnly");
|
|
102
|
+
expect(v).not.toContain("Secure");
|
|
103
|
+
expect(v).toContain("SameSite=Lax");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("keeps Secure when secure: true (explicit)", () => {
|
|
107
|
+
const v = buildSessionCookie("abc", 86400, { secure: true });
|
|
108
|
+
expect(v).toContain("Secure");
|
|
109
|
+
});
|
|
94
110
|
});
|
|
95
111
|
|
|
96
112
|
describe("buildSessionClearCookie", () => {
|
|
97
|
-
test("emits Max-Age=0", () => {
|
|
113
|
+
test("emits Max-Age=0 (default secure)", () => {
|
|
98
114
|
const v = buildSessionClearCookie();
|
|
99
115
|
expect(v).toContain(`${SESSION_COOKIE_NAME}=`);
|
|
100
116
|
expect(v).toContain("Max-Age=0");
|
|
117
|
+
expect(v).toContain("Secure");
|
|
101
118
|
expect(v).toContain("Path=/");
|
|
102
119
|
expect(v).not.toContain("Path=/oauth");
|
|
103
120
|
});
|
|
121
|
+
|
|
122
|
+
test("omits Secure when secure: false (HTTP localhost)", () => {
|
|
123
|
+
const v = buildSessionClearCookie({ secure: false });
|
|
124
|
+
expect(v).not.toContain("Secure");
|
|
125
|
+
expect(v).toContain("Max-Age=0");
|
|
126
|
+
});
|
|
104
127
|
});
|
|
105
128
|
|
|
106
129
|
describe("parseSessionCookie", () => {
|
|
@@ -130,12 +130,28 @@ describe("setup gate (no admin yet)", () => {
|
|
|
130
130
|
}
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
// Bug 2 (rc.5 → rc.6) regression: on a fresh hub, GET `/` should
|
|
134
|
+
// funnel straight to the wizard rather than render the static
|
|
135
|
+
// portal — the operator otherwise has to manually navigate to
|
|
136
|
+
// `/admin/setup`. The portal pre-setup carries no usable signal
|
|
137
|
+
// (no installed services to discover, no admin to sign in as).
|
|
138
|
+
test("/ 302s to /admin/setup when no admin exists (fresh-hub funnel)", async () => {
|
|
134
139
|
const db = openHubDb(hubDbPath(h.dir));
|
|
135
140
|
try {
|
|
136
141
|
const res = await hubFetch(h.dir, { getDb: () => db })(req("/"));
|
|
137
|
-
expect(res.status).toBe(
|
|
138
|
-
expect(res.headers.get("
|
|
142
|
+
expect(res.status).toBe(302);
|
|
143
|
+
expect(res.headers.get("location")).toBe("/admin/setup");
|
|
144
|
+
} finally {
|
|
145
|
+
db.close();
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("/hub.html 302s to /admin/setup when no admin exists", async () => {
|
|
150
|
+
const db = openHubDb(hubDbPath(h.dir));
|
|
151
|
+
try {
|
|
152
|
+
const res = await hubFetch(h.dir, { getDb: () => db })(req("/hub.html"));
|
|
153
|
+
expect(res.status).toBe(302);
|
|
154
|
+
expect(res.headers.get("location")).toBe("/admin/setup");
|
|
139
155
|
} finally {
|
|
140
156
|
db.close();
|
|
141
157
|
}
|
package/src/admin-handlers.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { Database } from "bun:sqlite";
|
|
|
14
14
|
import { renderAdminError, renderAdminLogin } from "./admin-login-ui.ts";
|
|
15
15
|
import { CSRF_FIELD_NAME, ensureCsrfToken, verifyCsrfToken } from "./csrf.ts";
|
|
16
16
|
import { checkAndRecord, clientIpFromRequest } from "./rate-limit.ts";
|
|
17
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
17
18
|
import {
|
|
18
19
|
SESSION_TTL_MS,
|
|
19
20
|
buildSessionClearCookie,
|
|
@@ -124,7 +125,9 @@ export async function handleAdminLoginPost(
|
|
|
124
125
|
);
|
|
125
126
|
}
|
|
126
127
|
const session = createSession(db, { userId: user.id });
|
|
127
|
-
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)
|
|
128
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
129
|
+
secure: isHttpsRequest(req),
|
|
130
|
+
});
|
|
128
131
|
return redirect(next, { "set-cookie": cookie });
|
|
129
132
|
}
|
|
130
133
|
|
|
@@ -164,5 +167,7 @@ export async function handleAdminLogoutPost(db: Database, req: Request): Promise
|
|
|
164
167
|
}
|
|
165
168
|
const sid = parseSessionCookie(req.headers.get("cookie"));
|
|
166
169
|
if (sid) deleteSession(db, sid);
|
|
167
|
-
return redirect("/login", {
|
|
170
|
+
return redirect("/login", {
|
|
171
|
+
"set-cookie": buildSessionClearCookie({ secure: isHttpsRequest(req) }),
|
|
172
|
+
});
|
|
168
173
|
}
|
package/src/csrf.ts
CHANGED
|
@@ -23,14 +23,24 @@
|
|
|
23
23
|
* server-rendered HTML form (cookie + embedded value, classic double-submit)
|
|
24
24
|
* or via the JSON body of `/api/me` (cookie alongside body — same pattern,
|
|
25
25
|
* just JSON instead of HTML). Neither path needs JS to read the cookie
|
|
26
|
-
* directly. SameSite=Lax (matches the session cookie), Secure
|
|
27
|
-
*
|
|
26
|
+
* directly. SameSite=Lax (matches the session cookie), Secure conditional
|
|
27
|
+
* on the request protocol (see below), and Path=/ (covers every admin
|
|
28
|
+
* form, OAuth flow, and `/api/me` consumer).
|
|
29
|
+
*
|
|
30
|
+
* `Secure` is set when the request arrived over HTTPS (direct or behind a
|
|
31
|
+
* reverse proxy that set `X-Forwarded-Proto: https`) — `isHttpsRequest` in
|
|
32
|
+
* `request-protocol.ts` is the single source of truth. On
|
|
33
|
+
* `http://localhost:1939` the attribute is omitted so the browser actually
|
|
34
|
+
* keeps the cookie; setting `Secure` unconditionally silently drops it on
|
|
35
|
+
* HTTP and breaks the double-submit handshake on the very next POST
|
|
36
|
+
* ("Invalid form submission" page on the wizard, etc.).
|
|
28
37
|
*
|
|
29
38
|
* Token entropy: 32 random bytes, base64url-encoded — same shape as session
|
|
30
39
|
* IDs. No HMAC needed: the value is opaque to the client and only ever
|
|
31
40
|
* compared to itself across the cookie/form boundary.
|
|
32
41
|
*/
|
|
33
42
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
43
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
34
44
|
|
|
35
45
|
export const CSRF_COOKIE_NAME = "parachute_hub_csrf";
|
|
36
46
|
export const CSRF_FIELD_NAME = "__csrf";
|
|
@@ -42,15 +52,18 @@ export function generateCsrfToken(): string {
|
|
|
42
52
|
return randomBytes(32).toString("base64url");
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Build a Set-Cookie header value for a CSRF token. `secure` defaults to
|
|
57
|
+
* true (the production posture behind a TLS terminator); callers that
|
|
58
|
+
* mint the cookie for a known-HTTP request — `ensureCsrfToken` does
|
|
59
|
+
* this via `isHttpsRequest` — pass `secure: false` to omit the
|
|
60
|
+
* attribute so the browser keeps the cookie on plain HTTP.
|
|
61
|
+
*/
|
|
62
|
+
export function buildCsrfCookie(token: string, opts: { secure?: boolean } = {}): string {
|
|
63
|
+
const parts = [`${CSRF_COOKIE_NAME}=${token}`, "HttpOnly"];
|
|
64
|
+
if (opts.secure !== false) parts.push("Secure");
|
|
65
|
+
parts.push("SameSite=Lax", "Path=/", `Max-Age=${CSRF_TTL_SECONDS}`);
|
|
66
|
+
return parts.join("; ");
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
export function parseCsrfCookie(cookieHeader: string | null): string | null {
|
|
@@ -72,12 +85,17 @@ export interface EnsuredCsrf {
|
|
|
72
85
|
* Ensure the request carries a CSRF token cookie; mint and return one if not.
|
|
73
86
|
* Callers embed `result.token` in the rendered form and attach
|
|
74
87
|
* `result.setCookie` (if defined) to the response.
|
|
88
|
+
*
|
|
89
|
+
* Protocol-aware: when the request is plain HTTP (`http://localhost:1939`
|
|
90
|
+
* during local dev / on-box CLI), the minted cookie omits the `Secure`
|
|
91
|
+
* attribute so the browser keeps it. When the request is HTTPS (or
|
|
92
|
+
* forwarded via `X-Forwarded-Proto: https`), `Secure` is set.
|
|
75
93
|
*/
|
|
76
94
|
export function ensureCsrfToken(req: Request): EnsuredCsrf {
|
|
77
95
|
const existing = parseCsrfCookie(req.headers.get("cookie"));
|
|
78
96
|
if (existing && existing.length > 0) return { token: existing };
|
|
79
97
|
const token = generateCsrfToken();
|
|
80
|
-
return { token, setCookie: buildCsrfCookie(token) };
|
|
98
|
+
return { token, setCookie: buildCsrfCookie(token, { secure: isHttpsRequest(req) }) };
|
|
81
99
|
}
|
|
82
100
|
|
|
83
101
|
/**
|
package/src/hub-server.ts
CHANGED
|
@@ -1013,14 +1013,34 @@ export function hubFetch(
|
|
|
1013
1013
|
return new Response("not found", { status: 404 });
|
|
1014
1014
|
}
|
|
1015
1015
|
|
|
1016
|
+
// Fresh-hub redirect: on a hub with no admin row yet, the discovery
|
|
1017
|
+
// page (`/`, `/hub.html`) funnels straight to the wizard. The static
|
|
1018
|
+
// portal isn't useful pre-setup — nothing's installed, the
|
|
1019
|
+
// "Signed in" affordance has no session to surface — and the
|
|
1020
|
+
// operator landing on `/` in a browser otherwise has to manually
|
|
1021
|
+
// type `/admin/setup` to escape. 302 (not 301) so the redirect
|
|
1022
|
+
// disappears the moment the wizard finishes.
|
|
1023
|
+
//
|
|
1024
|
+
// Sits before the JSON-shaped 503 gate below because `/` is an
|
|
1025
|
+
// HTML surface — a JSON 503 there would render as raw text in the
|
|
1026
|
+
// operator's browser tab. The 503 gate handles API + admin SPA +
|
|
1027
|
+
// OAuth callers that branch on the structured body.
|
|
1028
|
+
if (getDb && (pathname === "/" || pathname === "/hub.html") && userCount(getDb()) === 0) {
|
|
1029
|
+
return new Response(null, {
|
|
1030
|
+
status: 302,
|
|
1031
|
+
headers: { location: "/admin/setup" },
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1016
1035
|
// Pre-admin lockout. When the hub has booted with no admin row (the
|
|
1017
1036
|
// fresh-container case before PARACHUTE_INITIAL_ADMIN_* is set or
|
|
1018
1037
|
// /admin/setup is walked), every operator-facing surface that requires
|
|
1019
1038
|
// identity is meaningless — auth flows can't validate, the SPA can't
|
|
1020
1039
|
// mint a host-admin token, OAuth can't issue codes. Route those to a
|
|
1021
1040
|
// 503 that points at /admin/setup. Health, well-known, /admin/setup
|
|
1022
|
-
// itself,
|
|
1023
|
-
//
|
|
1041
|
+
// itself, OAuth third-party endpoints, and content proxies pass
|
|
1042
|
+
// through; the fresh-hub `/` and `/hub.html` redirect above handled
|
|
1043
|
+
// the discovery-page case.
|
|
1024
1044
|
//
|
|
1025
1045
|
// `shouldGateForSetup` runs first so non-gated paths (well-known, /,
|
|
1026
1046
|
// /health, /admin/setup) never touch getDb — keeping the
|
package/src/oauth-handlers.ts
CHANGED
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
renderLogin,
|
|
64
64
|
} from "./oauth-ui.ts";
|
|
65
65
|
import { isSameOriginRequest } from "./origin-check.ts";
|
|
66
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
66
67
|
import { isNonRequestableScope, isRequestableScope } from "./scope-explanations.ts";
|
|
67
68
|
import { findUnknownScopes, loadDeclaredScopes } from "./scope-registry.ts";
|
|
68
69
|
import {
|
|
@@ -717,7 +718,7 @@ export async function handleAuthorizePost(
|
|
|
717
718
|
|
|
718
719
|
async function handleLoginSubmit(
|
|
719
720
|
db: Database,
|
|
720
|
-
|
|
721
|
+
req: Request,
|
|
721
722
|
form: Awaited<ReturnType<Request["formData"]>>,
|
|
722
723
|
_deps: OAuthDeps,
|
|
723
724
|
csrfToken: string,
|
|
@@ -746,7 +747,9 @@ async function handleLoginSubmit(
|
|
|
746
747
|
);
|
|
747
748
|
}
|
|
748
749
|
const session = createSession(db, { userId: user.id });
|
|
749
|
-
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)
|
|
750
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
751
|
+
secure: isHttpsRequest(req),
|
|
752
|
+
});
|
|
750
753
|
// Redirect back to GET /oauth/authorize with the original query string so
|
|
751
754
|
// the user lands on the consent screen with full params re-validated.
|
|
752
755
|
const u = new URL("/oauth/authorize", "http://placeholder");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detect whether an incoming `Request` arrived over HTTPS — the signal
|
|
3
|
+
* cookie-mint helpers (`buildCsrfCookie`, `buildSessionCookie`,
|
|
4
|
+
* `buildSessionClearCookie`) use to decide whether to set the `Secure`
|
|
5
|
+
* attribute. Browsers DROP `Secure` cookies on `http://` connections; on
|
|
6
|
+
* `http://localhost:1939` (the normal local-dev / on-box-CLI shape)
|
|
7
|
+
* unconditionally setting `Secure` means the browser silently swallows
|
|
8
|
+
* the cookie, the next POST has no cookie to double-submit against, and
|
|
9
|
+
* CSRF verification fails with a "reload and try again" page.
|
|
10
|
+
*
|
|
11
|
+
* Signals checked, in priority order:
|
|
12
|
+
*
|
|
13
|
+
* 1. `new URL(req.url).protocol === "https:"` — direct HTTPS. Hub
|
|
14
|
+
* itself binds 127.0.0.1:1939 over plain HTTP, but a downstream
|
|
15
|
+
* caller (a test, an internal proxy) may rewrite the URL.
|
|
16
|
+
*
|
|
17
|
+
* 2. `X-Forwarded-Proto: https` — the standard reverse-proxy header.
|
|
18
|
+
* Tailscale Serve, cloudflared, and Render all terminate TLS at the
|
|
19
|
+
* edge and forward HTTP to the hub with this header set. Without
|
|
20
|
+
* this check, every cookie minted behind a reverse proxy would
|
|
21
|
+
* drop `Secure` and downgrade the security posture.
|
|
22
|
+
*
|
|
23
|
+
* 3. Otherwise: HTTP. The cookie is minted without `Secure` so the
|
|
24
|
+
* browser keeps it on `http://localhost:1939`.
|
|
25
|
+
*
|
|
26
|
+
* The pattern matches the standard double-submit / session-cookie
|
|
27
|
+
* convention: secure-by-default unless explicit evidence the connection
|
|
28
|
+
* is plain HTTP. We do NOT default to "secure" when the protocol is
|
|
29
|
+
* ambiguous — an ambiguous request is one we can't prove is HTTPS, and
|
|
30
|
+
* setting `Secure` on it would re-create the original bug. A real
|
|
31
|
+
* MITM-able HTTP connection is a different problem (operators on
|
|
32
|
+
* untrusted networks should expose the hub through tailnet/funnel, not
|
|
33
|
+
* raw HTTP); the cookie-Secure attribute isn't the defense against it.
|
|
34
|
+
*/
|
|
35
|
+
export function isHttpsRequest(req: Request): boolean {
|
|
36
|
+
// 1. Direct protocol on the parsed URL.
|
|
37
|
+
try {
|
|
38
|
+
if (new URL(req.url).protocol === "https:") return true;
|
|
39
|
+
} catch {
|
|
40
|
+
// Malformed URL on req — fall through to header sniffing.
|
|
41
|
+
}
|
|
42
|
+
// 2. Reverse-proxy header. The forwarded-proto header is a single
|
|
43
|
+
// token in practice ("http" or "https"); we lowercase + trim before
|
|
44
|
+
// compare so a stray "HTTPS" or " https" doesn't slip past.
|
|
45
|
+
const xfp = req.headers.get("x-forwarded-proto");
|
|
46
|
+
if (xfp && xfp.split(",")[0]?.trim().toLowerCase() === "https") return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
package/src/sessions.ts
CHANGED
|
@@ -83,26 +83,38 @@ export function deleteSession(db: Database, id: string): void {
|
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Build a `Set-Cookie` header value for the given session id. HttpOnly +
|
|
86
|
-
* SameSite=Lax + Secure (
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
* into this hub", and admin pages outside /oauth/ (config portal, etc.)
|
|
90
|
-
* the same session. State-changing admin POSTs require a CSRF token
|
|
91
|
-
* src/csrf.ts) since SameSite=Lax alone doesn't prevent same-site
|
|
86
|
+
* SameSite=Lax + Secure (conditional) + Path=/.
|
|
87
|
+
*
|
|
88
|
+
* Path=/ covers the whole hub origin: the operator's session is "logged
|
|
89
|
+
* into this hub", and admin pages outside /oauth/ (config portal, etc.)
|
|
90
|
+
* ride the same session. State-changing admin POSTs require a CSRF token
|
|
91
|
+
* (see src/csrf.ts) since SameSite=Lax alone doesn't prevent same-site
|
|
92
|
+
* CSRF.
|
|
93
|
+
*
|
|
94
|
+
* `Secure` defaults to true (production behind a TLS terminator).
|
|
95
|
+
* Callers minting the cookie for a known-HTTP request — `/login` POST
|
|
96
|
+
* over `http://localhost:1939`, the wizard's account POST same — pass
|
|
97
|
+
* `secure: false` (computed from `isHttpsRequest(req)`) so the
|
|
98
|
+
* browser actually keeps the cookie. Setting `Secure` unconditionally
|
|
99
|
+
* over plain HTTP silently drops the cookie and breaks the very next
|
|
100
|
+
* authenticated request.
|
|
92
101
|
*/
|
|
93
|
-
export function buildSessionCookie(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
export function buildSessionCookie(
|
|
103
|
+
sessionId: string,
|
|
104
|
+
maxAgeSeconds: number,
|
|
105
|
+
opts: { secure?: boolean } = {},
|
|
106
|
+
): string {
|
|
107
|
+
const parts = [`${SESSION_COOKIE_NAME}=${sessionId}`, "HttpOnly"];
|
|
108
|
+
if (opts.secure !== false) parts.push("Secure");
|
|
109
|
+
parts.push("SameSite=Lax", "Path=/", `Max-Age=${maxAgeSeconds}`);
|
|
110
|
+
return parts.join("; ");
|
|
102
111
|
}
|
|
103
112
|
|
|
104
|
-
export function buildSessionClearCookie(): string {
|
|
105
|
-
|
|
113
|
+
export function buildSessionClearCookie(opts: { secure?: boolean } = {}): string {
|
|
114
|
+
const parts = [`${SESSION_COOKIE_NAME}=`, "HttpOnly"];
|
|
115
|
+
if (opts.secure !== false) parts.push("Secure");
|
|
116
|
+
parts.push("SameSite=Lax", "Path=/", "Max-Age=0");
|
|
117
|
+
return parts.join("; ");
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
export function parseSessionCookie(cookieHeader: string | null): string | null {
|
package/src/setup-wizard.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
verifyCsrfToken,
|
|
48
48
|
} from "./csrf.ts";
|
|
49
49
|
import { escapeHtml } from "./oauth-ui.ts";
|
|
50
|
+
import { isHttpsRequest } from "./request-protocol.ts";
|
|
50
51
|
import { findService, readManifest } from "./services-manifest.ts";
|
|
51
52
|
import {
|
|
52
53
|
SESSION_TTL_MS,
|
|
@@ -551,7 +552,9 @@ export async function handleSetupAccountPost(
|
|
|
551
552
|
try {
|
|
552
553
|
const user = await createUser(deps.db, username, password);
|
|
553
554
|
const session = createSession(deps.db, { userId: user.id });
|
|
554
|
-
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000)
|
|
555
|
+
const cookie = buildSessionCookie(session.id, Math.floor(SESSION_TTL_MS / 1000), {
|
|
556
|
+
secure: isHttpsRequest(req),
|
|
557
|
+
});
|
|
555
558
|
return redirect("/admin/setup", { "set-cookie": cookie });
|
|
556
559
|
} catch (err) {
|
|
557
560
|
// Log the raw error server-side for the operator's debugging, but
|