@decocms/tanstack 7.20.9 → 7.21.0
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 +4 -4
- package/src/sdk/workerEntry.test.ts +119 -2
- package/src/sdk/workerEntry.ts +89 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.21.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
"lint:unused": "knip"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@decocms/blocks": "7.
|
|
28
|
-
"@decocms/blocks-admin": "7.
|
|
29
|
-
"@decocms/blocks-cli": "7.
|
|
27
|
+
"@decocms/blocks": "7.21.0",
|
|
28
|
+
"@decocms/blocks-admin": "7.21.0",
|
|
29
|
+
"@decocms/blocks-cli": "7.21.0",
|
|
30
30
|
"@deco-cx/warp-node": "^0.3.16",
|
|
31
31
|
"fast-json-patch": "^3.1.0",
|
|
32
32
|
"ws": "^8.18.0"
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { setBlocks } from "@decocms/blocks/cms";
|
|
2
2
|
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildGeoCacheParam,
|
|
5
|
+
createDecoWorkerEntry,
|
|
6
|
+
DECO_ADMIN_FRAME_ANCESTORS,
|
|
7
|
+
DEFAULT_FRAME_ANCESTORS_CSP,
|
|
8
|
+
DEFAULT_SECURITY_HEADERS,
|
|
9
|
+
detectLocationMatcher,
|
|
10
|
+
injectGeoCookies,
|
|
11
|
+
} from "./workerEntry";
|
|
4
12
|
import { __resetKvHydrationStateForTests } from "./kvHydration";
|
|
5
13
|
|
|
6
14
|
const EMPTY_ENV = {};
|
|
7
|
-
const MOCK_CTX = {
|
|
15
|
+
const MOCK_CTX = {
|
|
16
|
+
waitUntil: (_p: Promise<unknown>) => {},
|
|
17
|
+
passThroughOnException: () => {},
|
|
18
|
+
};
|
|
8
19
|
const MOCK_SERVER_ENTRY = {
|
|
9
20
|
fetch: async (_req: Request) => new Response("page content", { status: 200 }),
|
|
10
21
|
};
|
|
@@ -313,3 +324,109 @@ describe("CMS redirects", () => {
|
|
|
313
324
|
expect(res2.headers.get("Location")).toBe("/v2");
|
|
314
325
|
});
|
|
315
326
|
});
|
|
327
|
+
|
|
328
|
+
describe("security headers — frame-ancestors default", () => {
|
|
329
|
+
afterEach(() => {
|
|
330
|
+
__resetKvHydrationStateForTests();
|
|
331
|
+
setBlocks({});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const HTML_SERVER_ENTRY = {
|
|
335
|
+
fetch: async (_req: Request) =>
|
|
336
|
+
new Response("<html>hi</html>", {
|
|
337
|
+
status: 200,
|
|
338
|
+
headers: { "content-type": "text/html; charset=utf-8" },
|
|
339
|
+
}),
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
it("does NOT ship X-Frame-Options (frame-ancestors supersedes it)", () => {
|
|
343
|
+
expect(DEFAULT_SECURITY_HEADERS["X-Frame-Options"]).toBeUndefined();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("allows the deco studio to embed the storefront by default", () => {
|
|
347
|
+
expect(DEFAULT_FRAME_ANCESTORS_CSP).toContain("frame-ancestors");
|
|
348
|
+
expect(DECO_ADMIN_FRAME_ANCESTORS).toContain("https://studio.decocms.com");
|
|
349
|
+
expect(DECO_ADMIN_FRAME_ANCESTORS).toContain("https://*.deco.studio");
|
|
350
|
+
expect(DECO_ADMIN_FRAME_ANCESTORS).toContain("'self'");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("applies the default frame-ancestors CSP on HTML responses and drops X-Frame-Options", async () => {
|
|
354
|
+
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY);
|
|
355
|
+
const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
|
|
356
|
+
expect(res.headers.get("Content-Security-Policy")).toBe(DEFAULT_FRAME_ANCESTORS_CSP);
|
|
357
|
+
expect(res.headers.get("X-Frame-Options")).toBeNull();
|
|
358
|
+
// Other defaults are still present.
|
|
359
|
+
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("lets a site override the frame-ancestors CSP via securityHeaders", async () => {
|
|
363
|
+
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
364
|
+
securityHeaders: { "Content-Security-Policy": "frame-ancestors 'none'" },
|
|
365
|
+
});
|
|
366
|
+
const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
|
|
367
|
+
expect(res.headers.get("Content-Security-Policy")).toBe("frame-ancestors 'none'");
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("emits the full site CSP report-only alongside the enforced frame-ancestors", async () => {
|
|
371
|
+
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
|
|
372
|
+
csp: ["default-src 'self'", "img-src 'self' https:"],
|
|
373
|
+
});
|
|
374
|
+
const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
|
|
375
|
+
expect(res.headers.get("Content-Security-Policy")).toBe(DEFAULT_FRAME_ANCESTORS_CSP);
|
|
376
|
+
expect(res.headers.get("Content-Security-Policy-Report-Only")).toBe(
|
|
377
|
+
"default-src 'self'; img-src 'self' https:",
|
|
378
|
+
);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("omits all security headers when securityHeaders is false", async () => {
|
|
382
|
+
const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, { securityHeaders: false });
|
|
383
|
+
const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
|
|
384
|
+
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
|
385
|
+
expect(res.headers.get("X-Content-Type-Options")).toBeNull();
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
describe("POST /_cache/purge-loaders", () => {
|
|
390
|
+
afterEach(() => {
|
|
391
|
+
__resetKvHydrationStateForTests();
|
|
392
|
+
setBlocks({});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
const purge = (env: Record<string, unknown>, headers: Record<string, string> = {}) => {
|
|
396
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
|
|
397
|
+
return worker.fetch(
|
|
398
|
+
new Request("https://example.com/_cache/purge-loaders", { method: "POST", headers }),
|
|
399
|
+
env,
|
|
400
|
+
MOCK_CTX,
|
|
401
|
+
);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
it("401s without a valid Bearer token", async () => {
|
|
405
|
+
const res = await purge({ PURGE_TOKEN: "secret" });
|
|
406
|
+
expect(res.status).toBe(401);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it("401s with the wrong token", async () => {
|
|
410
|
+
const res = await purge({ PURGE_TOKEN: "secret" }, { Authorization: "Bearer nope" });
|
|
411
|
+
expect(res.status).toBe(401);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it("clears the loader cache with a valid token", async () => {
|
|
415
|
+
const res = await purge({ PURGE_TOKEN: "secret" }, { Authorization: "Bearer secret" });
|
|
416
|
+
expect(res.status).toBe(200);
|
|
417
|
+
expect(await res.json()).toEqual({ cleared: true, scope: "isolate" });
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("404s when purge is disabled (purgeTokenEnv: false)", async () => {
|
|
421
|
+
const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
|
|
422
|
+
observability: false,
|
|
423
|
+
purgeTokenEnv: false,
|
|
424
|
+
});
|
|
425
|
+
const res = await worker.fetch(
|
|
426
|
+
new Request("https://example.com/_cache/purge-loaders", { method: "POST" }),
|
|
427
|
+
EMPTY_ENV,
|
|
428
|
+
MOCK_CTX,
|
|
429
|
+
);
|
|
430
|
+
expect(res.status).toBe(404);
|
|
431
|
+
});
|
|
432
|
+
});
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
runSingleSectionLoader,
|
|
39
39
|
} from "@decocms/blocks/cms";
|
|
40
40
|
import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
|
|
41
|
+
import { clearLoaderCache } from "@decocms/blocks/sdk/cachedLoader";
|
|
41
42
|
import { loadRedirects, matchRedirect, type RedirectMap } from "@decocms/blocks/sdk/redirects";
|
|
42
43
|
import {
|
|
43
44
|
type CacheProfileName,
|
|
@@ -331,10 +332,13 @@ export interface DecoWorkerEntryOptions {
|
|
|
331
332
|
* Security headers appended to every SSR response (HTML pages).
|
|
332
333
|
* Pass `false` to disable entirely.
|
|
333
334
|
*
|
|
334
|
-
* Default headers: X-Content-Type-Options,
|
|
335
|
-
* Permissions-Policy, X-XSS-Protection, HSTS, Cross-Origin-Opener-Policy
|
|
335
|
+
* Default headers: X-Content-Type-Options, Referrer-Policy,
|
|
336
|
+
* Permissions-Policy, X-XSS-Protection, HSTS, Cross-Origin-Opener-Policy,
|
|
337
|
+
* and a `Content-Security-Policy: frame-ancestors` directive that lets the
|
|
338
|
+
* deco admin/studio embed the storefront (see DEFAULT_FRAME_ANCESTORS_CSP).
|
|
336
339
|
*
|
|
337
|
-
* Custom entries are merged with defaults (custom values take precedence)
|
|
340
|
+
* Custom entries are merged with defaults (custom values take precedence) —
|
|
341
|
+
* pass `Content-Security-Policy` here to override the default frame-ancestors.
|
|
338
342
|
*
|
|
339
343
|
* @default DEFAULT_SECURITY_HEADERS
|
|
340
344
|
*/
|
|
@@ -621,11 +625,19 @@ const ONE_YEAR = 31536000;
|
|
|
621
625
|
|
|
622
626
|
/**
|
|
623
627
|
* Sensible security headers for any production storefront.
|
|
624
|
-
*
|
|
628
|
+
*
|
|
629
|
+
* Framing protection is handled by a default `Content-Security-Policy:
|
|
630
|
+
* frame-ancestors` directive (see DEFAULT_FRAME_ANCESTORS_CSP), NOT
|
|
631
|
+
* `X-Frame-Options`. CSP level 3 `frame-ancestors` supersedes X-Frame-Options
|
|
632
|
+
* in every modern browser, and — unlike the all-or-nothing SAMEORIGIN value —
|
|
633
|
+
* it can allow the deco admin/studio to embed the storefront for live preview
|
|
634
|
+
* while still blocking arbitrary third-party framing (clickjacking).
|
|
635
|
+
*
|
|
636
|
+
* Full CSP (script-src etc.) is intentionally not included here — it's
|
|
637
|
+
* site-specific (third-party script domains) and passed via the `csp` option.
|
|
625
638
|
*/
|
|
626
639
|
export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
|
|
627
640
|
"X-Content-Type-Options": "nosniff",
|
|
628
|
-
"X-Frame-Options": "SAMEORIGIN",
|
|
629
641
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
|
630
642
|
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
|
|
631
643
|
"X-XSS-Protection": "1; mode=block",
|
|
@@ -633,6 +645,28 @@ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
|
|
|
633
645
|
"Cross-Origin-Opener-Policy": "same-origin-allow-popups",
|
|
634
646
|
};
|
|
635
647
|
|
|
648
|
+
/**
|
|
649
|
+
* Origins allowed to embed the storefront in an iframe. These are the deco
|
|
650
|
+
* studio surfaces that render the live-preview iframe, plus `'self'` so the
|
|
651
|
+
* storefront can frame itself.
|
|
652
|
+
*
|
|
653
|
+
* A site can widen this by passing its own
|
|
654
|
+
* `securityHeaders["Content-Security-Policy"]`, or drop framing protection
|
|
655
|
+
* entirely with `securityHeaders: false`.
|
|
656
|
+
*/
|
|
657
|
+
export const DECO_ADMIN_FRAME_ANCESTORS: string[] = [
|
|
658
|
+
"'self'",
|
|
659
|
+
"https://studio.decocms.com",
|
|
660
|
+
"https://*.deco.studio",
|
|
661
|
+
];
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Default enforcement CSP shipped on every SSR HTML response. Only the
|
|
665
|
+
* `frame-ancestors` directive is enforced by default; the full site CSP
|
|
666
|
+
* (passed via the `csp` option) is emitted report-only.
|
|
667
|
+
*/
|
|
668
|
+
export const DEFAULT_FRAME_ANCESTORS_CSP = `frame-ancestors ${DECO_ADMIN_FRAME_ANCESTORS.join(" ")}`;
|
|
669
|
+
|
|
636
670
|
const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
|
|
637
671
|
|
|
638
672
|
/**
|
|
@@ -879,6 +913,12 @@ export function createDecoWorkerEntry(
|
|
|
879
913
|
const secHeaders: Record<string, string> | null = (() => {
|
|
880
914
|
if (securityHeadersOpt === false) return null;
|
|
881
915
|
const base = { ...DEFAULT_SECURITY_HEADERS };
|
|
916
|
+
// Ship an enforcement `frame-ancestors` CSP by default so the deco
|
|
917
|
+
// admin/studio can embed the storefront for live preview without every
|
|
918
|
+
// site copy-pasting the directive into its worker entry. A site can
|
|
919
|
+
// override the whole header via securityHeaders["Content-Security-Policy"]
|
|
920
|
+
// (the merge loop below wins) or disable all security headers with `false`.
|
|
921
|
+
base["Content-Security-Policy"] = DEFAULT_FRAME_ANCESTORS_CSP;
|
|
882
922
|
if (securityHeadersOpt) {
|
|
883
923
|
for (const [k, v] of Object.entries(securityHeadersOpt)) base[k] = v;
|
|
884
924
|
}
|
|
@@ -1113,15 +1153,33 @@ export function createDecoWorkerEntry(
|
|
|
1113
1153
|
return segments;
|
|
1114
1154
|
}
|
|
1115
1155
|
|
|
1116
|
-
|
|
1156
|
+
// Constant-time string compare — avoids a byte-wise timing side-channel on the
|
|
1157
|
+
// purge token. Length is not secret here, so a fast length-mismatch reject is fine.
|
|
1158
|
+
function timingSafeEqualStr(a: string, b: string): boolean {
|
|
1159
|
+
if (a.length !== b.length) return false;
|
|
1160
|
+
let diff = 0;
|
|
1161
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
1162
|
+
return diff === 0;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Shared auth for the cache purge endpoints. Returns a Response when the request
|
|
1166
|
+
// is unauthorized or purge is disabled, or null when authorized. Single source
|
|
1167
|
+
// of truth so /_cache/purge and /_cache/purge-loaders can't drift.
|
|
1168
|
+
function authorizePurge(request: Request, env: Record<string, unknown>): Response | null {
|
|
1117
1169
|
if (purgeTokenEnv === false) {
|
|
1118
1170
|
return new Response("Purge disabled", { status: 404 });
|
|
1119
1171
|
}
|
|
1120
|
-
|
|
1121
1172
|
const token = (env[purgeTokenEnv] as string) || "";
|
|
1122
|
-
|
|
1173
|
+
const auth = request.headers.get("Authorization") ?? "";
|
|
1174
|
+
if (!token || !timingSafeEqualStr(auth, `Bearer ${token}`)) {
|
|
1123
1175
|
return new Response("Unauthorized", { status: 401 });
|
|
1124
1176
|
}
|
|
1177
|
+
return null;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
async function handlePurge(request: Request, env: Record<string, unknown>): Promise<Response> {
|
|
1181
|
+
const denied = authorizePurge(request, env);
|
|
1182
|
+
if (denied) return denied;
|
|
1125
1183
|
|
|
1126
1184
|
let body: PurgeRequestBody;
|
|
1127
1185
|
try {
|
|
@@ -1229,6 +1287,21 @@ export function createDecoWorkerEntry(
|
|
|
1229
1287
|
return Response.json({ purged, total: purged.length });
|
|
1230
1288
|
}
|
|
1231
1289
|
|
|
1290
|
+
// Purge the in-memory loader/commerce cache for THIS isolate. Same auth as
|
|
1291
|
+
// handlePurge (Bearer `purgeTokenEnv`). Per-isolate by nature — the isolate
|
|
1292
|
+
// that serves this request clears its cache; other live isolates keep theirs
|
|
1293
|
+
// until they're recycled or hit too. For a guaranteed global flush, redeploy
|
|
1294
|
+
// (loader cache keys are build-hash versioned).
|
|
1295
|
+
async function handlePurgeLoaders(
|
|
1296
|
+
request: Request,
|
|
1297
|
+
env: Record<string, unknown>,
|
|
1298
|
+
): Promise<Response> {
|
|
1299
|
+
const denied = authorizePurge(request, env);
|
|
1300
|
+
if (denied) return denied;
|
|
1301
|
+
clearLoaderCache();
|
|
1302
|
+
return Response.json({ cleared: true, scope: "isolate" });
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1232
1305
|
// -- Admin route handler ---------------------------------------------------
|
|
1233
1306
|
|
|
1234
1307
|
const ADMIN_NO_CACHE: Record<string, string> = {
|
|
@@ -1554,6 +1627,14 @@ export function createDecoWorkerEntry(
|
|
|
1554
1627
|
return handlePurge(request, env);
|
|
1555
1628
|
}
|
|
1556
1629
|
|
|
1630
|
+
// Loader/commerce cache purge (in-memory, this isolate only). Escape hatch
|
|
1631
|
+
// to invalidate loader results immediately after an out-of-band upstream
|
|
1632
|
+
// change (e.g. a Magento catalog sync) without waiting out the TTL. A
|
|
1633
|
+
// redeploy is the global lever — loader cache keys are build-hash versioned.
|
|
1634
|
+
if (url.pathname === "/_cache/purge-loaders" && request.method === "POST") {
|
|
1635
|
+
return handlePurgeLoaders(request, env);
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1557
1638
|
// CMS redirects (website/loaders/redirect.ts blocks) — checked before
|
|
1558
1639
|
// ?asJson and commerce proxy so every request path respects redirects.
|
|
1559
1640
|
// Revision-keyed so cross-bundle setBlocks() calls are detected even when
|