@caelo-cms/shared 0.10.37 → 0.10.39

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.
@@ -0,0 +1,418 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Issue #650 — the access gate in front of `staging.<domain>`.
5
+ *
6
+ * One pure module shared by the two sides of the gate:
7
+ *
8
+ * - the admin, which issues "Open staging" tokens and shows the Owner the
9
+ * crawler credentials (basic auth, secret header) it derives here;
10
+ * - the gateway (gcp-firebase), which verifies every staging request
11
+ * against the same rules before it proxies to the staged build.
12
+ *
13
+ * Every secret is derived from ONE shared key, the `staging-gate-secret`
14
+ * in Secret Manager (`CAELO_STAGING_GATE_SECRET` on admin + gateway):
15
+ *
16
+ * - Crawler credentials are `HMAC(key, kind + nonce)`. The database keeps
17
+ * only the random nonce, so a database read alone reveals no credential,
18
+ * and rotating a credential is writing a new nonce — no redeploy, no
19
+ * Secret Manager write from the admin at runtime.
20
+ * - "Open staging" tokens and the session cookie they turn into are
21
+ * `purpose.expiry.HMAC(key, purpose + expiry)`: stateless, short-lived,
22
+ * and useless for any other purpose.
23
+ *
24
+ * Every comparison of a presented secret is constant-time. WebCrypto only,
25
+ * so the module runs unchanged in Bun, Node and the admin's SvelteKit build.
26
+ */
27
+
28
+ /** Cookie the gateway sets on `staging.<domain>` after "Open staging". */
29
+ export const STAGING_SESSION_COOKIE = "caelo_staging_access";
30
+
31
+ /** Path on the staging host that turns an "Open staging" token into the session cookie. */
32
+ export const STAGING_ACCESS_PATH = "/_caelo/staging-access";
33
+
34
+ /** How long an "Open staging" link stays usable. Short: it travels in a URL. */
35
+ export const STAGING_OPEN_TOKEN_TTL_SECONDS = 120;
36
+
37
+ /** How long the staging session cookie keeps an operator's browser in. */
38
+ export const STAGING_SESSION_TTL_SECONDS = 12 * 60 * 60;
39
+
40
+ /** Default name of the secret request header crawlers can send. */
41
+ export const DEFAULT_STAGING_HEADER_NAME = "X-Caelo-Staging-Key";
42
+
43
+ /** The access modes of the gate. Any one satisfied lets a request in. */
44
+ export type StagingAccessMode = "cookie" | "basic-auth" | "header" | "ip";
45
+
46
+ /** The gate's settings as stored (`staging_access`, migration 0251). */
47
+ export interface StagingAccessSettings {
48
+ readonly cookieEnabled: boolean;
49
+ readonly basicAuthEnabled: boolean;
50
+ readonly basicAuthUser: string;
51
+ /** Random per-rotation nonce; null until the credential was first generated. */
52
+ readonly basicAuthNonce: string | null;
53
+ readonly headerEnabled: boolean;
54
+ readonly headerName: string;
55
+ readonly headerNonce: string | null;
56
+ /** CIDRs (`203.0.113.7/32`, `2001:db8::/48`); a bare address means /32 or /128. */
57
+ readonly ipAllowlist: readonly string[];
58
+ }
59
+
60
+ /** What the gate decided for one request. */
61
+ export type StagingAccessDecision =
62
+ | {
63
+ readonly allowed: true;
64
+ readonly via: StagingAccessMode;
65
+ /**
66
+ * Every mode the request satisfied. The proxy strips a credential
67
+ * header only when its mode is in here: a header that merely shares
68
+ * the gate's name (or a Basic login for the staged app itself) is the
69
+ * site's, and reaches the origin as it would in production.
70
+ */
71
+ readonly satisfied: readonly StagingAccessMode[];
72
+ }
73
+ | {
74
+ readonly allowed: false;
75
+ /** 401 + `WWW-Authenticate: Basic` when basic auth is on, else 403. */
76
+ readonly status: 401 | 403;
77
+ };
78
+
79
+ const ENCODER = new TextEncoder();
80
+
81
+ async function hmacBytes(secret: string, message: string): Promise<Uint8Array> {
82
+ const key = await crypto.subtle.importKey(
83
+ "raw",
84
+ ENCODER.encode(secret),
85
+ { name: "HMAC", hash: "SHA-256" },
86
+ false,
87
+ ["sign"],
88
+ );
89
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, ENCODER.encode(message)));
90
+ }
91
+
92
+ function base64Url(bytes: Uint8Array): string {
93
+ let bin = "";
94
+ for (const b of bytes) bin += String.fromCharCode(b);
95
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
96
+ }
97
+
98
+ /**
99
+ * Constant-time string comparison. Inputs of different length compare
100
+ * unequal without an early exit on the first differing byte; the length
101
+ * itself is not secret (every derived credential has a fixed length).
102
+ */
103
+ export function constantTimeEqual(a: string, b: string): boolean {
104
+ const x = ENCODER.encode(a);
105
+ const y = ENCODER.encode(b);
106
+ const other = x.length === y.length ? y : x;
107
+ let diff = x.length === y.length ? 0 : 1;
108
+ for (let i = 0; i < x.length; i++) diff |= (x[i] ?? 0) ^ (other[i] ?? 0);
109
+ return diff === 0;
110
+ }
111
+
112
+ /** A fresh random nonce for a credential rotation (24 random bytes, base64url). */
113
+ export function newStagingCredentialNonce(): string {
114
+ const bytes = new Uint8Array(24);
115
+ crypto.getRandomValues(bytes);
116
+ return base64Url(bytes);
117
+ }
118
+
119
+ /**
120
+ * The crawler credential (basic-auth password or secret header value) for
121
+ * `nonce`. 32 URL-safe characters (192 bits).
122
+ *
123
+ * @example
124
+ * await deriveStagingCredential(gateSecret, "basic-auth", settings.basicAuthNonce)
125
+ */
126
+ export async function deriveStagingCredential(
127
+ secret: string,
128
+ kind: "basic-auth" | "header",
129
+ nonce: string,
130
+ ): Promise<string> {
131
+ const mac = await hmacBytes(secret, `caelo-staging-credential:${kind}:${nonce}`);
132
+ return base64Url(mac.slice(0, 24));
133
+ }
134
+
135
+ type TokenPurpose = "open" | "session";
136
+
137
+ /**
138
+ * A signed, expiring token: `<purpose>.<expiresAtSeconds>.<mac>`. `open` is
139
+ * the one-time-ish link the admin hands the browser; `session` is the cookie
140
+ * the gateway sets in exchange.
141
+ */
142
+ export async function signStagingToken(
143
+ secret: string,
144
+ purpose: TokenPurpose,
145
+ expiresAtSeconds: number,
146
+ ): Promise<string> {
147
+ const exp = Math.floor(expiresAtSeconds);
148
+ const mac = base64Url(await hmacBytes(secret, `caelo-staging:${purpose}:${exp}`));
149
+ return `${purpose}.${exp}.${mac}`;
150
+ }
151
+
152
+ /** True when `token` is a valid, unexpired token for `purpose`. */
153
+ export async function verifyStagingToken(
154
+ secret: string,
155
+ token: string,
156
+ purpose: TokenPurpose,
157
+ nowSeconds: number,
158
+ ): Promise<boolean> {
159
+ const parts = token.split(".");
160
+ if (parts.length !== 3) return false;
161
+ const [p, expRaw, mac] = parts as [string, string, string];
162
+ if (p !== purpose || !/^\d{1,12}$/.test(expRaw)) return false;
163
+ const exp = Number(expRaw);
164
+ const expected = base64Url(await hmacBytes(secret, `caelo-staging:${purpose}:${exp}`));
165
+ // Compare before checking expiry so the timing does not tell an expired
166
+ // token from a forged one.
167
+ const valid = constantTimeEqual(mac, expected);
168
+ return valid && exp > nowSeconds;
169
+ }
170
+
171
+ // ─── IP allowlist ─────────────────────────────────────────────────────
172
+
173
+ function parseIpv4(s: string): number[] | null {
174
+ const parts = s.split(".");
175
+ if (parts.length !== 4) return null;
176
+ const out: number[] = [];
177
+ for (const p of parts) {
178
+ if (!/^\d{1,3}$/.test(p)) return null;
179
+ const n = Number(p);
180
+ if (n > 255) return null;
181
+ out.push(n);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ function parseIpv6(s: string): number[] | null {
187
+ let text = s;
188
+ let tail: number[] = [];
189
+ const lastColon = text.lastIndexOf(":");
190
+ if (text.includes(".")) {
191
+ const v4 = parseIpv4(text.slice(lastColon + 1));
192
+ if (!v4) return null;
193
+ tail = v4;
194
+ text = `${text.slice(0, lastColon + 1)}0:0`;
195
+ }
196
+ const halves = text.split("::");
197
+ if (halves.length > 2) return null;
198
+ const groups = (h: string) => (h === "" ? [] : h.split(":"));
199
+ const head = groups(halves[0] ?? "");
200
+ const rest = halves.length === 2 ? groups(halves[1] ?? "") : [];
201
+ const fill = 8 - head.length - rest.length;
202
+ if (halves.length === 1 ? head.length !== 8 : fill < 1) return null;
203
+ const all = [...head, ...Array<string>(halves.length === 2 ? fill : 0).fill("0"), ...rest];
204
+ const bytes: number[] = [];
205
+ for (const g of all) {
206
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g)) return null;
207
+ const n = Number.parseInt(g, 16);
208
+ bytes.push(n >> 8, n & 0xff);
209
+ }
210
+ if (tail.length === 4) bytes.splice(12, 4, ...tail);
211
+ return bytes;
212
+ }
213
+
214
+ /** Parse an IP address into bytes; an IPv4-mapped IPv6 address becomes IPv4. */
215
+ function parseIp(raw: string): number[] | null {
216
+ const s = raw.trim().replace(/^\[|\]$/g, "");
217
+ if (s.includes(":")) {
218
+ const v6 = parseIpv6(s.split("%")[0] ?? "");
219
+ if (!v6) return null;
220
+ const mapped = v6.slice(0, 10).every((b) => b === 0) && v6[10] === 0xff && v6[11] === 0xff;
221
+ return mapped ? v6.slice(12) : v6;
222
+ }
223
+ return parseIpv4(s);
224
+ }
225
+
226
+ /** A parsed CIDR, or null when `cidr` is not one. A bare address is a host route. */
227
+ export function parseCidr(
228
+ cidr: string,
229
+ ): { readonly bytes: number[]; readonly bits: number } | null {
230
+ const [addr, len, extra] = cidr.trim().split("/");
231
+ if (addr === undefined || extra !== undefined) return null;
232
+ const bytes = parseIp(addr);
233
+ if (!bytes) return null;
234
+ const max = bytes.length * 8;
235
+ if (len === undefined) return { bytes, bits: max };
236
+ if (!/^\d{1,3}$/.test(len)) return null;
237
+ const bits = Number(len);
238
+ return bits <= max ? { bytes, bits } : null;
239
+ }
240
+
241
+ /** True when `ip` lies inside `cidr` (same address family only). */
242
+ export function ipMatchesCidr(ip: string, cidr: string): boolean {
243
+ const addr = parseIp(ip);
244
+ const net = parseCidr(cidr);
245
+ if (!addr || !net || addr.length !== net.bytes.length) return false;
246
+ let bits = net.bits;
247
+ for (let i = 0; i < addr.length && bits > 0; i++) {
248
+ const take = Math.min(8, bits);
249
+ const mask = (0xff << (8 - take)) & 0xff;
250
+ if (((addr[i] ?? 0) & mask) !== ((net.bytes[i] ?? 0) & mask)) return false;
251
+ bits -= take;
252
+ }
253
+ return true;
254
+ }
255
+
256
+ // ─── request decision ─────────────────────────────────────────────────
257
+
258
+ /** `Authorization: Basic …` → user + password, or null. */
259
+ export function parseBasicAuthorization(
260
+ header: string | null,
261
+ ): { user: string; password: string } | null {
262
+ if (!header) return null;
263
+ const m = /^Basic\s+([A-Za-z0-9+/=]+)\s*$/i.exec(header);
264
+ if (!m?.[1]) return null;
265
+ let decoded: string;
266
+ try {
267
+ decoded = new TextDecoder().decode(Uint8Array.from(atob(m[1]), (c) => c.charCodeAt(0)));
268
+ } catch {
269
+ return null;
270
+ }
271
+ const i = decoded.indexOf(":");
272
+ if (i < 0) return null;
273
+ return { user: decoded.slice(0, i), password: decoded.slice(i + 1) };
274
+ }
275
+
276
+ /** The value of cookie `name` in a `Cookie` header, or null. */
277
+ export function readCookie(header: string | null, name: string): string | null {
278
+ if (!header) return null;
279
+ for (const part of header.split(/;\s*/)) {
280
+ const eq = part.indexOf("=");
281
+ if (eq > 0 && part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
282
+ }
283
+ return null;
284
+ }
285
+
286
+ /** The request facts the gate looks at. */
287
+ export interface StagingAccessRequest {
288
+ readonly cookieHeader: string | null;
289
+ readonly authorizationHeader: string | null;
290
+ /** Reads a request header by (case-insensitive) name. */
291
+ readonly header: (name: string) => string | null;
292
+ /** The client address the platform vouches for, or null when unknown. */
293
+ readonly clientIp: string | null;
294
+ }
295
+
296
+ /**
297
+ * Decide one staging request. Any enabled mode that the request satisfies
298
+ * lets it in; every check runs (no short-circuit on the first secret
299
+ * comparison), so the response time does not reveal which mode failed.
300
+ */
301
+ export async function decideStagingAccess(args: {
302
+ readonly settings: StagingAccessSettings;
303
+ readonly secret: string;
304
+ readonly request: StagingAccessRequest;
305
+ readonly nowSeconds: number;
306
+ }): Promise<StagingAccessDecision> {
307
+ const { settings, secret, request } = args;
308
+ const granted: StagingAccessMode[] = [];
309
+
310
+ if (settings.cookieEnabled) {
311
+ const token = readCookie(request.cookieHeader, STAGING_SESSION_COOKIE);
312
+ if (token && (await verifyStagingToken(secret, token, "session", args.nowSeconds))) {
313
+ granted.push("cookie");
314
+ }
315
+ }
316
+ if (settings.basicAuthEnabled && settings.basicAuthNonce) {
317
+ const presented = parseBasicAuthorization(request.authorizationHeader);
318
+ const expected = await deriveStagingCredential(secret, "basic-auth", settings.basicAuthNonce);
319
+ const userOk = constantTimeEqual(presented?.user ?? "", settings.basicAuthUser);
320
+ const passOk = constantTimeEqual(presented?.password ?? "", expected);
321
+ if (presented && userOk && passOk) granted.push("basic-auth");
322
+ }
323
+ if (settings.headerEnabled && settings.headerNonce) {
324
+ const presented = request.header(settings.headerName) ?? "";
325
+ const expected = await deriveStagingCredential(secret, "header", settings.headerNonce);
326
+ if (constantTimeEqual(presented, expected) && presented !== "") granted.push("header");
327
+ }
328
+ const ip = request.clientIp;
329
+ if (ip && settings.ipAllowlist.some((c) => ipMatchesCidr(ip, c))) granted.push("ip");
330
+
331
+ const via = granted[0];
332
+ if (via) return { allowed: true, via, satisfied: granted };
333
+ return { allowed: false, status: settings.basicAuthEnabled ? 401 : 403 };
334
+ }
335
+
336
+ /**
337
+ * Where "Open staging" may send the browser after setting the cookie: a
338
+ * same-origin path only (never `//host` or a scheme), else `/`.
339
+ *
340
+ * The result is always ASCII (safe as a `Location` header value).
341
+ *
342
+ * Control characters are refused outright, not only CR/LF: the WHATWG URL
343
+ * parser deletes tabs and newlines before it parses a Location, so
344
+ * `/<TAB>/evil.example` would otherwise become `//evil.example` in the
345
+ * browser — an open redirect off the staging host.
346
+ */
347
+ export function safeStagingNextPath(next: string | null): string {
348
+ if (next === null || !next.startsWith("/") || next.startsWith("//")) return "/";
349
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is the point
350
+ if (next.includes("\\") || /[\u0000-\u001f\u007f]/.test(next)) return "/";
351
+ // Serialise through the URL parser: non-ASCII is percent-encoded (a raw
352
+ // `/日本/` is not a valid header value and would fail the redirect), and
353
+ // the origin check is the parser's own verdict, not ours.
354
+ const base = "https://staging.invalid";
355
+ const url = new URL(next, base);
356
+ if (url.origin !== base) return "/";
357
+ return `${url.pathname}${url.search}${url.hash}`;
358
+ }
359
+
360
+ // ─── the staging host setting ─────────────────────────────────────────
361
+
362
+ /**
363
+ * The staging host an install gets when the operator names none:
364
+ * `staging.<install domain>`.
365
+ */
366
+ export function defaultStagingHost(domain: string): string {
367
+ return `staging.${domain.trim().toLowerCase().replace(/\.$/, "")}`;
368
+ }
369
+
370
+ /**
371
+ * Longest host a Cloud Run domain mapping accepts — the gcp-firebase
372
+ * gateway is mapped to the staging host, so a longer one would be stored,
373
+ * rendered into every staged build and then never answer.
374
+ * https://cloud.google.com/run/docs/mapping-custom-domains#limitations
375
+ */
376
+ export const CLOUD_RUN_DOMAIN_MAPPING_MAX_HOST_LENGTH = 64;
377
+
378
+ const FQDN =
379
+ /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
380
+
381
+ /** Lower-case, trailing-dot-free form of a host the operator typed. */
382
+ export function normalizeStagingHost(host: string): string {
383
+ return host.trim().toLowerCase().replace(/\.$/, "");
384
+ }
385
+
386
+ /**
387
+ * Why `host` cannot be the staging host, or null when it can. It must be a
388
+ * bare fully-qualified domain name (no scheme, port or path) and must not be
389
+ * the live site's host or an admin host — staging would otherwise shadow
390
+ * the site or the admin it is served next to. On gcp-firebase (`provider`)
391
+ * it must also fit a Cloud Run domain mapping
392
+ * ({@link CLOUD_RUN_DOMAIN_MAPPING_MAX_HOST_LENGTH} characters).
393
+ *
394
+ * @example
395
+ * stagingHostProblem("preview.example.com", { liveHosts: ["example.com"], adminHosts: ["admin.example.com"] })
396
+ * // → null
397
+ */
398
+ export function stagingHostProblem(
399
+ host: string,
400
+ reserved: { readonly liveHosts: readonly string[]; readonly adminHosts: readonly string[] },
401
+ opts: { readonly provider?: string | undefined } = {},
402
+ ): string | null {
403
+ const h = normalizeStagingHost(host);
404
+ if (!FQDN.test(h)) {
405
+ return `"${host}" is not a valid host name — use a bare domain such as staging.example.com (no https://, port or path)`;
406
+ }
407
+ if (opts.provider === "gcp-firebase" && h.length > CLOUD_RUN_DOMAIN_MAPPING_MAX_HOST_LENGTH) {
408
+ return `"${h}" is ${h.length} characters long; on gcp-firebase the staging host is served through a Cloud Run domain mapping, which accepts at most ${CLOUD_RUN_DOMAIN_MAPPING_MAX_HOST_LENGTH} — pick a shorter host (e.g. stg.<your domain>, or a shorter domain you own)`;
409
+ }
410
+ const norm = (list: readonly string[]) => list.map(normalizeStagingHost).filter(Boolean);
411
+ if (norm(reserved.liveHosts).includes(h)) {
412
+ return `"${h}" is the live site's host — staging needs a host of its own (e.g. staging.${h})`;
413
+ }
414
+ if (norm(reserved.adminHosts).includes(h)) {
415
+ return `"${h}" is the admin's host — pick another host for staging`;
416
+ }
417
+ return null;
418
+ }
package/src/version.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * follow standard SemVer.
23
23
  */
24
24
 
25
- export const CAELO_VERSION = "0.10.37";
25
+ export const CAELO_VERSION = "0.10.39";
26
26
 
27
27
  /**
28
28
  * Deprecated alias for back-compat — early P17 work spelled this