@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,337 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+ /**
3
+ * Issue #650 — the access gate in front of `staging.<domain>`.
4
+ *
5
+ * One pure module shared by the two sides of the gate:
6
+ *
7
+ * - the admin, which issues "Open staging" tokens and shows the Owner the
8
+ * crawler credentials (basic auth, secret header) it derives here;
9
+ * - the gateway (gcp-firebase), which verifies every staging request
10
+ * against the same rules before it proxies to the staged build.
11
+ *
12
+ * Every secret is derived from ONE shared key, the `staging-gate-secret`
13
+ * in Secret Manager (`CAELO_STAGING_GATE_SECRET` on admin + gateway):
14
+ *
15
+ * - Crawler credentials are `HMAC(key, kind + nonce)`. The database keeps
16
+ * only the random nonce, so a database read alone reveals no credential,
17
+ * and rotating a credential is writing a new nonce — no redeploy, no
18
+ * Secret Manager write from the admin at runtime.
19
+ * - "Open staging" tokens and the session cookie they turn into are
20
+ * `purpose.expiry.HMAC(key, purpose + expiry)`: stateless, short-lived,
21
+ * and useless for any other purpose.
22
+ *
23
+ * Every comparison of a presented secret is constant-time. WebCrypto only,
24
+ * so the module runs unchanged in Bun, Node and the admin's SvelteKit build.
25
+ */
26
+ /** Cookie the gateway sets on `staging.<domain>` after "Open staging". */
27
+ export const STAGING_SESSION_COOKIE = "caelo_staging_access";
28
+ /** Path on the staging host that turns an "Open staging" token into the session cookie. */
29
+ export const STAGING_ACCESS_PATH = "/_caelo/staging-access";
30
+ /** How long an "Open staging" link stays usable. Short: it travels in a URL. */
31
+ export const STAGING_OPEN_TOKEN_TTL_SECONDS = 120;
32
+ /** How long the staging session cookie keeps an operator's browser in. */
33
+ export const STAGING_SESSION_TTL_SECONDS = 12 * 60 * 60;
34
+ /** Default name of the secret request header crawlers can send. */
35
+ export const DEFAULT_STAGING_HEADER_NAME = "X-Caelo-Staging-Key";
36
+ const ENCODER = new TextEncoder();
37
+ async function hmacBytes(secret, message) {
38
+ const key = await crypto.subtle.importKey("raw", ENCODER.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
39
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, ENCODER.encode(message)));
40
+ }
41
+ function base64Url(bytes) {
42
+ let bin = "";
43
+ for (const b of bytes)
44
+ bin += String.fromCharCode(b);
45
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
46
+ }
47
+ /**
48
+ * Constant-time string comparison. Inputs of different length compare
49
+ * unequal without an early exit on the first differing byte; the length
50
+ * itself is not secret (every derived credential has a fixed length).
51
+ */
52
+ export function constantTimeEqual(a, b) {
53
+ const x = ENCODER.encode(a);
54
+ const y = ENCODER.encode(b);
55
+ const other = x.length === y.length ? y : x;
56
+ let diff = x.length === y.length ? 0 : 1;
57
+ for (let i = 0; i < x.length; i++)
58
+ diff |= (x[i] ?? 0) ^ (other[i] ?? 0);
59
+ return diff === 0;
60
+ }
61
+ /** A fresh random nonce for a credential rotation (24 random bytes, base64url). */
62
+ export function newStagingCredentialNonce() {
63
+ const bytes = new Uint8Array(24);
64
+ crypto.getRandomValues(bytes);
65
+ return base64Url(bytes);
66
+ }
67
+ /**
68
+ * The crawler credential (basic-auth password or secret header value) for
69
+ * `nonce`. 32 URL-safe characters (192 bits).
70
+ *
71
+ * @example
72
+ * await deriveStagingCredential(gateSecret, "basic-auth", settings.basicAuthNonce)
73
+ */
74
+ export async function deriveStagingCredential(secret, kind, nonce) {
75
+ const mac = await hmacBytes(secret, `caelo-staging-credential:${kind}:${nonce}`);
76
+ return base64Url(mac.slice(0, 24));
77
+ }
78
+ /**
79
+ * A signed, expiring token: `<purpose>.<expiresAtSeconds>.<mac>`. `open` is
80
+ * the one-time-ish link the admin hands the browser; `session` is the cookie
81
+ * the gateway sets in exchange.
82
+ */
83
+ export async function signStagingToken(secret, purpose, expiresAtSeconds) {
84
+ const exp = Math.floor(expiresAtSeconds);
85
+ const mac = base64Url(await hmacBytes(secret, `caelo-staging:${purpose}:${exp}`));
86
+ return `${purpose}.${exp}.${mac}`;
87
+ }
88
+ /** True when `token` is a valid, unexpired token for `purpose`. */
89
+ export async function verifyStagingToken(secret, token, purpose, nowSeconds) {
90
+ const parts = token.split(".");
91
+ if (parts.length !== 3)
92
+ return false;
93
+ const [p, expRaw, mac] = parts;
94
+ if (p !== purpose || !/^\d{1,12}$/.test(expRaw))
95
+ return false;
96
+ const exp = Number(expRaw);
97
+ const expected = base64Url(await hmacBytes(secret, `caelo-staging:${purpose}:${exp}`));
98
+ // Compare before checking expiry so the timing does not tell an expired
99
+ // token from a forged one.
100
+ const valid = constantTimeEqual(mac, expected);
101
+ return valid && exp > nowSeconds;
102
+ }
103
+ // ─── IP allowlist ─────────────────────────────────────────────────────
104
+ function parseIpv4(s) {
105
+ const parts = s.split(".");
106
+ if (parts.length !== 4)
107
+ return null;
108
+ const out = [];
109
+ for (const p of parts) {
110
+ if (!/^\d{1,3}$/.test(p))
111
+ return null;
112
+ const n = Number(p);
113
+ if (n > 255)
114
+ return null;
115
+ out.push(n);
116
+ }
117
+ return out;
118
+ }
119
+ function parseIpv6(s) {
120
+ let text = s;
121
+ let tail = [];
122
+ const lastColon = text.lastIndexOf(":");
123
+ if (text.includes(".")) {
124
+ const v4 = parseIpv4(text.slice(lastColon + 1));
125
+ if (!v4)
126
+ return null;
127
+ tail = v4;
128
+ text = `${text.slice(0, lastColon + 1)}0:0`;
129
+ }
130
+ const halves = text.split("::");
131
+ if (halves.length > 2)
132
+ return null;
133
+ const groups = (h) => (h === "" ? [] : h.split(":"));
134
+ const head = groups(halves[0] ?? "");
135
+ const rest = halves.length === 2 ? groups(halves[1] ?? "") : [];
136
+ const fill = 8 - head.length - rest.length;
137
+ if (halves.length === 1 ? head.length !== 8 : fill < 1)
138
+ return null;
139
+ const all = [...head, ...Array(halves.length === 2 ? fill : 0).fill("0"), ...rest];
140
+ const bytes = [];
141
+ for (const g of all) {
142
+ if (!/^[0-9a-fA-F]{1,4}$/.test(g))
143
+ return null;
144
+ const n = Number.parseInt(g, 16);
145
+ bytes.push(n >> 8, n & 0xff);
146
+ }
147
+ if (tail.length === 4)
148
+ bytes.splice(12, 4, ...tail);
149
+ return bytes;
150
+ }
151
+ /** Parse an IP address into bytes; an IPv4-mapped IPv6 address becomes IPv4. */
152
+ function parseIp(raw) {
153
+ const s = raw.trim().replace(/^\[|\]$/g, "");
154
+ if (s.includes(":")) {
155
+ const v6 = parseIpv6(s.split("%")[0] ?? "");
156
+ if (!v6)
157
+ return null;
158
+ const mapped = v6.slice(0, 10).every((b) => b === 0) && v6[10] === 0xff && v6[11] === 0xff;
159
+ return mapped ? v6.slice(12) : v6;
160
+ }
161
+ return parseIpv4(s);
162
+ }
163
+ /** A parsed CIDR, or null when `cidr` is not one. A bare address is a host route. */
164
+ export function parseCidr(cidr) {
165
+ const [addr, len, extra] = cidr.trim().split("/");
166
+ if (addr === undefined || extra !== undefined)
167
+ return null;
168
+ const bytes = parseIp(addr);
169
+ if (!bytes)
170
+ return null;
171
+ const max = bytes.length * 8;
172
+ if (len === undefined)
173
+ return { bytes, bits: max };
174
+ if (!/^\d{1,3}$/.test(len))
175
+ return null;
176
+ const bits = Number(len);
177
+ return bits <= max ? { bytes, bits } : null;
178
+ }
179
+ /** True when `ip` lies inside `cidr` (same address family only). */
180
+ export function ipMatchesCidr(ip, cidr) {
181
+ const addr = parseIp(ip);
182
+ const net = parseCidr(cidr);
183
+ if (!addr || !net || addr.length !== net.bytes.length)
184
+ return false;
185
+ let bits = net.bits;
186
+ for (let i = 0; i < addr.length && bits > 0; i++) {
187
+ const take = Math.min(8, bits);
188
+ const mask = (0xff << (8 - take)) & 0xff;
189
+ if (((addr[i] ?? 0) & mask) !== ((net.bytes[i] ?? 0) & mask))
190
+ return false;
191
+ bits -= take;
192
+ }
193
+ return true;
194
+ }
195
+ // ─── request decision ─────────────────────────────────────────────────
196
+ /** `Authorization: Basic …` → user + password, or null. */
197
+ export function parseBasicAuthorization(header) {
198
+ if (!header)
199
+ return null;
200
+ const m = /^Basic\s+([A-Za-z0-9+/=]+)\s*$/i.exec(header);
201
+ if (!m?.[1])
202
+ return null;
203
+ let decoded;
204
+ try {
205
+ decoded = new TextDecoder().decode(Uint8Array.from(atob(m[1]), (c) => c.charCodeAt(0)));
206
+ }
207
+ catch {
208
+ return null;
209
+ }
210
+ const i = decoded.indexOf(":");
211
+ if (i < 0)
212
+ return null;
213
+ return { user: decoded.slice(0, i), password: decoded.slice(i + 1) };
214
+ }
215
+ /** The value of cookie `name` in a `Cookie` header, or null. */
216
+ export function readCookie(header, name) {
217
+ if (!header)
218
+ return null;
219
+ for (const part of header.split(/;\s*/)) {
220
+ const eq = part.indexOf("=");
221
+ if (eq > 0 && part.slice(0, eq).trim() === name)
222
+ return part.slice(eq + 1).trim();
223
+ }
224
+ return null;
225
+ }
226
+ /**
227
+ * Decide one staging request. Any enabled mode that the request satisfies
228
+ * lets it in; every check runs (no short-circuit on the first secret
229
+ * comparison), so the response time does not reveal which mode failed.
230
+ */
231
+ export async function decideStagingAccess(args) {
232
+ const { settings, secret, request } = args;
233
+ const granted = [];
234
+ if (settings.cookieEnabled) {
235
+ const token = readCookie(request.cookieHeader, STAGING_SESSION_COOKIE);
236
+ if (token && (await verifyStagingToken(secret, token, "session", args.nowSeconds))) {
237
+ granted.push("cookie");
238
+ }
239
+ }
240
+ if (settings.basicAuthEnabled && settings.basicAuthNonce) {
241
+ const presented = parseBasicAuthorization(request.authorizationHeader);
242
+ const expected = await deriveStagingCredential(secret, "basic-auth", settings.basicAuthNonce);
243
+ const userOk = constantTimeEqual(presented?.user ?? "", settings.basicAuthUser);
244
+ const passOk = constantTimeEqual(presented?.password ?? "", expected);
245
+ if (presented && userOk && passOk)
246
+ granted.push("basic-auth");
247
+ }
248
+ if (settings.headerEnabled && settings.headerNonce) {
249
+ const presented = request.header(settings.headerName) ?? "";
250
+ const expected = await deriveStagingCredential(secret, "header", settings.headerNonce);
251
+ if (constantTimeEqual(presented, expected) && presented !== "")
252
+ granted.push("header");
253
+ }
254
+ const ip = request.clientIp;
255
+ if (ip && settings.ipAllowlist.some((c) => ipMatchesCidr(ip, c)))
256
+ granted.push("ip");
257
+ const via = granted[0];
258
+ if (via)
259
+ return { allowed: true, via, satisfied: granted };
260
+ return { allowed: false, status: settings.basicAuthEnabled ? 401 : 403 };
261
+ }
262
+ /**
263
+ * Where "Open staging" may send the browser after setting the cookie: a
264
+ * same-origin path only (never `//host` or a scheme), else `/`.
265
+ *
266
+ * The result is always ASCII (safe as a `Location` header value).
267
+ *
268
+ * Control characters are refused outright, not only CR/LF: the WHATWG URL
269
+ * parser deletes tabs and newlines before it parses a Location, so
270
+ * `/<TAB>/evil.example` would otherwise become `//evil.example` in the
271
+ * browser — an open redirect off the staging host.
272
+ */
273
+ export function safeStagingNextPath(next) {
274
+ if (next === null || !next.startsWith("/") || next.startsWith("//"))
275
+ return "/";
276
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: matching control characters is the point
277
+ if (next.includes("\\") || /[\u0000-\u001f\u007f]/.test(next))
278
+ return "/";
279
+ // Serialise through the URL parser: non-ASCII is percent-encoded (a raw
280
+ // `/日本/` is not a valid header value and would fail the redirect), and
281
+ // the origin check is the parser's own verdict, not ours.
282
+ const base = "https://staging.invalid";
283
+ const url = new URL(next, base);
284
+ if (url.origin !== base)
285
+ return "/";
286
+ return `${url.pathname}${url.search}${url.hash}`;
287
+ }
288
+ // ─── the staging host setting ─────────────────────────────────────────
289
+ /**
290
+ * The staging host an install gets when the operator names none:
291
+ * `staging.<install domain>`.
292
+ */
293
+ export function defaultStagingHost(domain) {
294
+ return `staging.${domain.trim().toLowerCase().replace(/\.$/, "")}`;
295
+ }
296
+ /**
297
+ * Longest host a Cloud Run domain mapping accepts — the gcp-firebase
298
+ * gateway is mapped to the staging host, so a longer one would be stored,
299
+ * rendered into every staged build and then never answer.
300
+ * https://cloud.google.com/run/docs/mapping-custom-domains#limitations
301
+ */
302
+ export const CLOUD_RUN_DOMAIN_MAPPING_MAX_HOST_LENGTH = 64;
303
+ const FQDN = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
304
+ /** Lower-case, trailing-dot-free form of a host the operator typed. */
305
+ export function normalizeStagingHost(host) {
306
+ return host.trim().toLowerCase().replace(/\.$/, "");
307
+ }
308
+ /**
309
+ * Why `host` cannot be the staging host, or null when it can. It must be a
310
+ * bare fully-qualified domain name (no scheme, port or path) and must not be
311
+ * the live site's host or an admin host — staging would otherwise shadow
312
+ * the site or the admin it is served next to. On gcp-firebase (`provider`)
313
+ * it must also fit a Cloud Run domain mapping
314
+ * ({@link CLOUD_RUN_DOMAIN_MAPPING_MAX_HOST_LENGTH} characters).
315
+ *
316
+ * @example
317
+ * stagingHostProblem("preview.example.com", { liveHosts: ["example.com"], adminHosts: ["admin.example.com"] })
318
+ * // → null
319
+ */
320
+ export function stagingHostProblem(host, reserved, opts = {}) {
321
+ const h = normalizeStagingHost(host);
322
+ if (!FQDN.test(h)) {
323
+ return `"${host}" is not a valid host name — use a bare domain such as staging.example.com (no https://, port or path)`;
324
+ }
325
+ if (opts.provider === "gcp-firebase" && h.length > CLOUD_RUN_DOMAIN_MAPPING_MAX_HOST_LENGTH) {
326
+ 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)`;
327
+ }
328
+ const norm = (list) => list.map(normalizeStagingHost).filter(Boolean);
329
+ if (norm(reserved.liveHosts).includes(h)) {
330
+ return `"${h}" is the live site's host — staging needs a host of its own (e.g. staging.${h})`;
331
+ }
332
+ if (norm(reserved.adminHosts).includes(h)) {
333
+ return `"${h}" is the admin's host — pick another host for staging`;
334
+ }
335
+ return null;
336
+ }
337
+ //# sourceMappingURL=staging-gate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staging-gate.js","sourceRoot":"","sources":["../src/staging-gate.ts"],"names":[],"mappings":"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"}
package/dist/version.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
20
20
  * follow standard SemVer.
21
21
  */
22
- export declare const CAELO_VERSION = "0.10.37";
22
+ export declare const CAELO_VERSION = "0.10.39";
23
23
  /**
24
24
  * Deprecated alias for back-compat — early P17 work spelled this
25
25
  * `CALEO_VERSION` (typo of "Caelo"). New code should import
@@ -31,7 +31,7 @@ export declare const CAELO_VERSION = "0.10.37";
31
31
  *
32
32
  * @deprecated use CAELO_VERSION
33
33
  */
34
- export declare const CALEO_VERSION = "0.10.37";
34
+ export declare const CALEO_VERSION = "0.10.39";
35
35
  /**
36
36
  * Parsed shape — major/minor/patch + optional pre-release tag.
37
37
  * Stable interface for callers that need to feature-gate (rare —
package/dist/version.js CHANGED
@@ -20,7 +20,7 @@
20
20
  * Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
21
21
  * follow standard SemVer.
22
22
  */
23
- export const CAELO_VERSION = "0.10.37";
23
+ export const CAELO_VERSION = "0.10.39";
24
24
  /**
25
25
  * Deprecated alias for back-compat — early P17 work spelled this
26
26
  * `CALEO_VERSION` (typo of "Caelo"). New code should import
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@caelo-cms/shared",
3
- "version": "0.10.37",
3
+ "version": "0.10.39",
4
4
  "private": false,
5
5
  "license": "MPL-2.0",
6
6
  "description": "Shared Zod schemas + types + the two-pass HTML composer used by every Caelo CMS workspace package. Stable enough for plugin authors and embedders to depend on directly.",
package/src/ai-tools.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  slugSchema,
26
26
  } from "./content.js";
27
27
  import { pluginPreviewSelectionSchema } from "./plugin-preview.js";
28
+ import { redirectTargetPathSchema } from "./redirect-target.js";
28
29
 
29
30
  /**
30
31
  * v0.6.2 — `position` argument shared across the three `add_module_to_*`
@@ -905,7 +906,7 @@ export const bulkCreateRedirectsToolInput = z
905
906
  z
906
907
  .object({
907
908
  fromPath: z.string().min(1).max(500),
908
- toPath: z.string().min(1).max(500),
909
+ toPath: redirectTargetPathSchema,
909
910
  statusCode: z
910
911
  .union([
911
912
  z.literal(301),
package/src/index.ts CHANGED
@@ -39,12 +39,14 @@ export * from "./plugin-preview.js";
39
39
  export * from "./preview-compose.js";
40
40
  export * from "./preview-scanner.js";
41
41
  export * from "./proposal-status.js";
42
+ export * from "./redirect-target.js";
42
43
  export * from "./responsive-images.js";
43
44
  export * from "./result.js";
44
45
  export * from "./safe-keys.js";
45
46
  export * from "./seo.js";
46
47
  export * from "./skills.js";
47
48
  export * from "./snapshots.js";
49
+ export * from "./staging-gate.js";
48
50
  export * from "./static-cache-policy.js";
49
51
  export * from "./strip-cdata.js";
50
52
  export * from "./structured-sets.js";
@@ -0,0 +1,91 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import { redirectTargetIssue, redirectTargetPathSchema } from "./redirect-target.js";
5
+
6
+ describe("redirectTargetIssue (#657)", () => {
7
+ it.each([
8
+ "/",
9
+ "/a/b",
10
+ "/a/b?c=d#e",
11
+ "/blog/2024/hello-world",
12
+ "/de/preise",
13
+ "/files/report.pdf",
14
+ "/caf%C3%A9",
15
+ "/über-uns",
16
+ "/a//b",
17
+ "/wiki/Special:Search",
18
+ "/foo:bar",
19
+ "/a?next=https://example.com",
20
+ ])("accepts the same-site path %p", (path) => {
21
+ expect(redirectTargetIssue(path)).toBeNull();
22
+ });
23
+
24
+ it("rejects a path without a leading slash and says external targets are unsupported", () => {
25
+ const issue = redirectTargetIssue("https://example.com/x");
26
+ expect(issue).toContain("must start with '/'");
27
+ expect(issue).toContain("external (other-domain) redirect targets are not supported");
28
+ expect(redirectTargetIssue("pricing")).toContain("must start with '/'");
29
+ });
30
+
31
+ it.each(["//evil.example/x", "//evil.example", "///evil.example/x"])(
32
+ "rejects the protocol-relative %p and suggests a single-slash path",
33
+ (path) => {
34
+ const issue = redirectTargetIssue(path);
35
+ expect(issue).toContain("protocol-relative");
36
+ expect(issue).toContain("'/evil.example");
37
+ },
38
+ );
39
+
40
+ it("rejects backslashes, which browsers normalise to '/' (\\/\\host == //host)", () => {
41
+ expect(redirectTargetIssue("/\\evil.example/x")).toContain("backslash");
42
+ });
43
+
44
+ it.each(["/\t/evil.example", "/x\ny", "/a b", "/a\u0000b"])(
45
+ "rejects whitespace/control characters in %p",
46
+ (path) => {
47
+ expect(redirectTargetIssue(path)).toContain("whitespace or control characters");
48
+ },
49
+ );
50
+
51
+ it.each([
52
+ ["/https://example.com/x", "/x"],
53
+ ["/http://example.com/a/b?c=d#e", "/a/b?c=d#e"],
54
+ ["/HTTPS://Example.com/", "/"],
55
+ ])("rejects the embedded URL %p and suggests its path %p", (path, suggested) => {
56
+ const issue = redirectTargetIssue(path);
57
+ expect(issue).toContain("embeds a full URL");
58
+ expect(issue).toContain(`use its path '${suggested}'`);
59
+ expect(issue).toContain("external (other-domain) redirect targets are not supported");
60
+ });
61
+
62
+ it.each(["/https:/example.com/x", "/https:example.com", "/http:"])(
63
+ "rejects a mangled web-scheme first segment %p without a misleading suggestion",
64
+ (path) => {
65
+ const issue = redirectTargetIssue(path);
66
+ expect(issue).toContain("embeds a full URL");
67
+ expect(issue).not.toContain("use its path");
68
+ },
69
+ );
70
+
71
+ it.each(["/ftp://files.example/x", "/javascript://x", "/custom+scheme://x"])(
72
+ "rejects any scheme followed by '//' (%p)",
73
+ (path) => {
74
+ expect(redirectTargetIssue(path)).toContain("embeds a full URL");
75
+ },
76
+ );
77
+ });
78
+
79
+ describe("redirectTargetPathSchema", () => {
80
+ it("parses a valid target and carries the actionable message on failure", () => {
81
+ expect(redirectTargetPathSchema.parse("/a/b?c=d#e")).toBe("/a/b?c=d#e");
82
+ const r = redirectTargetPathSchema.safeParse("/https://example.com/x");
83
+ expect(r.success).toBe(false);
84
+ expect(r.error?.issues[0]?.message).toContain("use its path '/x'");
85
+ });
86
+
87
+ it("keeps the length bounds", () => {
88
+ expect(redirectTargetPathSchema.safeParse("").success).toBe(false);
89
+ expect(redirectTargetPathSchema.safeParse(`/${"a".repeat(500)}`).success).toBe(false);
90
+ });
91
+ });
@@ -0,0 +1,114 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * The one validator for a redirect's TARGET (`toPath`) — issue #657.
5
+ *
6
+ * Caelo redirects only ever point at a path on the same site: the
7
+ * `redirects` table has no external-URL field, and every surface that
8
+ * serves the table (the admin's 404 fallback, the generator's
9
+ * `_redirects` / `_redirects.caddy`, the per-provider emitters) writes
10
+ * `toPath` verbatim into a `Location` header. "Starts with `/`" is not
11
+ * enough for that to stay on-site:
12
+ *
13
+ * - `//host/x` (and `/\host/x`, which browsers normalise to `//`) is a
14
+ * protocol-relative URL — the browser leaves the site for `host`.
15
+ * That turns any redirect into an open redirect.
16
+ * - `/https://example.com/x` is a same-site path whose first segment is
17
+ * `https:`; the browser lands on `https://<site>/https://example.com/x`,
18
+ * which is never what the author meant (they pasted a full URL).
19
+ * - Whitespace / control characters: browsers strip tab/CR/LF inside a
20
+ * URL, so `/\t/host` collapses to `//host`; the `_redirects` formats
21
+ * are whitespace-delimited and the emitters already refuse them.
22
+ *
23
+ * Every write path (redirects.create / create_many, page delete with
24
+ * `disposition: 'redirect'`, the AI tool schemas, the import composer)
25
+ * and every serving path (static generator, admin 404 fallback) calls
26
+ * this, so a value rejected at write time can never be served either.
27
+ */
28
+
29
+ import { z } from "zod";
30
+
31
+ /** Max length of a stored redirect path (matches the op schemas). */
32
+ const REDIRECT_PATH_MAX_LENGTH = 500;
33
+
34
+ /** A URL scheme token (RFC 3986 §3.1) followed by `:`. */
35
+ const SCHEME_PREFIX = /^([A-Za-z][A-Za-z0-9+.-]*):/;
36
+
37
+ /** Schemes that are never a meaningful first path segment. A pasted
38
+ * `/https:/x` (slashes collapsed by a form or a CSV tool) is the same
39
+ * mistake as `/https://x`, so these are rejected even without `//`. */
40
+ const WEB_SCHEMES = new Set(["http", "https"]);
41
+
42
+ const EXTERNAL_NOT_SUPPORTED =
43
+ "Redirects can only target a path on this site (e.g. '/pricing'); external (other-domain) redirect targets are not supported. If the destination is a page on this site, pass only its path.";
44
+
45
+ /**
46
+ * Return why `toPath` is not a safe same-site redirect target, or `null`
47
+ * when it is. The message is written for the AI: it names the problem
48
+ * and what to pass instead.
49
+ *
50
+ * @example
51
+ * redirectTargetIssue("/blog/new?x=1#top") // null
52
+ * redirectTargetIssue("//evil.example/x") // "... protocol-relative ..."
53
+ * redirectTargetIssue("/https://a.example/x") // "... use '/x' ..."
54
+ */
55
+ export function redirectTargetIssue(toPath: string): string | null {
56
+ const shown = JSON.stringify(toPath);
57
+ if (!toPath.startsWith("/")) {
58
+ return `toPath ${shown} must start with '/'. ${EXTERNAL_NOT_SUPPORTED}`;
59
+ }
60
+ if (/[\s\p{Cc}]/u.test(toPath)) {
61
+ return `toPath ${shown} contains whitespace or control characters. Percent-encode them (a space is '%20') or remove them.`;
62
+ }
63
+ if (toPath.includes("\\")) {
64
+ return `toPath ${shown} contains a backslash; browsers treat '\\' as '/', so it can turn into a link to another host. Use forward slashes only.`;
65
+ }
66
+ if (toPath.startsWith("//")) {
67
+ return `toPath ${shown} starts with '//', which browsers read as a protocol-relative URL to another host (an open redirect), not as a path on this site. Use a single leading slash, e.g. '/${trimLeadingSlashes(toPath)}'. ${EXTERNAL_NOT_SUPPORTED}`;
68
+ }
69
+ const scheme = SCHEME_PREFIX.exec(toPath.slice(1));
70
+ if (scheme) {
71
+ const rest = toPath.slice(1 + scheme[0].length);
72
+ const name = (scheme[1] ?? "").toLowerCase();
73
+ if (rest.startsWith("/") || WEB_SCHEMES.has(name)) {
74
+ const suggestion = pathOfEmbeddedUrl(toPath.slice(1));
75
+ const hint = suggestion ? ` If that URL is on this site, use its path '${suggestion}'.` : "";
76
+ return `toPath ${shown} embeds a full URL ('${scheme[1]}:…') after the leading '/'; browsers resolve it as a path on this site, not as that URL.${hint} ${EXTERNAL_NOT_SUPPORTED}`;
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Zod schema for a redirect target. Use this — not a bare
84
+ * `z.string().regex(/^\//)` — wherever a redirect target enters the
85
+ * system, so the rule above holds on every path.
86
+ */
87
+ export const redirectTargetPathSchema = z
88
+ .string()
89
+ .min(1)
90
+ .max(REDIRECT_PATH_MAX_LENGTH)
91
+ .superRefine((value, ctx) => {
92
+ const issue = redirectTargetIssue(value);
93
+ if (issue) ctx.addIssue({ code: "custom", message: issue });
94
+ });
95
+
96
+ function trimLeadingSlashes(s: string): string {
97
+ let i = 0;
98
+ while (i < s.length && s.charCodeAt(i) === 47 /* '/' */) i += 1;
99
+ return s.slice(i);
100
+ }
101
+
102
+ /** `https://a.example/x?y#z` → `/x?y#z`; null when unparseable. */
103
+ function pathOfEmbeddedUrl(url: string): string | null {
104
+ // Only a well-formed `scheme://host` URL has a path worth suggesting;
105
+ // `https:/x` would parse as host `x` and suggest a misleading '/'.
106
+ if (!/^https?:\/\/[^/]/i.test(url)) return null;
107
+ try {
108
+ const u = new URL(url);
109
+ if (u.protocol !== "http:" && u.protocol !== "https:") return null;
110
+ return `${u.pathname}${u.search}${u.hash}`;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }