@caelo-cms/shared 0.10.61 → 0.10.62
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/dist/auto-update.d.ts +15 -0
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +27 -0
- package/dist/auto-update.js.map +1 -1
- package/dist/cloud-shell-install.d.ts +42 -10
- package/dist/cloud-shell-install.d.ts.map +1 -1
- package/dist/cloud-shell-install.js +59 -0
- package/dist/cloud-shell-install.js.map +1 -1
- package/dist/css-scope-scan.d.ts +29 -0
- package/dist/css-scope-scan.d.ts.map +1 -0
- package/dist/css-scope-scan.js +251 -0
- package/dist/css-scope-scan.js.map +1 -0
- package/dist/font-assets.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +65 -13
- package/dist/interactions.d.ts.map +1 -1
- package/dist/interactions.js +132 -33
- package/dist/interactions.js.map +1 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +15 -19
- package/dist/preview-compose.js.map +1 -1
- package/dist/release-manifest.d.ts +3 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +11 -0
- package/dist/release-manifest.js.map +1 -1
- package/dist/static-security-headers.d.ts +50 -0
- package/dist/static-security-headers.d.ts.map +1 -0
- package/dist/static-security-headers.js +58 -0
- package/dist/static-security-headers.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/auto-update.test.ts +25 -0
- package/src/auto-update.ts +36 -0
- package/src/cloud-shell-install.test.ts +36 -1
- package/src/cloud-shell-install.ts +64 -0
- package/src/css-scope-scan.test.ts +117 -0
- package/src/css-scope-scan.ts +263 -0
- package/src/index.ts +2 -0
- package/src/interactions.test.ts +61 -0
- package/src/interactions.ts +181 -26
- package/src/preview-compose.test.ts +87 -1
- package/src/preview-compose.ts +16 -21
- package/src/release-manifest.ts +12 -0
- package/src/static-security-headers.test.ts +60 -0
- package/src/static-security-headers.ts +64 -0
- package/src/version.ts +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { INTERACTIONS } from "./interactions.js";
|
|
4
5
|
import {
|
|
5
6
|
ComposeError,
|
|
6
7
|
composePagePreview,
|
|
@@ -528,11 +529,96 @@ describe("composePageWithLayout", () => {
|
|
|
528
529
|
expect(out.html).toContain('data-nav-open="false"');
|
|
529
530
|
// Functional assets exactly once, even with two nav placements.
|
|
530
531
|
expect(out.html.split("caelo-nav-toggle{display:none").length - 1).toBe(1);
|
|
531
|
-
expect(out.html.split("
|
|
532
|
+
expect(out.html.split(INTERACTIONS["mobile-nav"].js).length - 1).toBe(1);
|
|
533
|
+
expect(out.html).toContain('data-caelo-ui="mobile-nav"');
|
|
532
534
|
// Zero aesthetics in the functional css: no colors beyond currentColor.
|
|
533
535
|
expect(out.html).not.toMatch(/caelo-nav-toggle\{[^}]*#[0-9a-f]{3}/i);
|
|
534
536
|
});
|
|
535
537
|
|
|
538
|
+
it("ships each vetted interaction once per page, whoever asks for it (issue #160)", () => {
|
|
539
|
+
const tabsModule = {
|
|
540
|
+
...blankModule,
|
|
541
|
+
moduleId: "33333333-3333-4333-8333-333333333304",
|
|
542
|
+
slug: "pricing-tabs",
|
|
543
|
+
html: '<div class="pricing" data-caelo-ui="tabs"><div role="tablist"></div></div>',
|
|
544
|
+
css: ".pricing{display:grid}",
|
|
545
|
+
};
|
|
546
|
+
const faqModule = {
|
|
547
|
+
...blankModule,
|
|
548
|
+
moduleId: "33333333-3333-4333-8333-333333333305",
|
|
549
|
+
slug: "faq",
|
|
550
|
+
html: '<div class="faq" data-caelo-ui="accordion"><details><summary>Q</summary>A</details></div>',
|
|
551
|
+
};
|
|
552
|
+
const out = composePageWithLayout({
|
|
553
|
+
templateHtml,
|
|
554
|
+
templateCss: "",
|
|
555
|
+
blocks: [{ blockName: "content", modules: [tabsModule, faqModule, tabsModule] }],
|
|
556
|
+
// The layout's own chrome may ask too.
|
|
557
|
+
layoutHtml: layoutHtml.replace("<body>", "<body><div data-caelo-ui='tabs'></div>"),
|
|
558
|
+
layoutCss: "",
|
|
559
|
+
layoutBlocks: [],
|
|
560
|
+
layoutSlug: "test",
|
|
561
|
+
});
|
|
562
|
+
expect(out.html.split(INTERACTIONS.tabs.js).length - 1).toBe(1);
|
|
563
|
+
expect(out.html.split(INTERACTIONS.tabs.css).length - 1).toBe(1);
|
|
564
|
+
expect(out.html.split(INTERACTIONS.accordion.js).length - 1).toBe(1);
|
|
565
|
+
// Registry order, independent of which module asked first.
|
|
566
|
+
expect(out.html.indexOf(INTERACTIONS.accordion.js)).toBeLessThan(
|
|
567
|
+
out.html.indexOf(INTERACTIONS.tabs.js),
|
|
568
|
+
);
|
|
569
|
+
// Unused ones stay out.
|
|
570
|
+
expect(out.html).not.toContain(INTERACTIONS.carousel.js);
|
|
571
|
+
expect(out.html).not.toContain(INTERACTIONS["mobile-nav"].js);
|
|
572
|
+
// Functional rules follow module CSS so a module display rule cannot unhide a panel.
|
|
573
|
+
expect(out.html.indexOf(".pricing{display:grid}")).toBeLessThan(
|
|
574
|
+
out.html.indexOf(INTERACTIONS.tabs.css),
|
|
575
|
+
);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("ships the interaction a withheld module needs (issue #160)", () => {
|
|
579
|
+
const gated = {
|
|
580
|
+
...blankModule,
|
|
581
|
+
moduleId: "33333333-3333-4333-8333-333333333306",
|
|
582
|
+
slug: "gallery",
|
|
583
|
+
html: '<section class="gallery" data-caelo-ui="carousel"></section>',
|
|
584
|
+
};
|
|
585
|
+
const out = composePageWithLayout({
|
|
586
|
+
templateHtml,
|
|
587
|
+
templateCss: "",
|
|
588
|
+
blocks: [{ blockName: "content", modules: [gated] }],
|
|
589
|
+
layoutHtml,
|
|
590
|
+
layoutCss: "",
|
|
591
|
+
layoutBlocks: [],
|
|
592
|
+
layoutSlug: "test",
|
|
593
|
+
deferredModules: {
|
|
594
|
+
[gated.moduleId]: {
|
|
595
|
+
pluginSlug: "consent-manager",
|
|
596
|
+
reason: "marketing",
|
|
597
|
+
placeholderModuleSlug: "consent-placeholder",
|
|
598
|
+
placeholderHtml: "<p>consent</p>",
|
|
599
|
+
placeholderCss: "",
|
|
600
|
+
},
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
// The snippet binds by delegation, so it works once the plugin clones
|
|
604
|
+
// the module out of its <template> after consent.
|
|
605
|
+
expect(out.html).toContain(INTERACTIONS.carousel.js);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it("does not ship anything for a data-caelo-ui value outside the kit (issue #160)", () => {
|
|
609
|
+
const out = composePagePreview({
|
|
610
|
+
templateHtml: `<!doctype html><html><head></head><body><caelo-slot name="content"></caelo-slot></body></html>`,
|
|
611
|
+
templateCss: "",
|
|
612
|
+
blocks: [
|
|
613
|
+
{
|
|
614
|
+
blockName: "content",
|
|
615
|
+
modules: [{ ...blankModule, html: '<div data-caelo-ui="lightbox"></div>' }],
|
|
616
|
+
},
|
|
617
|
+
],
|
|
618
|
+
});
|
|
619
|
+
expect(out.html).not.toContain('data-source="modules"');
|
|
620
|
+
});
|
|
621
|
+
|
|
536
622
|
it("injects the invisible technical baseline between theme and modules (issue #151)", () => {
|
|
537
623
|
const out = composePageWithLayout({
|
|
538
624
|
templateHtml,
|
package/src/preview-compose.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { BASE_TECHNICAL_CSS } from "./base-css.js";
|
|
29
29
|
import type { ModuleFieldKind } from "./content.js";
|
|
30
|
-
import {
|
|
30
|
+
import { findInteractionIds, interactionAssets } from "./interactions.js";
|
|
31
31
|
import { type LinkResolver, resolveLinkRefsInHtml } from "./link-refs.js";
|
|
32
32
|
import {
|
|
33
33
|
type NestedModuleResource,
|
|
@@ -276,8 +276,6 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
276
276
|
const contentByName = new Map<string, string>();
|
|
277
277
|
const allCss: string[] = [];
|
|
278
278
|
const allJs: string[] = [];
|
|
279
|
-
// issue #160 — see composePageWithLayout.
|
|
280
|
-
let navRendered = false;
|
|
281
279
|
// issue #158 — same per-module dedup as composePageWithLayout.
|
|
282
280
|
const seenAssetModules = new Set<string>();
|
|
283
281
|
const moduleFailures: ComposeModuleFailure[] = [];
|
|
@@ -298,7 +296,6 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
298
296
|
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
299
297
|
let baseHtml: string;
|
|
300
298
|
if (navMenuItems !== null) {
|
|
301
|
-
navRendered = true;
|
|
302
299
|
const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
|
|
303
300
|
baseHtml = nav.html;
|
|
304
301
|
recordFailures(moduleFailures, block.blockName, m, nav.failures);
|
|
@@ -328,13 +325,12 @@ export function composePagePreview(input: ComposeInput): ComposeOutput {
|
|
|
328
325
|
}
|
|
329
326
|
}
|
|
330
327
|
|
|
331
|
-
if (navRendered) {
|
|
332
|
-
allCss.push(NAV_FUNCTIONAL_CSS);
|
|
333
|
-
allJs.push(NAV_TOGGLE_JS);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
328
|
const replaced = applySlotReplacements(input.templateHtml, { contentByName });
|
|
337
329
|
let html = replaced.html;
|
|
330
|
+
// issue #160 — see composePageWithLayout.
|
|
331
|
+
const interactions = interactionAssets(new Set(findInteractionIds(html).known));
|
|
332
|
+
allCss.push(...interactions.css);
|
|
333
|
+
allJs.push(...interactions.js);
|
|
338
334
|
|
|
339
335
|
// Theme brand metadata (favicon) leads the injected head block — it
|
|
340
336
|
// is document metadata, not styling, and is independent of the
|
|
@@ -462,13 +458,13 @@ function renderNavMenuHtml(
|
|
|
462
458
|
return typeof o.label === "string" && typeof o.href === "string";
|
|
463
459
|
});
|
|
464
460
|
// issue #160 — mobile-ready markup: toggle button (three functional
|
|
465
|
-
// bars via currentColor) + collapsible list.
|
|
466
|
-
//
|
|
461
|
+
// bars via currentColor) + collapsible list. `data-caelo-ui` pulls the
|
|
462
|
+
// functional CSS/JS from ./interactions.ts in once per page.
|
|
467
463
|
const toggle =
|
|
468
464
|
'<button type="button" class="caelo-nav-toggle" aria-expanded="false" aria-label="Menu">' +
|
|
469
465
|
'<span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span><span class="caelo-nav-bar"></span>' +
|
|
470
466
|
"</button>";
|
|
471
|
-
const html = `<nav class="caelo-nav-menu" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
|
|
467
|
+
const html = `<nav class="caelo-nav-menu" data-caelo-ui="mobile-nav" data-nav-open="false">${toggle}<ul>${safeItems.map(renderNavItem).join("")}</ul></nav>`;
|
|
472
468
|
return { html, failures: missing.map((reason) => ({ field: "", reason })) };
|
|
473
469
|
}
|
|
474
470
|
|
|
@@ -794,9 +790,6 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
794
790
|
// brittle and reads as a bug).
|
|
795
791
|
const cssParts: string[] = [];
|
|
796
792
|
const jsParts: string[] = [];
|
|
797
|
-
// issue #160 — set when any nav-menu module rendered; pulls the
|
|
798
|
-
// functional nav CSS/JS in exactly once per page.
|
|
799
|
-
let navRendered = false;
|
|
800
793
|
// issue #158 — a module placed N times contributes its CSS/JS ONCE
|
|
801
794
|
// (first occurrence wins; source order is otherwise preserved).
|
|
802
795
|
// Duplicate rule blocks made the cascade order-dependent and bloated
|
|
@@ -821,7 +814,6 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
821
814
|
const navMenuItems = lookupNavMenuItems(m.slug, input.structuredSets);
|
|
822
815
|
let baseHtml: string;
|
|
823
816
|
if (navMenuItems !== null) {
|
|
824
|
-
navRendered = true;
|
|
825
817
|
const nav = renderNavMenuHtml(navMenuItems, input.linkResolver);
|
|
826
818
|
baseHtml = nav.html;
|
|
827
819
|
recordFailures(moduleFailures, blockName, m, nav.failures);
|
|
@@ -913,11 +905,6 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
913
905
|
if (css.trim().length > 0) cssParts.push(css);
|
|
914
906
|
}
|
|
915
907
|
|
|
916
|
-
if (navRendered) {
|
|
917
|
-
cssParts.push(NAV_FUNCTIONAL_CSS);
|
|
918
|
-
jsParts.push(NAV_TOGGLE_JS);
|
|
919
|
-
}
|
|
920
|
-
|
|
921
908
|
// 3. Render the layout HTML, substituting all named slots. The
|
|
922
909
|
// layout's own markup (the page frame around the blocks) stores
|
|
923
910
|
// internal links as references too (#724 rollout C canonicalises
|
|
@@ -932,6 +919,14 @@ export function composePageWithLayout(input: ComposeWithLayoutInput): ComposeOut
|
|
|
932
919
|
});
|
|
933
920
|
let html = replaced.html;
|
|
934
921
|
|
|
922
|
+
// issue #160 — every vetted interaction the page's markup asks for
|
|
923
|
+
// (`data-caelo-ui="…"`, from modules, the nav renderer, the layout or
|
|
924
|
+
// the template, withheld modules included) ships once, after module
|
|
925
|
+
// CSS/JS so its functional rules win over a module's display rules.
|
|
926
|
+
const interactions = interactionAssets(new Set(findInteractionIds(html).known));
|
|
927
|
+
cssParts.push(...interactions.css);
|
|
928
|
+
jsParts.push(...interactions.js);
|
|
929
|
+
|
|
935
930
|
// Theme brand metadata (favicon) — see composePagePreview.
|
|
936
931
|
const themeHeadLinks = themeHeadFragment(input.theme);
|
|
937
932
|
if (themeHeadLinks !== null) {
|
package/src/release-manifest.ts
CHANGED
|
@@ -31,6 +31,9 @@ const GHSA_ID = /^GHSA(-[23456789cfghjmpqrvwx]{4}){3}$/;
|
|
|
31
31
|
|
|
32
32
|
const digest = z.string().regex(IMAGE_DIGEST, "expected sha256:<64 hex>");
|
|
33
33
|
|
|
34
|
+
/** The longest `ownerAction` a release may declare: one sentence. */
|
|
35
|
+
export const OWNER_ACTION_MAX_CHARS = 300;
|
|
36
|
+
|
|
34
37
|
/** Zod schema of `release-manifest.json` (manifestVersion 1). */
|
|
35
38
|
export const ReleaseManifestSchema = z.object({
|
|
36
39
|
manifestVersion: z.literal(1),
|
|
@@ -55,6 +58,15 @@ export const ReleaseManifestSchema = z.object({
|
|
|
55
58
|
/** Set only via the release-cut input, backed by published GitHub Security Advisories. */
|
|
56
59
|
security: z.boolean(),
|
|
57
60
|
advisories: z.array(z.string().regex(GHSA_ID, "expected a GHSA id")),
|
|
61
|
+
/**
|
|
62
|
+
* #868 — what the Owner has to do for this release's breaking change, in
|
|
63
|
+
* one sentence (Settings → Updates shows it under "Before you update").
|
|
64
|
+
* Set from the release-cut input; the release checks refuse a release
|
|
65
|
+
* with a breaking commit without it. Absent on releases from before the
|
|
66
|
+
* field and on releases without a breaking change: optional, so
|
|
67
|
+
* manifestVersion stays 1 and older readers ignore it.
|
|
68
|
+
*/
|
|
69
|
+
ownerAction: z.string().min(1).max(OWNER_ACTION_MAX_CHARS).optional(),
|
|
58
70
|
});
|
|
59
71
|
export type ReleaseManifest = z.infer<typeof ReleaseManifestSchema>;
|
|
60
72
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #863 / #891 — the media security headers. The CSP must reach every SVG
|
|
5
|
+
* (the second line behind `sanitizeSvg`) and must NOT reach a PDF:
|
|
6
|
+
* `sandbox` stops Chrome's built-in viewer from opening it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
isSvgContentType,
|
|
12
|
+
mediaSecurityHeaders,
|
|
13
|
+
SVG_DOCUMENT_CSP,
|
|
14
|
+
SVG_PATH_PATTERN,
|
|
15
|
+
} from "./static-security-headers.js";
|
|
16
|
+
|
|
17
|
+
describe("mediaSecurityHeaders", () => {
|
|
18
|
+
it("sandboxes SVG documents without script, network or plugins", () => {
|
|
19
|
+
const h = mediaSecurityHeaders("image/svg+xml");
|
|
20
|
+
expect(h["content-security-policy"]).toBe(SVG_DOCUMENT_CSP);
|
|
21
|
+
expect(SVG_DOCUMENT_CSP).toContain("default-src 'none'");
|
|
22
|
+
expect(SVG_DOCUMENT_CSP).toContain("sandbox");
|
|
23
|
+
expect(SVG_DOCUMENT_CSP).not.toContain("script-src");
|
|
24
|
+
expect(h["x-content-type-options"]).toBe("nosniff");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("matches the SVG type regardless of case or parameters", () => {
|
|
28
|
+
expect(mediaSecurityHeaders("Image/SVG+XML; charset=utf-8")["content-security-policy"]).toBe(
|
|
29
|
+
SVG_DOCUMENT_CSP,
|
|
30
|
+
);
|
|
31
|
+
expect(isSvgContentType(" image/svg+xml ")).toBe(true);
|
|
32
|
+
expect(isSvgContentType("image/png")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("keeps embedded rasters and inline styles rendering", () => {
|
|
36
|
+
expect(SVG_DOCUMENT_CSP).toContain("img-src data:");
|
|
37
|
+
expect(SVG_DOCUMENT_CSP).toContain("style-src 'unsafe-inline'");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("leaves PDFs and rasters openable (nosniff only)", () => {
|
|
41
|
+
for (const type of ["application/pdf", "image/png", "image/webp", "video/mp4", "text/html"]) {
|
|
42
|
+
expect(mediaSecurityHeaders(type)).toEqual({ "x-content-type-options": "nosniff" });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("SVG_PATH_PATTERN", () => {
|
|
48
|
+
it("matches .svg paths in any case and nothing else", () => {
|
|
49
|
+
for (const p of ["/_assets/logo.0123456789abcdef.svg", "/_assets/LOGO.SVG", "icon.Svg"]) {
|
|
50
|
+
expect(new RegExp(SVG_PATH_PATTERN).test(p)).toBe(true);
|
|
51
|
+
}
|
|
52
|
+
for (const p of ["/_assets/logo.svgz", "/_assets/svg/logo.png", "/about/", "/x.svg/"]) {
|
|
53
|
+
expect(new RegExp(SVG_PATH_PATTERN).test(p)).toBe(false);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("uses no inline flags or lookaround, so RE2 (Firebase Hosting) accepts it", () => {
|
|
58
|
+
expect(SVG_PATH_PATTERN).not.toMatch(/\(\?/);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Security response headers for uploaded media and the published static
|
|
5
|
+
* site — the one definition the admin's media routes, the staging preview,
|
|
6
|
+
* every static publisher and every provider stack's edge config derive
|
|
7
|
+
* from (#863, #891).
|
|
8
|
+
*
|
|
9
|
+
* An SVG opened directly (`https://<site>/_assets/logo.<hash>.svg`) is a
|
|
10
|
+
* document, not an image: it runs script in the origin it is served from.
|
|
11
|
+
* On the public site that origin also serves `/api/*` (the gateway behind
|
|
12
|
+
* the same load balancer, CLAUDE.md §11.B), on the admin it holds the
|
|
13
|
+
* operator's session. `sanitizeSvg` strips script at upload; this CSP is
|
|
14
|
+
* the line behind it should a construct ever get past the sanitizer: no
|
|
15
|
+
* script, no plugins, no network, and `sandbox` gives the document an
|
|
16
|
+
* opaque origin. Inline styles and `data:` rasters / fonts stay allowed so
|
|
17
|
+
* an SVG renders exactly as before — as a document and as an `<img>`.
|
|
18
|
+
* Browsers apply a CSP only to documents (and workers), so the header
|
|
19
|
+
* changes nothing for an SVG loaded through `<img>` or CSS.
|
|
20
|
+
*
|
|
21
|
+
* Only SVG gets the CSP: raster images, video and fonts cannot run script,
|
|
22
|
+
* and `sandbox` would stop Chrome's built-in viewer from opening a PDF.
|
|
23
|
+
* `nosniff` goes on everything so a browser never reinterprets bytes as a
|
|
24
|
+
* scriptable type the content-type did not declare.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The CSP an SVG document is served with (see the file comment). */
|
|
28
|
+
export const SVG_DOCUMENT_CSP =
|
|
29
|
+
"default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; sandbox";
|
|
30
|
+
|
|
31
|
+
/** `X-Content-Type-Options` value every media / static response carries. */
|
|
32
|
+
export const NOSNIFF = "nosniff";
|
|
33
|
+
|
|
34
|
+
/** The MIME type the CSP is keyed on. */
|
|
35
|
+
export const SVG_CONTENT_TYPE = "image/svg+xml";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Regex (source string, unanchored at the start) matching a URL path that
|
|
39
|
+
* ends in `.svg`, any case. Only syntax shared by ECMAScript and RE2 (no
|
|
40
|
+
* inline flags), so the same string serves Firebase Hosting's `regex`
|
|
41
|
+
* header matcher and `RegExp` here. The static generator names every file
|
|
42
|
+
* by extension (`contentTypeFor` in admin-core), so the extension is what
|
|
43
|
+
* decides the served `image/svg+xml`.
|
|
44
|
+
*/
|
|
45
|
+
export const SVG_PATH_PATTERN = "\\.[sS][vV][gG]$";
|
|
46
|
+
|
|
47
|
+
/** True when `contentType` is SVG (case and parameters such as `; charset=` ignored). */
|
|
48
|
+
export function isSvgContentType(contentType: string): boolean {
|
|
49
|
+
return (contentType.split(";")[0] ?? "").trim().toLowerCase() === SVG_CONTENT_TYPE;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Security headers for one media / static-file response.
|
|
54
|
+
*
|
|
55
|
+
* @param contentType - the `content-type` the response is served with.
|
|
56
|
+
* @returns lower-case header entries to spread into the response's headers.
|
|
57
|
+
* @example
|
|
58
|
+
* new Response(body, { headers: { "Content-Type": ct, ...mediaSecurityHeaders(ct) } });
|
|
59
|
+
*/
|
|
60
|
+
export function mediaSecurityHeaders(contentType: string): Record<string, string> {
|
|
61
|
+
const headers: Record<string, string> = { "x-content-type-options": NOSNIFF };
|
|
62
|
+
if (isSvgContentType(contentType)) headers["content-security-policy"] = SVG_DOCUMENT_CSP;
|
|
63
|
+
return headers;
|
|
64
|
+
}
|