@rathnasgala2/template 2.0.0 → 2.1.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/README.md +204 -137
- package/contracts/render-policy.jcs +1 -1
- package/contracts/theme-styling-contract.jcs +1 -1
- package/package.json +4 -5
- package/src/core/errors.js +13 -13
- package/src/core/index.js +102 -66
- package/src/core/internal/appearance/base-layer.js +180 -0
- package/src/core/internal/appearance/bootstrap-script.js +11 -11
- package/src/core/internal/appearance/contract.js +59 -44
- package/src/core/internal/appearance/controller-markup.js +7 -8
- package/src/core/internal/appearance/styling-contract.js +132 -29
- package/src/core/internal/canonical-jcs.js +0 -0
- package/src/core/internal/content-security.js +0 -0
- package/src/core/internal/eleventy-render.js +52 -35
- package/src/core/internal/feeds.js +10 -13
- package/src/core/internal/fs-walk.js +2 -2
- package/src/core/internal/media/avif-probe.js +4 -4
- package/src/core/internal/media/font-policy.js +2 -2
- package/src/core/internal/media/jpeg-codec.js +0 -0
- package/src/core/internal/media/limits.js +32 -34
- package/src/core/internal/media/pipeline.js +4 -4
- package/src/core/internal/media/png-codec.js +7 -7
- package/src/core/internal/media/resize.js +2 -2
- package/src/core/internal/media/sniff.js +4 -4
- package/src/core/internal/media/theme-svg-sanitizer.js +278 -0
- package/src/core/internal/media/webp-probe.js +3 -3
- package/src/core/internal/messages.js +8 -8
- package/src/core/internal/page-kinds.js +46 -48
- package/src/core/internal/render-policy-content.js +57 -21
- package/src/core/internal/route-labels.js +5 -6
- package/src/core/internal/route.js +16 -20
- package/src/core/internal/search-index.js +12 -14
- package/src/core/internal/semver-range.js +84 -0
- package/src/core/internal/seo.js +21 -23
- package/src/core/internal/sitemap.js +6 -8
- package/src/core/internal/skeleton.js +42 -39
- package/src/core/internal/source-inventory.js +4 -5
- package/src/core/internal/text-direction.js +1 -2
- package/src/core/internal/theme-assets.js +294 -67
- package/src/core/internal/uuid.js +4 -5
- package/src/core/manifest.js +0 -0
- package/types/index.d.ts +22 -8
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* RSS 2.0 and Atom 1.0 feed generation
|
|
3
|
-
* section 3 "Required generated outputs": "RSS and Atom feeds").
|
|
2
|
+
* RSS 2.0 and Atom 1.0 feed generation.
|
|
4
3
|
*
|
|
5
4
|
* Both feeds are built from the exact same {@link selectPublishedArticles}
|
|
6
5
|
* selection and ordering `internal/page-kinds.js`'s `index` page kind
|
|
7
6
|
* paginates (most recently published first, `publishedAt` descending, tied
|
|
8
7
|
* broken by ascending `id`), so a feed's item order can never drift from the
|
|
9
8
|
* site's own index listing. Every URL is an absolute `https://` URL resolved
|
|
10
|
-
* against `buildInput.baseUrl`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* against.
|
|
9
|
+
* against `buildInput.baseUrl` — always the build input's public origin,
|
|
10
|
+
* never a relative path — since a feed reader has no notion of the
|
|
11
|
+
* publication's own base path to resolve a relative URL against.
|
|
14
12
|
*
|
|
15
|
-
* Item count
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* scope decisions in the package README).
|
|
13
|
+
* Item count: this renderer's build input carries no maximum feed item
|
|
14
|
+
* count. This module bounds it at {@link FEED_ITEM_LIMIT} — a conservative,
|
|
15
|
+
* deterministic default with no author-facing configuration surface,
|
|
16
|
+
* matching this renderer's own established style for an unspecified
|
|
17
|
+
* numeric bound (see `internal/page-kinds.js`'s `LISTING_PAGE_SIZE` and the
|
|
18
|
+
* media pipeline scope decisions in the package README).
|
|
22
19
|
*/
|
|
23
20
|
|
|
24
21
|
import { escapeHtml } from './skeleton.js';
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Filesystem readdir order is not portable, so every caller that needs a
|
|
5
5
|
* reproducible route/asset ordering must sort explicitly; this module never
|
|
6
|
-
* returns raw enumeration order
|
|
7
|
-
* filesystem-enumeration-order dependence
|
|
6
|
+
* returns raw enumeration order, so this renderer has no
|
|
7
|
+
* filesystem-enumeration-order dependence.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readdir } from 'node:fs/promises';
|
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
* enough to confirm an `ftyp` box with major brand `avif`, locate the
|
|
8
8
|
* `meta` box, and read the primary item's `ispe` (image spatial extents)
|
|
9
9
|
* property for declared width/height, but it does not run a full AV1
|
|
10
|
-
* bitstream decode.
|
|
10
|
+
* bitstream decode. This renderer's closed passive-theme-asset decoder
|
|
11
11
|
* requires a complete single-frame AV1 decode for *passive theme assets*;
|
|
12
12
|
* an equivalent from-scratch AV1 decoder for *author media* is out of
|
|
13
|
-
* proportion to this
|
|
13
|
+
* proportion to this module's scope, flagged to the orchestrator as a
|
|
14
14
|
* follow-up. AVIF is therefore admitted as a *source* reference
|
|
15
15
|
* (recorded, digest-verified, dimension-bounded) with no generated
|
|
16
16
|
* responsive derivative; its original bytes pass through as the sole asset
|
|
17
17
|
* entry, exactly like WebP.
|
|
18
18
|
*
|
|
19
|
-
* The box walk is bounded: at most 4,096 boxes are visited (matching
|
|
20
|
-
*
|
|
19
|
+
* The box walk is bounded: at most 4,096 boxes are visited (matching this
|
|
20
|
+
* renderer's own container-structure isolation bound for the binary
|
|
21
21
|
* decoder family) and every box's declared size is checked against the
|
|
22
22
|
* remaining buffer length before it is used to slice, so a malformed or
|
|
23
23
|
* adversarial box table cannot cause an out-of-bounds read or an unbounded
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Font policy for `appearance.fontAssets
|
|
2
|
+
* Font policy for `appearance.fontAssets`: custom fonts
|
|
3
3
|
* require licensing, glyph and language coverage plus fallback evidence, and
|
|
4
|
-
* no remote font is ever retrieved
|
|
4
|
+
* no remote font is ever retrieved.
|
|
5
5
|
*
|
|
6
6
|
* `build-input:2.0.0`'s `resolvedFile` shape (the only shape
|
|
7
7
|
* `appearance.fontAssets` entries use) is `{path, sourceDigest}` — a
|
|
Binary file
|
|
@@ -1,37 +1,34 @@
|
|
|
1
1
|
import { MediaPipelineError } from '../../errors.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Media pipeline resource ceilings
|
|
5
|
-
*
|
|
4
|
+
* Media pipeline resource ceilings: a bounded decoding worker with fixed
|
|
5
|
+
* decompression and resource limits, so a hostile or malformed image/font
|
|
6
|
+
* can never trigger unbounded allocation or a decode bomb.
|
|
6
7
|
*
|
|
7
|
-
* The raster-specific numbers below are
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* bytes
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* unrelated set — the physical decode-bomb risk (integer overflow, unbounded
|
|
17
|
-
* allocation) is identical for both call sites. This is an explicit,
|
|
18
|
-
* documented implementer decision, not a value taken from author-media text.
|
|
8
|
+
* The raster-specific numbers below are this renderer's closed binary-asset
|
|
9
|
+
* bounds, shared between the theme passive-asset decoder profile and the
|
|
10
|
+
* author-media pipeline (which has no separate numeric profile of its own):
|
|
11
|
+
* non-animated raster, width/height each `1..8192`, at most 16,777,216
|
|
12
|
+
* pixels, at most 67,108,864 decoded RGBA8 buffer bytes, source raster at
|
|
13
|
+
* most 16,777,216 bytes, source font at most 4,194,304 bytes. Reusing the
|
|
14
|
+
* same numbers for both call sites, rather than inventing an unrelated set,
|
|
15
|
+
* is a deliberate choice: the physical decode-bomb risk (integer overflow,
|
|
16
|
+
* unbounded allocation) is identical for both.
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* smaller declared per-file/package budget still wins." Applied here: the
|
|
18
|
+
* A separate, smaller general per-source-file ceiling (10,485,760 bytes)
|
|
19
|
+
* applies to *every* repository source file regardless of kind, and the
|
|
20
|
+
* smaller declared per-file/package budget always wins. Applied here: the
|
|
24
21
|
* effective per-image source-byte ceiling is `min(16,777,216, 10,485,760)`.
|
|
25
22
|
* The font ceiling (4,194,304) is already smaller than the general cap, so
|
|
26
23
|
* it is unaffected.
|
|
27
24
|
*
|
|
28
25
|
* `MAX_IMAGES_PER_PUBLICATION` and `MAX_TOTAL_MEDIA_BYTES_PER_PUBLICATION`
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
26
|
+
* have no externally fixed per-publication *media-specific* aggregate to
|
|
27
|
+
* conform to. These two values are this renderer's own conservative,
|
|
28
|
+
* documented ceilings (well under this renderer's separate, unrelated
|
|
29
|
+
* 50,000-file / 1,073,741,824-byte whole-artifact ceiling), so a publication
|
|
30
|
+
* cannot exhaust the media pipeline's bounded decode budget by sheer
|
|
31
|
+
* reference count even though each individual file is itself bounded.
|
|
35
32
|
*/
|
|
36
33
|
|
|
37
34
|
/** Maximum admitted raster width or height, in pixels, inclusive. */
|
|
@@ -44,15 +41,15 @@ export const MAX_IMAGE_PIXELS = 16_777_216;
|
|
|
44
41
|
export const MAX_DECODED_RGBA_BYTES = 67_108_864;
|
|
45
42
|
|
|
46
43
|
/**
|
|
47
|
-
* Maximum admitted raster source byte length: the smaller of
|
|
48
|
-
* raster-specific cap (16,777,216) and its general
|
|
49
|
-
* (10,485,760).
|
|
44
|
+
* Maximum admitted raster source byte length: the smaller of this
|
|
45
|
+
* renderer's raster-specific cap (16,777,216) and its general
|
|
46
|
+
* per-source-file cap (10,485,760).
|
|
50
47
|
*/
|
|
51
48
|
export const MAX_IMAGE_SOURCE_BYTES = 10_485_760;
|
|
52
49
|
|
|
53
50
|
/**
|
|
54
|
-
* Maximum admitted font source byte length (
|
|
55
|
-
* already smaller than the general per-source-file cap).
|
|
51
|
+
* Maximum admitted font source byte length (this renderer's binary font
|
|
52
|
+
* cap, already smaller than the general per-source-file cap).
|
|
56
53
|
*/
|
|
57
54
|
export const MAX_FONT_SOURCE_BYTES = 4_194_304;
|
|
58
55
|
|
|
@@ -74,8 +71,9 @@ export const MAX_FONTS_PER_PUBLICATION = 8;
|
|
|
74
71
|
/**
|
|
75
72
|
* Maximum aggregate decoded-plus-derivative byte budget the media pipeline
|
|
76
73
|
* will spend for one publication (implementer-chosen; see module
|
|
77
|
-
* documentation). Comfortably under
|
|
78
|
-
* whole-artifact ceiling, which this constant does not
|
|
74
|
+
* documentation). Comfortably under this renderer's separate, unrelated
|
|
75
|
+
* 1,073,741,824-byte whole-artifact ceiling, which this constant does not
|
|
76
|
+
* replace or relax.
|
|
79
77
|
*/
|
|
80
78
|
export const MAX_TOTAL_MEDIA_BYTES_PER_PUBLICATION = 268_435_456;
|
|
81
79
|
|
|
@@ -116,9 +114,9 @@ export function assertDimensionsWithinCeiling(
|
|
|
116
114
|
|
|
117
115
|
/**
|
|
118
116
|
* Non-resetting wall-clock budget for one file's bounded decode, in
|
|
119
|
-
* milliseconds.
|
|
120
|
-
* non-resetting deadline
|
|
121
|
-
*
|
|
117
|
+
* milliseconds. This renderer's closed binary-asset decoder uses a
|
|
118
|
+
* ten-second non-resetting deadline, for the same documented reason as the
|
|
119
|
+
* size/pixel ceilings above.
|
|
122
120
|
*/
|
|
123
121
|
export const DECODE_DEADLINE_MS = 10_000;
|
|
124
122
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Media pipeline orchestration
|
|
2
|
+
* Media pipeline orchestration.
|
|
3
3
|
*
|
|
4
4
|
* `build-input:2.0.0` never carries raw asset bytes for a `resolvedFile`
|
|
5
|
-
* reference — only its repository-relative `path` and a `sourceDigest
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* reference — only its repository-relative `path` and a `sourceDigest`:
|
|
6
|
+
* `build` exposes this decoded set as a read-only source mount. This
|
|
7
|
+
* pipeline therefore reads actual bytes from
|
|
8
8
|
* `options.sourceDirectory`, the caller-mounted read-only source tree, and
|
|
9
9
|
* fails closed (`MEDIA_SOURCE_DIGEST_MISMATCH`) if what it reads there does
|
|
10
10
|
* not hash to the digest `build-input` declared — the same fail-closed
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* (grayscale+alpha) and 6 (truecolor+alpha), bit depth 8 only, no
|
|
7
7
|
* interlacing. Any other bit depth, interlace method, or PNG structure this
|
|
8
8
|
* module does not recognize is a `MEDIA_FORMAT_INVALID` rejection — never a
|
|
9
|
-
* best-effort guess — matching the fail-closed posture
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
9
|
+
* best-effort guess — matching the fail-closed posture required throughout
|
|
10
|
+
* this pipeline. This is a deliberately smaller surface than this
|
|
11
|
+
* renderer's own closed passive-theme-asset PNG grammar (which the theme
|
|
12
|
+
* packages use for passive presentation assets); author media only needs a
|
|
13
|
+
* decode path sufficient to bound, re-derive
|
|
14
14
|
* orientation-free pixels from, and re-encode ordinary photographic/graphic
|
|
15
15
|
* uploads.
|
|
16
16
|
*
|
|
@@ -371,8 +371,8 @@ function crc32(buffer) {
|
|
|
371
371
|
* Deterministically encode an RGBA8 raster as a minimal truecolor+alpha PNG:
|
|
372
372
|
* one `IHDR`, one "filter type 0 (none)" `IDAT` stream at a fixed zlib
|
|
373
373
|
* level/strategy, one `IEND`. No ancillary chunk (no metadata) is ever
|
|
374
|
-
* emitted,
|
|
375
|
-
*
|
|
374
|
+
* emitted, stripping nonessential metadata
|
|
375
|
+
* from every output this renderer produces.
|
|
376
376
|
*
|
|
377
377
|
* @param {{width: number, height: number, rgba: Buffer}} raster the raster
|
|
378
378
|
* to encode
|
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* IEEE 754 double-precision arithmetic is fully deterministic — the same
|
|
6
6
|
* inputs always produce the same bit pattern on any conforming engine — so
|
|
7
|
-
* this box filter (unlike a lossy codec's own pixel decode, which
|
|
8
|
-
*
|
|
7
|
+
* this box filter (unlike a lossy codec's own pixel decode, which is
|
|
8
|
+
* implementation-dependent) is safe to use directly for the
|
|
9
9
|
* two-clean-build byte-equality requirement: the same source raster and the
|
|
10
10
|
* same target width always produce the same resized raster, and therefore,
|
|
11
11
|
* fed through this pipeline's own deterministic encoders, the same
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Media-type sniffing from decoded bytes, never from a file extension or a
|
|
3
|
-
* caller-declared media type
|
|
4
|
-
* decoded bytes, not extension
|
|
3
|
+
* caller-declared media type: media type is established from
|
|
4
|
+
* decoded bytes, not extension.
|
|
5
5
|
*
|
|
6
|
-
* SVG is deliberately excluded from this sniffer's admitted output set:
|
|
7
|
-
* is never processed as author media
|
|
6
|
+
* SVG is deliberately excluded from this sniffer's admitted output set: SVG
|
|
7
|
+
* is never processed as author media. Any byte
|
|
8
8
|
* sequence recognizable as SVG/XML is classified `'svg'` here specifically
|
|
9
9
|
* so the caller can reject it with a distinct, explicit
|
|
10
10
|
* `MEDIA_SVG_REJECTED` reason rather than falling through to a generic
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed admission grammar a theme-declared passive SVG asset must pass
|
|
3
|
+
* before it is copied into a published artifact (TPL-C2, prerequisite for
|
|
4
|
+
* TPL-H6's iconography decision).
|
|
5
|
+
*
|
|
6
|
+
* Author-supplied SVG is unconditionally rejected by the media pipeline
|
|
7
|
+
* (`internal/media/sniff.js`'s `'svg'` classification, `MEDIA_SVG_REJECTED`
|
|
8
|
+
* in `internal/media/pipeline.js`) — SVG is active content (it admits
|
|
9
|
+
* `<script>`, event-handler attributes and external references) and the
|
|
10
|
+
* render-policy's own residual-active-content scan explicitly forbids it in
|
|
11
|
+
* markdown-derived HTML. A theme package is a lower-trust supply-chain input
|
|
12
|
+
* than the repository owner's own authored content, so it cannot be held to
|
|
13
|
+
* a looser standard: this module is the one path an SVG byte sequence may
|
|
14
|
+
* take to reach a published artifact, and it applies its own closed
|
|
15
|
+
* tag/attribute/scheme allowlist — independent of, and strictly narrower
|
|
16
|
+
* than, `sanitize-html`'s author-content configuration in
|
|
17
|
+
* `internal/content-security.js` — followed by the same class of fail-closed
|
|
18
|
+
* residual-scan backstop that pipeline uses.
|
|
19
|
+
*
|
|
20
|
+
* Scope: this grammar targets exactly one use case — a small, static,
|
|
21
|
+
* decorative icon or mark referenced from theme CSS (`::before`/`::after`
|
|
22
|
+
* `content: url(...)`, `background-image`, or an `<img>`/`<use>` a future
|
|
23
|
+
* theme ships). It is deliberately conservative: no `<script>`, no `<style>`
|
|
24
|
+
* (inline CSS can itself carry `url()` external references), no
|
|
25
|
+
* `<foreignObject>` (arbitrary embedded HTML), no `<image>` (a raster/SVG
|
|
26
|
+
* reference of its own, which would need this same admission recursively),
|
|
27
|
+
* no event-handler attribute of any kind, and no attribute whose value is a
|
|
28
|
+
* URL reference other than a same-document `#fragment` (so `<use href="#id">`
|
|
29
|
+
* referencing a `<symbol>` defined earlier in the same file works; anything
|
|
30
|
+
* that could reach off-document content does not).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import sanitizeHtml from 'sanitize-html';
|
|
34
|
+
|
|
35
|
+
import { ThemeAssetError } from '../../errors.js';
|
|
36
|
+
|
|
37
|
+
/** @type {string} the reason code every rejection in this module carries. */
|
|
38
|
+
export const THEME_SVG_REJECTED_CODE = 'THEME_ASSET_SVG_REJECTED';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The closed SVG element allowlist: structural/grouping and shape elements,
|
|
42
|
+
* gradients/clipping used for flat icon artwork, and `<title>`/`<desc>` for
|
|
43
|
+
* accessible names. No scripting, styling, foreign-content or
|
|
44
|
+
* external-media element is admitted.
|
|
45
|
+
*
|
|
46
|
+
* @type {readonly string[]}
|
|
47
|
+
*/
|
|
48
|
+
const ALLOWED_SVG_TAGS = Object.freeze([
|
|
49
|
+
'svg',
|
|
50
|
+
'g',
|
|
51
|
+
'defs',
|
|
52
|
+
'symbol',
|
|
53
|
+
'use',
|
|
54
|
+
'title',
|
|
55
|
+
'desc',
|
|
56
|
+
'path',
|
|
57
|
+
'rect',
|
|
58
|
+
'circle',
|
|
59
|
+
'ellipse',
|
|
60
|
+
'line',
|
|
61
|
+
'polyline',
|
|
62
|
+
'polygon',
|
|
63
|
+
'clipPath',
|
|
64
|
+
'mask',
|
|
65
|
+
'linearGradient',
|
|
66
|
+
'radialGradient',
|
|
67
|
+
'stop',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
/** @type {readonly string[]} presentation/geometry attributes admitted on
|
|
71
|
+
* every allowed tag (a closed union, not per-tag — {@link assertNoResidualActiveSvgContent}
|
|
72
|
+
* is the fail-closed backstop for anything this allowlist under-restricts). */
|
|
73
|
+
const COMMON_SVG_ATTRIBUTES = Object.freeze([
|
|
74
|
+
'id',
|
|
75
|
+
'class',
|
|
76
|
+
'role',
|
|
77
|
+
'aria-hidden',
|
|
78
|
+
'aria-label',
|
|
79
|
+
'focusable',
|
|
80
|
+
'viewBox',
|
|
81
|
+
'width',
|
|
82
|
+
'height',
|
|
83
|
+
'x',
|
|
84
|
+
'y',
|
|
85
|
+
'x1',
|
|
86
|
+
'y1',
|
|
87
|
+
'x2',
|
|
88
|
+
'y2',
|
|
89
|
+
'cx',
|
|
90
|
+
'cy',
|
|
91
|
+
'r',
|
|
92
|
+
'rx',
|
|
93
|
+
'ry',
|
|
94
|
+
'd',
|
|
95
|
+
'points',
|
|
96
|
+
'transform',
|
|
97
|
+
'fill',
|
|
98
|
+
'fill-rule',
|
|
99
|
+
'fill-opacity',
|
|
100
|
+
'stroke',
|
|
101
|
+
'stroke-width',
|
|
102
|
+
'stroke-linecap',
|
|
103
|
+
'stroke-linejoin',
|
|
104
|
+
'stroke-dasharray',
|
|
105
|
+
'stroke-opacity',
|
|
106
|
+
'opacity',
|
|
107
|
+
'clip-path',
|
|
108
|
+
'clip-rule',
|
|
109
|
+
'mask',
|
|
110
|
+
'offset',
|
|
111
|
+
'stop-color',
|
|
112
|
+
'stop-opacity',
|
|
113
|
+
'gradientUnits',
|
|
114
|
+
'gradientTransform',
|
|
115
|
+
'preserveAspectRatio',
|
|
116
|
+
'xmlns',
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/** @type {import('sanitize-html').IOptions} */
|
|
120
|
+
const SVG_SANITIZE_OPTIONS = {
|
|
121
|
+
allowedTags: [...ALLOWED_SVG_TAGS],
|
|
122
|
+
allowedAttributes: Object.fromEntries(
|
|
123
|
+
ALLOWED_SVG_TAGS.map((tag) => [
|
|
124
|
+
tag,
|
|
125
|
+
tag === 'use'
|
|
126
|
+
? [...COMMON_SVG_ATTRIBUTES, 'href', 'xlink:href']
|
|
127
|
+
: [...COMMON_SVG_ATTRIBUTES],
|
|
128
|
+
]),
|
|
129
|
+
),
|
|
130
|
+
// `href`/`xlink:href` are only ever admitted on `use` above, and only a
|
|
131
|
+
// same-document fragment reference is a legal value (enforced below by
|
|
132
|
+
// {@link assertNoResidualActiveSvgContent}, since sanitize-html's own
|
|
133
|
+
// scheme allowlist does not distinguish "no scheme" from "fragment-only").
|
|
134
|
+
allowedSchemesByTag: { use: [] },
|
|
135
|
+
allowProtocolRelative: false,
|
|
136
|
+
nonTextTags: [
|
|
137
|
+
'script',
|
|
138
|
+
'style',
|
|
139
|
+
'foreignObject',
|
|
140
|
+
'image',
|
|
141
|
+
'animate',
|
|
142
|
+
'animateTransform',
|
|
143
|
+
'animateMotion',
|
|
144
|
+
'set',
|
|
145
|
+
'iframe',
|
|
146
|
+
],
|
|
147
|
+
disallowedTagsMode: 'discard',
|
|
148
|
+
parser: { xmlMode: true, decodeEntities: true },
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
/** @type {readonly RegExp[]} fail-closed backstop patterns; a hit here is
|
|
152
|
+
* this module's own configuration defect, never expected of admitted
|
|
153
|
+
* content — mirrors `content-security.js`'s
|
|
154
|
+
* `RESIDUAL_ACTIVE_CONTENT_PATTERNS` backstop pattern. */
|
|
155
|
+
const RESIDUAL_ACTIVE_SVG_PATTERNS = Object.freeze([
|
|
156
|
+
/<script[\s>/]/i,
|
|
157
|
+
/<style[\s>/]/i,
|
|
158
|
+
/<foreignobject[\s>/]/i,
|
|
159
|
+
/<image[\s>/]/i,
|
|
160
|
+
/<iframe[\s>/]/i,
|
|
161
|
+
/\son[a-z]+\s*=/i,
|
|
162
|
+
/url\(\s*['"]?(?:https?:)?\/\//i,
|
|
163
|
+
/url\(\s*['"]?data:/i,
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* @param {string} sanitized the sanitize-html output
|
|
168
|
+
* @returns {void}
|
|
169
|
+
*/
|
|
170
|
+
function assertNoResidualActiveSvgContent(sanitized) {
|
|
171
|
+
for (const pattern of RESIDUAL_ACTIVE_SVG_PATTERNS) {
|
|
172
|
+
if (pattern.test(sanitized)) {
|
|
173
|
+
throw new ThemeAssetError(
|
|
174
|
+
THEME_SVG_REJECTED_CODE,
|
|
175
|
+
`sanitized theme SVG still matches a forbidden pattern (${pattern})`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Every href/xlink:href that survived sanitization must be a same-document
|
|
180
|
+
// fragment reference; sanitize-html's own scheme allowlist admits a
|
|
181
|
+
// schemeless value (including a bare fragment) without distinguishing it
|
|
182
|
+
// from, say, a schemeless relative path, so this is checked explicitly.
|
|
183
|
+
for (const match of sanitized.matchAll(
|
|
184
|
+
/(?:href|xlink:href)\s*=\s*"([^"]*)"/gi,
|
|
185
|
+
)) {
|
|
186
|
+
if (!match[1].startsWith('#')) {
|
|
187
|
+
throw new ThemeAssetError(
|
|
188
|
+
THEME_SVG_REJECTED_CODE,
|
|
189
|
+
`theme SVG href must be a same-document fragment reference, got ${JSON.stringify(match[1])}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reject, rather than silently strip, any of the specific constructs the
|
|
197
|
+
* fix requires an SVG sanitizer to police (no script, no event handlers, no
|
|
198
|
+
* external references): a theme package is untrusted supply-chain input, so
|
|
199
|
+
* a file that *contains* one of these is treated as a rejected asset, not
|
|
200
|
+
* quietly laundered into a smaller, safe file the theme author never
|
|
201
|
+
* reviewed. The allowlist pass below is defense-in-depth on top of this,
|
|
202
|
+
* narrowing an already-clean file to the admitted tag/attribute vocabulary.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} source the raw SVG text
|
|
205
|
+
* @returns {void}
|
|
206
|
+
*/
|
|
207
|
+
function assertNoForbiddenSvgConstructs(source) {
|
|
208
|
+
if (/<!doctype/i.test(source) || /<!entity/i.test(source)) {
|
|
209
|
+
throw new ThemeAssetError(
|
|
210
|
+
THEME_SVG_REJECTED_CODE,
|
|
211
|
+
'theme SVG must not declare a DOCTYPE or an XML entity',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
if (
|
|
215
|
+
/<\s*(script|style|foreignobject|image|iframe|animate\w*|set)[\s>/]/i.test(
|
|
216
|
+
source,
|
|
217
|
+
)
|
|
218
|
+
) {
|
|
219
|
+
throw new ThemeAssetError(
|
|
220
|
+
THEME_SVG_REJECTED_CODE,
|
|
221
|
+
'theme SVG must not contain a <script>, <style>, <foreignObject>, ' +
|
|
222
|
+
'<image>, <iframe>, animation or <set> element',
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
if (/\son[a-z]+\s*=/i.test(source)) {
|
|
226
|
+
throw new ThemeAssetError(
|
|
227
|
+
THEME_SVG_REJECTED_CODE,
|
|
228
|
+
'theme SVG must not carry an event-handler attribute',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
for (const match of source.matchAll(
|
|
232
|
+
/(?:href|xlink:href)\s*=\s*(['"])(.*?)\1/gis,
|
|
233
|
+
)) {
|
|
234
|
+
if (!match[2].startsWith('#')) {
|
|
235
|
+
throw new ThemeAssetError(
|
|
236
|
+
THEME_SVG_REJECTED_CODE,
|
|
237
|
+
`theme SVG href must be a same-document fragment reference, got ${JSON.stringify(match[2])}`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (/url\(\s*['"]?(?:https?:)?\/\//i.test(source)) {
|
|
242
|
+
throw new ThemeAssetError(
|
|
243
|
+
THEME_SVG_REJECTED_CODE,
|
|
244
|
+
'theme SVG must not reference an external url()',
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
if (/url\(\s*['"]?data:/i.test(source)) {
|
|
248
|
+
throw new ThemeAssetError(
|
|
249
|
+
THEME_SVG_REJECTED_CODE,
|
|
250
|
+
'theme SVG must not embed a data: url()',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Sanitize one theme-declared SVG passive asset's exact bytes against the
|
|
257
|
+
* closed grammar above, failing closed on anything it does not admit.
|
|
258
|
+
*
|
|
259
|
+
* @param {Buffer} bytes the file's raw bytes (already sniffed as `'svg'` by
|
|
260
|
+
* {@link ../media/sniff.js})
|
|
261
|
+
* @returns {Buffer} the sanitized UTF-8 SVG bytes, safe to publish
|
|
262
|
+
*/
|
|
263
|
+
export function sanitizeThemeSvg(bytes) {
|
|
264
|
+
const source = bytes.toString('utf8');
|
|
265
|
+
assertNoForbiddenSvgConstructs(source);
|
|
266
|
+
const sanitized = sanitizeHtml(source, SVG_SANITIZE_OPTIONS);
|
|
267
|
+
if (sanitized.trim().length === 0 || !/<svg[\s>]/i.test(sanitized)) {
|
|
268
|
+
throw new ThemeAssetError(
|
|
269
|
+
THEME_SVG_REJECTED_CODE,
|
|
270
|
+
'theme SVG did not survive sanitization as a root <svg> element',
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
// Fail-closed backstop over the allowlist pass above — a hit here is this
|
|
274
|
+
// module's own configuration defect, never expected of admitted content
|
|
275
|
+
// (the explicit rejection checks above should already have caught it).
|
|
276
|
+
assertNoResidualActiveSvgContent(sanitized);
|
|
277
|
+
return Buffer.from(sanitized, 'utf8');
|
|
278
|
+
}
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* the `RIFF`/`WEBP` container framing and the `VP8 `/`VP8L` chunk header
|
|
6
6
|
* closely enough to reject a malformed or oversized file and to read the
|
|
7
7
|
* format's own declared width/height, but it does not run a full VP8/VP8L
|
|
8
|
-
* entropy decoder —
|
|
9
|
-
*
|
|
8
|
+
* entropy decoder — this renderer's closed passive-theme-asset decoder
|
|
9
|
+
* requires a complete bounded VP8/VP8L bitstream decode for
|
|
10
10
|
* *passive theme assets*; hand-authoring an equivalent, independently
|
|
11
11
|
* verifiable decoder for *author media* was judged out of proportion to
|
|
12
|
-
* this
|
|
12
|
+
* this module's scope and is flagged to the orchestrator as a follow-up
|
|
13
13
|
* rather than attempted partially. What this module still enforces, fully:
|
|
14
14
|
* exact container framing, a single recognized image chunk (any `VP8X`,
|
|
15
15
|
* animation, or a second image chunk rejects), and the shared
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Core-owned chrome message catalog
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* localization can find them").
|
|
2
|
+
* Core-owned chrome message catalog: every user-visible chrome string comes
|
|
3
|
+
* from a message catalog, not an inline literal, so localization can find
|
|
4
|
+
* them.
|
|
6
5
|
*
|
|
7
6
|
* Every user-visible string this package's own generated chrome (skip link,
|
|
8
7
|
* navigation labels, breadcrumb labels, pagination controls, generated
|
|
9
|
-
* listing-page headings, the error page kind, the
|
|
8
|
+
* listing-page headings, the error page kind, the Light/Dark/System
|
|
10
9
|
* appearance control's label and its three option labels) emits is looked
|
|
11
10
|
* up here by key
|
|
12
|
-
* rather than written inline at its call site.
|
|
13
|
-
* catalog, `en` (the only language the golden fixture's own
|
|
14
|
-
* required to exercise); per-content localization of
|
|
11
|
+
* rather than written inline at its call site. This renderer authors
|
|
12
|
+
* exactly one catalog, `en` (the only language the golden fixture's own
|
|
13
|
+
* core chrome is required to exercise); per-content localization of
|
|
14
|
+
* *authored* content is
|
|
15
15
|
* `publication`/`content` data, not core chrome, and full chrome
|
|
16
16
|
* localization (additional catalogs, a locale-selection policy) is future
|
|
17
17
|
* work this module is shaped to admit without changing any call site: every
|