@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,323 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ constantTimeEqual,
6
+ DEFAULT_STAGING_HEADER_NAME,
7
+ decideStagingAccess,
8
+ defaultStagingHost,
9
+ deriveStagingCredential,
10
+ ipMatchesCidr,
11
+ newStagingCredentialNonce,
12
+ normalizeStagingHost,
13
+ parseBasicAuthorization,
14
+ parseCidr,
15
+ readCookie,
16
+ STAGING_SESSION_COOKIE,
17
+ type StagingAccessRequest,
18
+ type StagingAccessSettings,
19
+ safeStagingNextPath,
20
+ signStagingToken,
21
+ stagingHostProblem,
22
+ verifyStagingToken,
23
+ } from "./staging-gate.js";
24
+
25
+ const SECRET = "test-gate-secret-0123456789abcdef";
26
+ const NOW = 1_800_000_000;
27
+
28
+ const OFF: StagingAccessSettings = {
29
+ cookieEnabled: false,
30
+ basicAuthEnabled: false,
31
+ basicAuthUser: "crawler",
32
+ basicAuthNonce: "nonce-basic",
33
+ headerEnabled: false,
34
+ headerName: DEFAULT_STAGING_HEADER_NAME,
35
+ headerNonce: "nonce-header",
36
+ ipAllowlist: [],
37
+ };
38
+
39
+ function req(over: Partial<StagingAccessRequest> & { headers?: Record<string, string> } = {}) {
40
+ const headers = Object.fromEntries(
41
+ Object.entries(over.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v]),
42
+ );
43
+ return {
44
+ cookieHeader: over.cookieHeader ?? null,
45
+ authorizationHeader: over.authorizationHeader ?? null,
46
+ header: (name: string) => headers[name.toLowerCase()] ?? null,
47
+ clientIp: over.clientIp ?? null,
48
+ } satisfies StagingAccessRequest;
49
+ }
50
+
51
+ function basic(user: string, password: string): string {
52
+ return `Basic ${btoa(`${user}:${password}`)}`;
53
+ }
54
+
55
+ describe("constantTimeEqual", () => {
56
+ it("compares equal and unequal strings of any length", () => {
57
+ expect(constantTimeEqual("abc", "abc")).toBe(true);
58
+ expect(constantTimeEqual("abc", "abd")).toBe(false);
59
+ expect(constantTimeEqual("abc", "abcd")).toBe(false);
60
+ expect(constantTimeEqual("", "")).toBe(true);
61
+ expect(constantTimeEqual("", "a")).toBe(false);
62
+ });
63
+ });
64
+
65
+ describe("deriveStagingCredential", () => {
66
+ it("is deterministic per (secret, kind, nonce) and 32 URL-safe chars", async () => {
67
+ const a = await deriveStagingCredential(SECRET, "basic-auth", "n1");
68
+ expect(a).toBe(await deriveStagingCredential(SECRET, "basic-auth", "n1"));
69
+ expect(a).toMatch(/^[A-Za-z0-9_-]{32}$/);
70
+ });
71
+
72
+ it("changes with the nonce (rotation), the kind and the secret", async () => {
73
+ const a = await deriveStagingCredential(SECRET, "basic-auth", "n1");
74
+ expect(await deriveStagingCredential(SECRET, "basic-auth", "n2")).not.toBe(a);
75
+ expect(await deriveStagingCredential(SECRET, "header", "n1")).not.toBe(a);
76
+ expect(await deriveStagingCredential("other-secret", "basic-auth", "n1")).not.toBe(a);
77
+ });
78
+
79
+ it("new nonces are random", () => {
80
+ expect(newStagingCredentialNonce()).not.toBe(newStagingCredentialNonce());
81
+ });
82
+ });
83
+
84
+ describe("staging tokens", () => {
85
+ it("verifies a valid token for its purpose only", async () => {
86
+ const t = await signStagingToken(SECRET, "open", NOW + 60);
87
+ expect(await verifyStagingToken(SECRET, t, "open", NOW)).toBe(true);
88
+ expect(await verifyStagingToken(SECRET, t, "session", NOW)).toBe(false);
89
+ });
90
+
91
+ it("rejects expired, forged, tampered and malformed tokens", async () => {
92
+ const t = await signStagingToken(SECRET, "session", NOW + 60);
93
+ expect(await verifyStagingToken(SECRET, t, "session", NOW + 60)).toBe(false);
94
+ expect(await verifyStagingToken("wrong", t, "session", NOW)).toBe(false);
95
+ const [p, , mac] = t.split(".");
96
+ expect(await verifyStagingToken(SECRET, `${p}.${NOW + 9999}.${mac}`, "session", NOW)).toBe(
97
+ false,
98
+ );
99
+ expect(await verifyStagingToken(SECRET, "session.abc.def", "session", NOW)).toBe(false);
100
+ expect(await verifyStagingToken(SECRET, "", "session", NOW)).toBe(false);
101
+ // A session token presented as the other purpose's link.
102
+ const forged = t.replace(/^session/, "open");
103
+ expect(await verifyStagingToken(SECRET, forged, "open", NOW)).toBe(false);
104
+ });
105
+ });
106
+
107
+ describe("CIDR matching", () => {
108
+ it("matches IPv4 ranges and host routes", () => {
109
+ expect(ipMatchesCidr("203.0.113.7", "203.0.113.0/24")).toBe(true);
110
+ expect(ipMatchesCidr("203.0.114.7", "203.0.113.0/24")).toBe(false);
111
+ expect(ipMatchesCidr("203.0.113.7", "203.0.113.7")).toBe(true);
112
+ expect(ipMatchesCidr("203.0.113.8", "203.0.113.7/32")).toBe(false);
113
+ expect(ipMatchesCidr("10.1.2.3", "0.0.0.0/0")).toBe(true);
114
+ expect(ipMatchesCidr("10.1.2.3", "10.0.0.0/9")).toBe(true);
115
+ expect(ipMatchesCidr("10.128.2.3", "10.0.0.0/9")).toBe(false);
116
+ });
117
+
118
+ it("matches IPv6 ranges, compressed forms and IPv4-mapped addresses", () => {
119
+ expect(ipMatchesCidr("2001:db8::1", "2001:db8::/32")).toBe(true);
120
+ expect(ipMatchesCidr("2001:db9::1", "2001:db8::/32")).toBe(false);
121
+ expect(ipMatchesCidr("2001:0db8:0000:0000:0000:0000:0000:0001", "2001:db8::1/128")).toBe(true);
122
+ expect(ipMatchesCidr("::ffff:203.0.113.7", "203.0.113.0/24")).toBe(true);
123
+ expect(ipMatchesCidr("[2001:db8::1]", "2001:db8::/48")).toBe(true);
124
+ });
125
+
126
+ it("never matches across families or on garbage", () => {
127
+ expect(ipMatchesCidr("203.0.113.7", "2001:db8::/32")).toBe(false);
128
+ expect(ipMatchesCidr("not-an-ip", "0.0.0.0/0")).toBe(false);
129
+ expect(ipMatchesCidr("1.2.3.4", "1.2.3.4/33")).toBe(false);
130
+ expect(ipMatchesCidr("1.2.3.256", "0.0.0.0/0")).toBe(false);
131
+ });
132
+
133
+ it("parseCidr validates", () => {
134
+ expect(parseCidr("203.0.113.0/24")).not.toBeNull();
135
+ expect(parseCidr("2001:db8::/129")).toBeNull();
136
+ expect(parseCidr("1.2.3/8")).toBeNull();
137
+ expect(parseCidr("1.2.3.4/8/1")).toBeNull();
138
+ expect(parseCidr("1:2:3:4:5:6:7:8:9")).toBeNull();
139
+ });
140
+ });
141
+
142
+ describe("header parsing", () => {
143
+ it("parses basic authorization, including colons in the password", () => {
144
+ expect(parseBasicAuthorization(basic("u", "p:w"))).toEqual({ user: "u", password: "p:w" });
145
+ expect(parseBasicAuthorization("Bearer x")).toBeNull();
146
+ expect(parseBasicAuthorization("Basic !!!")).toBeNull();
147
+ expect(parseBasicAuthorization(`Basic ${btoa("nocolon")}`)).toBeNull();
148
+ expect(parseBasicAuthorization(null)).toBeNull();
149
+ });
150
+
151
+ it("reads one cookie", () => {
152
+ expect(readCookie("a=1; caelo_staging_access=tok; b=2", STAGING_SESSION_COOKIE)).toBe("tok");
153
+ expect(readCookie("a=1", STAGING_SESSION_COOKIE)).toBeNull();
154
+ });
155
+ });
156
+
157
+ describe("decideStagingAccess", () => {
158
+ const decide = (settings: StagingAccessSettings, request: StagingAccessRequest) =>
159
+ decideStagingAccess({ settings, secret: SECRET, request, nowSeconds: NOW });
160
+
161
+ it("denies with 403 when nothing is enabled", async () => {
162
+ expect(await decide(OFF, req())).toEqual({ allowed: false, status: 403 });
163
+ });
164
+
165
+ it("cookie mode: a valid session cookie gets in, an expired or open token does not", async () => {
166
+ const s = { ...OFF, cookieEnabled: true };
167
+ const session = await signStagingToken(SECRET, "session", NOW + 100);
168
+ expect(await decide(s, req({ cookieHeader: `${STAGING_SESSION_COOKIE}=${session}` }))).toEqual({
169
+ allowed: true,
170
+ via: "cookie",
171
+ satisfied: ["cookie"],
172
+ });
173
+ const expired = await signStagingToken(SECRET, "session", NOW - 1);
174
+ expect(
175
+ (await decide(s, req({ cookieHeader: `${STAGING_SESSION_COOKIE}=${expired}` }))).allowed,
176
+ ).toBe(false);
177
+ const open = await signStagingToken(SECRET, "open", NOW + 100);
178
+ expect(
179
+ (await decide(s, req({ cookieHeader: `${STAGING_SESSION_COOKIE}=${open}` }))).allowed,
180
+ ).toBe(false);
181
+ // Cookie mode off: the same valid cookie is ignored.
182
+ expect(
183
+ (await decide(OFF, req({ cookieHeader: `${STAGING_SESSION_COOKIE}=${session}` }))).allowed,
184
+ ).toBe(false);
185
+ });
186
+
187
+ it("basic-auth mode: right user + derived password only; 401 otherwise", async () => {
188
+ const s = { ...OFF, basicAuthEnabled: true };
189
+ const pw = await deriveStagingCredential(SECRET, "basic-auth", "nonce-basic");
190
+ expect(await decide(s, req({ authorizationHeader: basic("crawler", pw) }))).toEqual({
191
+ allowed: true,
192
+ via: "basic-auth",
193
+ satisfied: ["basic-auth"],
194
+ });
195
+ expect(await decide(s, req({ authorizationHeader: basic("other", pw) }))).toEqual({
196
+ allowed: false,
197
+ status: 401,
198
+ });
199
+ expect(await decide(s, req({ authorizationHeader: basic("crawler", `${pw}x`) }))).toEqual({
200
+ allowed: false,
201
+ status: 401,
202
+ });
203
+ // A password derived for the header kind is not a basic-auth password.
204
+ const headerValue = await deriveStagingCredential(SECRET, "basic-auth", "nonce-header");
205
+ expect(
206
+ (await decide(s, req({ authorizationHeader: basic("crawler", headerValue) }))).allowed,
207
+ ).toBe(false);
208
+ expect(await decide(s, req())).toEqual({ allowed: false, status: 401 });
209
+ });
210
+
211
+ it("basic-auth mode without a generated credential lets nobody in", async () => {
212
+ const s = { ...OFF, basicAuthEnabled: true, basicAuthNonce: null };
213
+ expect(await decide(s, req({ authorizationHeader: basic("crawler", "") }))).toEqual({
214
+ allowed: false,
215
+ status: 401,
216
+ });
217
+ });
218
+
219
+ it("header mode: the derived value under the configured name only", async () => {
220
+ const s = { ...OFF, headerEnabled: true };
221
+ const value = await deriveStagingCredential(SECRET, "header", "nonce-header");
222
+ expect(await decide(s, req({ headers: { "x-caelo-staging-key": value } }))).toEqual({
223
+ allowed: true,
224
+ via: "header",
225
+ satisfied: ["header"],
226
+ });
227
+ expect((await decide(s, req({ headers: { "x-other": value } }))).allowed).toBe(false);
228
+ expect((await decide(s, req({ headers: { "x-caelo-staging-key": "" } }))).allowed).toBe(false);
229
+ expect(
230
+ (await decide(s, req({ headers: { "x-caelo-staging-key": `${value}0` } }))).allowed,
231
+ ).toBe(false);
232
+ });
233
+
234
+ it("IP mode: an allowlisted client address only", async () => {
235
+ const s = { ...OFF, ipAllowlist: ["198.51.100.0/24", "2001:db8::/48"] };
236
+ expect(await decide(s, req({ clientIp: "198.51.100.20" }))).toEqual({
237
+ allowed: true,
238
+ via: "ip",
239
+ satisfied: ["ip"],
240
+ });
241
+ expect(await decide(s, req({ clientIp: "2001:db8:0:1::5" }))).toEqual({
242
+ allowed: true,
243
+ via: "ip",
244
+ satisfied: ["ip"],
245
+ });
246
+ expect((await decide(s, req({ clientIp: "198.51.101.20" }))).allowed).toBe(false);
247
+ expect((await decide(s, req({ clientIp: null }))).allowed).toBe(false);
248
+ });
249
+
250
+ it("reports every satisfied mode, so the proxy strips only credentials that authenticated", async () => {
251
+ const s = { ...OFF, cookieEnabled: true, headerEnabled: true, basicAuthEnabled: true };
252
+ const session = await signStagingToken(SECRET, "session", NOW + 100);
253
+ const value = await deriveStagingCredential(SECRET, "header", "nonce-header");
254
+ expect(
255
+ await decide(
256
+ s,
257
+ req({
258
+ cookieHeader: `${STAGING_SESSION_COOKIE}=${session}`,
259
+ headers: { "x-caelo-staging-key": value },
260
+ authorizationHeader: basic("crawler", "the-sites-own-password"),
261
+ }),
262
+ ),
263
+ ).toEqual({ allowed: true, via: "cookie", satisfied: ["cookie", "header"] });
264
+ });
265
+ });
266
+
267
+ describe("safeStagingNextPath", () => {
268
+ it("keeps same-origin paths and refuses everything else", () => {
269
+ expect(safeStagingNextPath("/about/?x=1")).toBe("/about/?x=1");
270
+ expect(safeStagingNextPath("//evil.example")).toBe("/");
271
+ expect(safeStagingNextPath("https://evil.example")).toBe("/");
272
+ expect(safeStagingNextPath("/\\evil")).toBe("/");
273
+ expect(safeStagingNextPath("/a\r\nSet-Cookie: x")).toBe("/");
274
+ // Tabs/newlines vanish in the browser's URL parser: `/\t/evil` → `//evil`.
275
+ expect(safeStagingNextPath("/\t/evil.example")).toBe("/");
276
+ expect(safeStagingNextPath("/\n/evil.example")).toBe("/");
277
+ expect(safeStagingNextPath("/a\u007fb")).toBe("/");
278
+ // Non-ASCII paths come back percent-encoded: a raw one is not a valid
279
+ // Location header value (the redirect would fail with a 500).
280
+ expect(safeStagingNextPath("/日本/?q=ü#x")).toBe("/%E6%97%A5%E6%9C%AC/?q=%C3%BC#x");
281
+ expect(safeStagingNextPath(null)).toBe("/");
282
+ });
283
+ });
284
+
285
+ describe("staging host setting", () => {
286
+ const reserved = {
287
+ liveHosts: ["example.com", "www.example.com"],
288
+ adminHosts: ["admin.example.com"],
289
+ };
290
+ it("defaults to staging.<domain>", () => {
291
+ expect(defaultStagingHost("Example.com.")).toBe("staging.example.com");
292
+ });
293
+ it("accepts any other FQDN, also on another domain", () => {
294
+ expect(stagingHostProblem("preview.example.com", reserved)).toBeNull();
295
+ expect(stagingHostProblem("Staging.Other-Domain.de.", reserved)).toBeNull();
296
+ expect(normalizeStagingHost(" Staging.Example.com. ")).toBe("staging.example.com");
297
+ });
298
+ it("refuses non-hosts, the live host and the admin host", () => {
299
+ for (const bad of [
300
+ "https://staging.example.com",
301
+ "staging",
302
+ "staging.example.com:8443",
303
+ "a/b.com",
304
+ "-x.example.com",
305
+ "",
306
+ ]) {
307
+ expect(stagingHostProblem(bad, reserved)).toContain("not a valid host name");
308
+ }
309
+ expect(stagingHostProblem("WWW.example.com", reserved)).toContain("live site");
310
+ expect(stagingHostProblem("admin.example.com", reserved)).toContain("admin");
311
+ });
312
+ it("on gcp-firebase refuses hosts a Cloud Run domain mapping cannot take (> 64 chars)", () => {
313
+ const long = `staging.${"a".repeat(50)}.example.com`; // 70 characters
314
+ expect(stagingHostProblem(long, reserved)).toBeNull();
315
+ expect(stagingHostProblem(long, reserved, { provider: "self-hosted" })).toBeNull();
316
+ const problem = stagingHostProblem(long, reserved, { provider: "gcp-firebase" });
317
+ expect(problem).toContain("at most 64");
318
+ expect(problem).toContain("pick a shorter host");
319
+ const fits = `s.${"a".repeat(50)}.example.com`; // 64 characters
320
+ expect(fits.length).toBe(64);
321
+ expect(stagingHostProblem(fits, reserved, { provider: "gcp-firebase" })).toBeNull();
322
+ });
323
+ });