@jini-ai/cms 0.5.2 → 0.5.3
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/CHANGELOG.md +11 -0
- package/README.md +14 -8
- package/dist/comments/errors.d.ts +23 -0
- package/dist/comments/errors.js +25 -0
- package/dist/comments/express/index.d.ts +2 -0
- package/dist/comments/express/index.js +2 -0
- package/dist/comments/express/submit-handler.d.ts +30 -0
- package/dist/comments/express/submit-handler.js +85 -0
- package/dist/comments/hooks.d.ts +39 -0
- package/dist/comments/hooks.js +41 -0
- package/dist/comments/index.d.ts +11 -0
- package/dist/comments/index.js +11 -0
- package/dist/comments/ingress.d.ts +57 -0
- package/dist/comments/ingress.js +173 -0
- package/dist/comments/module.d.ts +94 -0
- package/dist/comments/module.js +73 -0
- package/dist/comments/ports.d.ts +184 -0
- package/dist/comments/ports.js +2 -0
- package/dist/comments/repo.memory.d.ts +70 -0
- package/dist/comments/repo.memory.js +120 -0
- package/dist/comments/sanitize.d.ts +15 -0
- package/dist/comments/sanitize.js +31 -0
- package/dist/comments/spam.heuristic.d.ts +8 -0
- package/dist/comments/spam.heuristic.js +54 -0
- package/dist/comments/sql/index.d.ts +3 -0
- package/dist/comments/sql/index.js +3 -0
- package/dist/comments/sql/repo.d.ts +77 -0
- package/dist/comments/sql/repo.js +190 -0
- package/dist/comments/sql/repo.rows.d.ts +49 -0
- package/dist/comments/sql/repo.rows.js +71 -0
- package/dist/comments/types.d.ts +187 -0
- package/dist/comments/types.js +39 -0
- package/dist/comments/write-service.d.ts +121 -0
- package/dist/comments/write-service.js +144 -0
- package/dist/core/commands/command.d.ts +2 -9
- package/dist/core/commands/command.js +2 -9
- package/dist/core/index.d.ts +2 -1
- package/dist/core/index.js +1 -0
- package/dist/core/keyset-cursor.d.ts +25 -0
- package/dist/core/keyset-cursor.js +20 -0
- package/dist/core/ports.d.ts +30 -0
- package/dist/core/ports.js +0 -1
- package/dist/core/tools/args.d.ts +1 -46
- package/dist/core/tools/args.js +1 -44
- package/dist/media/index.d.ts +0 -3
- package/dist/media/index.js +1 -4
- package/dist/media/node.d.ts +5 -0
- package/dist/media/node.js +6 -0
- package/dist/redirects/capture.d.ts +80 -0
- package/dist/redirects/capture.js +66 -0
- package/dist/redirects/express/index.d.ts +27 -0
- package/dist/redirects/express/index.js +40 -0
- package/dist/redirects/hit-sink.d.ts +56 -0
- package/dist/redirects/hit-sink.js +44 -0
- package/dist/redirects/host-config/index.d.ts +40 -0
- package/dist/redirects/host-config/index.js +145 -0
- package/dist/redirects/index.d.ts +14 -0
- package/dist/redirects/index.js +14 -0
- package/dist/redirects/matcher.d.ts +58 -0
- package/dist/redirects/matcher.js +144 -0
- package/dist/redirects/order.d.ts +30 -0
- package/dist/redirects/order.js +26 -0
- package/dist/redirects/ports.d.ts +242 -0
- package/dist/redirects/ports.internal.d.ts +57 -0
- package/dist/redirects/ports.internal.js +14 -0
- package/dist/redirects/ports.js +2 -0
- package/dist/redirects/redirects.d.ts +226 -0
- package/dist/redirects/redirects.js +590 -0
- package/dist/redirects/referrer-alias.d.ts +31 -0
- package/dist/redirects/referrer-alias.js +33 -0
- package/dist/redirects/repo.memory.d.ts +51 -0
- package/dist/redirects/repo.memory.js +98 -0
- package/dist/redirects/reserved-destination.d.ts +58 -0
- package/dist/redirects/reserved-destination.js +59 -0
- package/dist/redirects/resolver.d.ts +100 -0
- package/dist/redirects/resolver.js +87 -0
- package/dist/redirects/spa/index.d.ts +30 -0
- package/dist/redirects/spa/index.js +27 -0
- package/dist/redirects/sql/index.d.ts +4 -0
- package/dist/redirects/sql/index.js +4 -0
- package/dist/redirects/sql/repo.d.ts +82 -0
- package/dist/redirects/sql/repo.js +143 -0
- package/dist/redirects/sql/repo.rows.d.ts +49 -0
- package/dist/redirects/sql/repo.rows.js +79 -0
- package/dist/redirects/sql/row-types.d.ts +34 -0
- package/dist/redirects/sql/row-types.js +2 -0
- package/dist/redirects/target-gate.d.ts +30 -0
- package/dist/redirects/target-gate.js +85 -0
- package/dist/redirects/types.d.ts +213 -0
- package/dist/redirects/types.js +42 -0
- package/dist/seo/absolute-url.d.ts +58 -0
- package/dist/seo/absolute-url.js +117 -0
- package/dist/seo/dom/index.d.ts +14 -0
- package/dist/seo/dom/index.js +52 -0
- package/dist/seo/errors.d.ts +35 -0
- package/dist/seo/errors.js +35 -0
- package/dist/seo/feed.d.ts +49 -0
- package/dist/seo/feed.js +61 -0
- package/dist/seo/index.d.ts +13 -0
- package/dist/seo/index.js +13 -0
- package/dist/seo/media.d.ts +57 -0
- package/dist/seo/media.js +134 -0
- package/dist/seo/page-head.d.ts +18 -0
- package/dist/seo/page-head.js +114 -0
- package/dist/seo/ports.d.ts +221 -0
- package/dist/seo/ports.js +27 -0
- package/dist/seo/seo.d.ts +73 -0
- package/dist/seo/seo.js +230 -0
- package/dist/seo/sitemap.d.ts +139 -0
- package/dist/seo/sitemap.js +217 -0
- package/dist/seo/types.d.ts +278 -0
- package/dist/seo/types.js +2 -0
- package/dist/seo/write-service.d.ts +79 -0
- package/dist/seo/write-service.js +273 -0
- package/dist/seo/xml-escape.d.ts +16 -0
- package/dist/seo/xml-escape.js +19 -0
- package/dist/settings/express/cms-adapter.d.ts +20 -0
- package/dist/settings/express/cms-adapter.js +124 -0
- package/dist/settings/express/contracts.d.ts +135 -0
- package/dist/settings/express/contracts.js +2 -0
- package/dist/settings/express/http.d.ts +10 -0
- package/dist/settings/express/http.js +211 -0
- package/dist/settings/express/index.d.ts +5 -0
- package/dist/settings/express/index.js +5 -0
- package/dist/settings/express/shared.d.ts +68 -0
- package/dist/settings/express/shared.js +54 -0
- package/dist/settings/express/stream.d.ts +24 -0
- package/dist/settings/express/stream.js +147 -0
- package/dist/settings/index.d.ts +3 -38
- package/dist/settings/index.js +3 -35
- package/dist/taxonomy/write-service.d.ts +2 -30
- package/dist/widgets/authorize-helper.d.ts +42 -0
- package/dist/widgets/authorize-helper.js +16 -0
- package/dist/widgets/concurrency.d.ts +12 -0
- package/dist/widgets/concurrency.js +44 -0
- package/dist/widgets/config-validation.d.ts +34 -0
- package/dist/widgets/config-validation.js +125 -0
- package/dist/widgets/embed-service.d.ts +133 -0
- package/dist/widgets/embed-service.js +460 -0
- package/dist/widgets/embed-validation.d.ts +23 -0
- package/dist/widgets/embed-validation.js +38 -0
- package/dist/widgets/entry-payload.d.ts +103 -0
- package/dist/widgets/entry-payload.js +160 -0
- package/dist/widgets/errors.d.ts +136 -0
- package/dist/widgets/errors.js +136 -0
- package/dist/widgets/host-ports.d.ts +149 -0
- package/dist/widgets/host-ports.js +2 -0
- package/dist/widgets/html/html-embeds.d.ts +247 -0
- package/dist/widgets/html/html-embeds.js +408 -0
- package/dist/widgets/html/index.d.ts +5 -0
- package/dist/widgets/html/index.js +4 -0
- package/dist/widgets/html/legacy-widget-markers.d.ts +8 -0
- package/dist/widgets/html/legacy-widget-markers.js +45 -0
- package/dist/widgets/html/resolve-html-page-embeds.d.ts +42 -0
- package/dist/widgets/html/resolve-html-page-embeds.js +57 -0
- package/dist/widgets/index.d.ts +20 -0
- package/dist/widgets/index.js +19 -0
- package/dist/widgets/markers/html-attributes.d.ts +98 -0
- package/dist/widgets/markers/html-attributes.js +216 -0
- package/dist/widgets/markers/index.d.ts +3 -0
- package/dist/widgets/markers/index.js +3 -0
- package/dist/widgets/markers/marker.d.ts +496 -0
- package/dist/widgets/markers/marker.js +780 -0
- package/dist/widgets/page-embed-types.d.ts +42 -0
- package/dist/widgets/page-embed-types.js +42 -0
- package/dist/widgets/ports.d.ts +142 -0
- package/dist/widgets/ports.js +2 -0
- package/dist/widgets/read-service.d.ts +81 -0
- package/dist/widgets/read-service.js +79 -0
- package/dist/widgets/region-area-service.d.ts +110 -0
- package/dist/widgets/region-area-service.js +180 -0
- package/dist/widgets/registry.d.ts +217 -0
- package/dist/widgets/registry.js +172 -0
- package/dist/widgets/repo.memory.d.ts +41 -0
- package/dist/widgets/repo.memory.js +48 -0
- package/dist/widgets/resolver-service.d.ts +209 -0
- package/dist/widgets/resolver-service.js +909 -0
- package/dist/widgets/resolvers/contact-form.d.ts +26 -0
- package/dist/widgets/resolvers/contact-form.js +54 -0
- package/dist/widgets/resolvers/create-core-resolvers.d.ts +34 -0
- package/dist/widgets/resolvers/create-core-resolvers.js +11 -0
- package/dist/widgets/resolvers/index.d.ts +65 -0
- package/dist/widgets/resolvers/index.js +155 -0
- package/dist/widgets/resolvers/menu.d.ts +42 -0
- package/dist/widgets/resolvers/menu.js +43 -0
- package/dist/widgets/resolvers/ports.d.ts +82 -0
- package/dist/widgets/resolvers/ports.js +2 -0
- package/dist/widgets/resolvers/recent-entries.d.ts +71 -0
- package/dist/widgets/resolvers/recent-entries.js +165 -0
- package/dist/widgets/resolvers/taxonomy.d.ts +12 -0
- package/dist/widgets/resolvers/taxonomy.js +34 -0
- package/dist/widgets/sql/index.d.ts +3 -0
- package/dist/widgets/sql/index.js +3 -0
- package/dist/widgets/sql/repo.d.ts +41 -0
- package/dist/widgets/sql/repo.js +82 -0
- package/dist/widgets/sql/tables.d.ts +13 -0
- package/dist/widgets/sql/tables.js +2 -0
- package/dist/widgets/types.d.ts +213 -0
- package/dist/widgets/types.js +17 -0
- package/dist/widgets/where-used.d.ts +41 -0
- package/dist/widgets/where-used.js +11 -0
- package/dist/widgets/write-service.d.ts +204 -0
- package/dist/widgets/write-service.js +332 -0
- package/dist/workspace/delete.d.ts +1 -1
- package/package.json +220 -11
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The ONE render-time HTML-attribute allowlist for every free-text `htmlAttributes` field this
|
|
3
|
+
* codebase re-parses at render time: media assets, media post-nodes, and (from this task on) widget
|
|
4
|
+
* post-nodes (2026-09-23 embed-attributes-everywhere plan, decision §2). It replaces two prior,
|
|
5
|
+
* narrower boundaries in the same render path:
|
|
6
|
+
*
|
|
7
|
+
* - `@jini-ai/cms/media`'s `parseMediaHtmlAttributes` (the Jini-side twin of
|
|
8
|
+
* `apps/admin/src/features/media/rules.ts`'s `parseMediaHtmlAttributes`, which this file's
|
|
9
|
+
* tokenizer and name pattern are ported from) — rejected `style`/`id`/`class` outright and failed
|
|
10
|
+
* the WHOLE string closed on the first bad token.
|
|
11
|
+
* - `render.ts`'s own `restrictToDataAriaAttributes` — an extra widget-only restriction down to
|
|
12
|
+
* `data-*`/`aria-*`, now folded into this one list instead of being a second filter layered on top.
|
|
13
|
+
*
|
|
14
|
+
* Two behavior changes from that prior boundary, both owner-directed:
|
|
15
|
+
* 1. **The allowlist widens** to include `class`, `id`, `style`, `title`, `role`, `tabindex`, `lang`,
|
|
16
|
+
* `dir`, `hidden`, `translate` and `draggable` — global HTML attributes an author styling or
|
|
17
|
+
* animating a widget/media embed needs — on top of the open-ended `data-*`/`aria-*` families and
|
|
18
|
+
* today's `<img>`/`<video>`-only extras (`loading`, `decoding`, `playsinline`, `muted`, `loop`,
|
|
19
|
+
* `autoplay`, `poster`). One shared list for every element shape, not a list per tag: the owner
|
|
20
|
+
* rejected per-element name filtering (an extra `loading` on a `<div>` wrapper is harmless).
|
|
21
|
+
* 2. **One bad token drops only that token**, not the whole string. Once a name/value pair has been
|
|
22
|
+
* tokenized, it is self-contained — dropping it cannot change how its neighbors are read — so
|
|
23
|
+
* `data-x="1" onclick="…" style="color:red"` keeps `data-x` and `style`, drops only `onclick`. The
|
|
24
|
+
* exception is `malformed`: the tokenizer itself lost sync (e.g. a stray quote), so token
|
|
25
|
+
* boundaries downstream of that point cannot be trusted (value text could re-parse as a name) —
|
|
26
|
+
* that failure mode still discards everything and fails the WHOLE string closed, same as before.
|
|
27
|
+
*
|
|
28
|
+
* The admin (`apps/admin/src/features/media/rules.ts`) keeps its OWN hand-copied allowlist, unchanged
|
|
29
|
+
* by this task — it is a UX hint only (never gates `save()`, incident `a7cce060`), and the
|
|
30
|
+
* browser/Node split between admin and this server-side file is the existing precedent (see that
|
|
31
|
+
* file's own header). Widening ITS list, and Jini's write-path copy, is tracked separately (plan
|
|
32
|
+
* slice J) — until then, an admin Media-library save with `style` still gets Jini's own 400.
|
|
33
|
+
*/
|
|
34
|
+
/** Why one parsed attribute token was rejected. `unsafe-url` replaces the old `javascript-url` name
|
|
35
|
+
* (it now also covers `vbscript:`/`data:` schemes, not just `javascript:`) — the admin's own i18n
|
|
36
|
+
* message key for it is unchanged. `unsafe-style` is new: a `style` value carrying a known CSS
|
|
37
|
+
* injection/escape vector. `malformed` is the one reason that still discards the WHOLE parsed
|
|
38
|
+
* result, not just its own token — see this file's header for why. */
|
|
39
|
+
export type EmbedHtmlAttributeRejectionReason = "disallowed-name" | "event-handler" | "unsafe-url" | "unsafe-style" | "malformed";
|
|
40
|
+
export interface EmbedHtmlAttributeError {
|
|
41
|
+
reason: EmbedHtmlAttributeRejectionReason;
|
|
42
|
+
/** The exact attribute name (or, for `malformed`, the unparsable fragment) a caller's error
|
|
43
|
+
* message must name — never a generic "invalid input". */
|
|
44
|
+
attribute: string;
|
|
45
|
+
}
|
|
46
|
+
export interface ParsedEmbedHtmlAttributes {
|
|
47
|
+
/** Lowercased attribute name -> value, for every token that WAS accepted. Never all-or-nothing
|
|
48
|
+
* except on `malformed` (see this file's header): a rejected token is simply absent here while
|
|
49
|
+
* every other accepted token in the same string still lands. A boolean attribute (`muted`,
|
|
50
|
+
* written with no `="..."`) maps to `""`. */
|
|
51
|
+
attributes: Record<string, string>;
|
|
52
|
+
/** Every rejection found, in the order encountered — zero or more. Unlike `attributes`, this is
|
|
53
|
+
* never reset to empty except that a `malformed` entry is always the last one recorded (parsing
|
|
54
|
+
* stops there). */
|
|
55
|
+
errors: EmbedHtmlAttributeError[];
|
|
56
|
+
/** The FIRST rejection in `errors`, or `null` when every token was accepted — kept as its own field
|
|
57
|
+
* for the existing admin-hint callers that only ever showed one reason at a time. */
|
|
58
|
+
error: EmbedHtmlAttributeError | null;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Exact-match attribute names accepted beyond the open-ended `data-`/`aria-` prefix families (see
|
|
62
|
+
* {@link isAllowedEmbedHtmlAttributeName}). Two groups, kept in one flat list because the owner
|
|
63
|
+
* rejected per-element filtering (§2): global attributes any element can carry (styling/animation/
|
|
64
|
+
* a11y), and the pre-existing `<img>`/`<video>`-only extras that are harmless, if meaningless, on
|
|
65
|
+
* any other element.
|
|
66
|
+
*/
|
|
67
|
+
export declare const EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES: readonly ["class", "id", "style", "title", "role", "tabindex", "lang", "dir", "hidden", "translate", "draggable", "loading", "decoding", "playsinline", "muted", "loop", "autoplay", "poster"];
|
|
68
|
+
/** Whether `name` is on the allowlist — a well-formed name ({@link EMBED_HTML_ATTRIBUTE_NAME_PATTERN})
|
|
69
|
+
* that is either an exact match against {@link EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES} or carries a
|
|
70
|
+
* `data-`/`aria-` prefix. Case-insensitive, since HTML attribute names are themselves
|
|
71
|
+
* case-insensitive.
|
|
72
|
+
*
|
|
73
|
+
* @complexity O(n) in the name's length (one anchored regex test), then O(1).
|
|
74
|
+
*/
|
|
75
|
+
export declare function isAllowedEmbedHtmlAttributeName({ name }: {
|
|
76
|
+
name: string;
|
|
77
|
+
}, _optional?: Record<string, never>): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Parses free-text HTML attributes (`name="value" name2="value2"`, or a bare boolean `name`) into a
|
|
80
|
+
* validated attribute map, per-token: an accepted token lands in `attributes`, a rejected one lands
|
|
81
|
+
* in `errors` and is simply omitted — this string never fails closed as a whole EXCEPT when the
|
|
82
|
+
* tokenizer itself loses sync (a stray quote or other unparseable fragment between/after matches),
|
|
83
|
+
* in which case `attributes` is `{}` and the sole entry in `errors` has `reason: "malformed"` (see
|
|
84
|
+
* this file's header for why that one case is different).
|
|
85
|
+
*
|
|
86
|
+
* This is a SECURITY boundary, not a syntax convenience: every string this parses was authored in an
|
|
87
|
+
* admin/owner context but rendered on the public site, so free-text HTML-attribute pass-through is a
|
|
88
|
+
* stored-XSS vector the moment it reaches a public page. {@link isAllowedEmbedHtmlAttributeName} is
|
|
89
|
+
* therefore the ONLY path to acceptance for a name; nothing here tries to sanitize an otherwise-
|
|
90
|
+
* disallowed name or value into something safe.
|
|
91
|
+
*
|
|
92
|
+
* @complexity Time O(n) in `text`'s length (one regex pass over it), space O(k) for k accepted
|
|
93
|
+
* attributes plus O(e) for e rejected tokens.
|
|
94
|
+
*/
|
|
95
|
+
export declare function parseEmbedHtmlAttributes({ text }: {
|
|
96
|
+
text: string;
|
|
97
|
+
}, _optional?: Record<string, never>): ParsedEmbedHtmlAttributes;
|
|
98
|
+
//# sourceMappingURL=html-attributes.d.ts.map
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The ONE render-time HTML-attribute allowlist for every free-text `htmlAttributes` field this
|
|
3
|
+
* codebase re-parses at render time: media assets, media post-nodes, and (from this task on) widget
|
|
4
|
+
* post-nodes (2026-09-23 embed-attributes-everywhere plan, decision §2). It replaces two prior,
|
|
5
|
+
* narrower boundaries in the same render path:
|
|
6
|
+
*
|
|
7
|
+
* - `@jini-ai/cms/media`'s `parseMediaHtmlAttributes` (the Jini-side twin of
|
|
8
|
+
* `apps/admin/src/features/media/rules.ts`'s `parseMediaHtmlAttributes`, which this file's
|
|
9
|
+
* tokenizer and name pattern are ported from) — rejected `style`/`id`/`class` outright and failed
|
|
10
|
+
* the WHOLE string closed on the first bad token.
|
|
11
|
+
* - `render.ts`'s own `restrictToDataAriaAttributes` — an extra widget-only restriction down to
|
|
12
|
+
* `data-*`/`aria-*`, now folded into this one list instead of being a second filter layered on top.
|
|
13
|
+
*
|
|
14
|
+
* Two behavior changes from that prior boundary, both owner-directed:
|
|
15
|
+
* 1. **The allowlist widens** to include `class`, `id`, `style`, `title`, `role`, `tabindex`, `lang`,
|
|
16
|
+
* `dir`, `hidden`, `translate` and `draggable` — global HTML attributes an author styling or
|
|
17
|
+
* animating a widget/media embed needs — on top of the open-ended `data-*`/`aria-*` families and
|
|
18
|
+
* today's `<img>`/`<video>`-only extras (`loading`, `decoding`, `playsinline`, `muted`, `loop`,
|
|
19
|
+
* `autoplay`, `poster`). One shared list for every element shape, not a list per tag: the owner
|
|
20
|
+
* rejected per-element name filtering (an extra `loading` on a `<div>` wrapper is harmless).
|
|
21
|
+
* 2. **One bad token drops only that token**, not the whole string. Once a name/value pair has been
|
|
22
|
+
* tokenized, it is self-contained — dropping it cannot change how its neighbors are read — so
|
|
23
|
+
* `data-x="1" onclick="…" style="color:red"` keeps `data-x` and `style`, drops only `onclick`. The
|
|
24
|
+
* exception is `malformed`: the tokenizer itself lost sync (e.g. a stray quote), so token
|
|
25
|
+
* boundaries downstream of that point cannot be trusted (value text could re-parse as a name) —
|
|
26
|
+
* that failure mode still discards everything and fails the WHOLE string closed, same as before.
|
|
27
|
+
*
|
|
28
|
+
* The admin (`apps/admin/src/features/media/rules.ts`) keeps its OWN hand-copied allowlist, unchanged
|
|
29
|
+
* by this task — it is a UX hint only (never gates `save()`, incident `a7cce060`), and the
|
|
30
|
+
* browser/Node split between admin and this server-side file is the existing precedent (see that
|
|
31
|
+
* file's own header). Widening ITS list, and Jini's write-path copy, is tracked separately (plan
|
|
32
|
+
* slice J) — until then, an admin Media-library save with `style` still gets Jini's own 400.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Exact-match attribute names accepted beyond the open-ended `data-`/`aria-` prefix families (see
|
|
36
|
+
* {@link isAllowedEmbedHtmlAttributeName}). Two groups, kept in one flat list because the owner
|
|
37
|
+
* rejected per-element filtering (§2): global attributes any element can carry (styling/animation/
|
|
38
|
+
* a11y), and the pre-existing `<img>`/`<video>`-only extras that are harmless, if meaningless, on
|
|
39
|
+
* any other element.
|
|
40
|
+
*/
|
|
41
|
+
export const EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES = [
|
|
42
|
+
// Global attributes.
|
|
43
|
+
"class",
|
|
44
|
+
"id",
|
|
45
|
+
"style",
|
|
46
|
+
"title",
|
|
47
|
+
"role",
|
|
48
|
+
"tabindex",
|
|
49
|
+
"lang",
|
|
50
|
+
"dir",
|
|
51
|
+
"hidden",
|
|
52
|
+
"translate",
|
|
53
|
+
"draggable",
|
|
54
|
+
// Pre-existing <img>/<video> extras, kept everywhere (one list, not one per element).
|
|
55
|
+
"loading",
|
|
56
|
+
"decoding",
|
|
57
|
+
"playsinline",
|
|
58
|
+
"muted",
|
|
59
|
+
"loop",
|
|
60
|
+
"autoplay",
|
|
61
|
+
"poster",
|
|
62
|
+
];
|
|
63
|
+
/**
|
|
64
|
+
* The shape EVERY accepted attribute name must have, checked before allowlist membership (ported
|
|
65
|
+
* unchanged from the 2026-09-07 stored-XSS fix in `apps/admin/src/features/media/rules.ts`). The
|
|
66
|
+
* `data-`/`aria-` families are open-ended by design — no fixed suffix list — so the prefix check
|
|
67
|
+
* alone would accept whatever characters {@link HTML_ATTRIBUTE_TOKEN}'s name class (`[^\s="']+`,
|
|
68
|
+
* which excludes only whitespace, `=` and quotes) lets through; `<`, `>` and `/` are all legal in
|
|
69
|
+
* that class, and the renderer templates a name into ` name="value"` escaping only the VALUE — so a
|
|
70
|
+
* stored `data-x><svg/onload=alert(1)` would close the tag and open a live `<svg onload>`.
|
|
71
|
+
* Constraining the NAME to characters a real HTML attribute name can contain is what makes escaping
|
|
72
|
+
* only the value sufficient.
|
|
73
|
+
*/
|
|
74
|
+
const EMBED_HTML_ATTRIBUTE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
75
|
+
/** Whether `name` is on the allowlist — a well-formed name ({@link EMBED_HTML_ATTRIBUTE_NAME_PATTERN})
|
|
76
|
+
* that is either an exact match against {@link EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES} or carries a
|
|
77
|
+
* `data-`/`aria-` prefix. Case-insensitive, since HTML attribute names are themselves
|
|
78
|
+
* case-insensitive.
|
|
79
|
+
*
|
|
80
|
+
* @complexity O(n) in the name's length (one anchored regex test), then O(1).
|
|
81
|
+
*/
|
|
82
|
+
export function isAllowedEmbedHtmlAttributeName({ name }, _optional = {}) {
|
|
83
|
+
const lower = name.toLowerCase();
|
|
84
|
+
if (!EMBED_HTML_ATTRIBUTE_NAME_PATTERN.test(lower))
|
|
85
|
+
return false;
|
|
86
|
+
if (lower.startsWith("data-") || lower.startsWith("aria-"))
|
|
87
|
+
return true;
|
|
88
|
+
return EMBED_HTML_ATTRIBUTE_ALLOWED_NAMES.includes(lower);
|
|
89
|
+
}
|
|
90
|
+
/** The highest ASCII code point {@link isUnsafeUrlValue} strips: every C0 control character plus
|
|
91
|
+
* the ordinary space, i.e. code points `0`-`32` inclusive. Named rather than written as an inline
|
|
92
|
+
* regex control-character range so the linter never has to reason about a literal control
|
|
93
|
+
* character inside a pattern. */
|
|
94
|
+
const ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT = 0x20;
|
|
95
|
+
/**
|
|
96
|
+
* Whether `value`'s scheme, once every ASCII whitespace/control character (code points `0`-`32`) is
|
|
97
|
+
* stripped and the result lowercased, starts with `javascript:`, `vbscript:` or `data:` — the three
|
|
98
|
+
* schemes a browser will still execute or navigate to even when the URL carries interior whitespace
|
|
99
|
+
* a naive end-trim (the old `.trim()`-only check) would miss, e.g. `java\tscript:x`: browsers strip
|
|
100
|
+
* tabs and newlines from inside a URL before parsing its scheme, so the check must too. Filters by
|
|
101
|
+
* code point rather than a regex character class so no literal control character ever has to appear
|
|
102
|
+
* in a pattern.
|
|
103
|
+
*
|
|
104
|
+
* @complexity O(n) in the value's length (one filter pass, one prefix test).
|
|
105
|
+
*/
|
|
106
|
+
function isUnsafeUrlValue(value) {
|
|
107
|
+
let stripped = "";
|
|
108
|
+
for (const char of value) {
|
|
109
|
+
if ((char.codePointAt(0) ?? 0) > ASCII_WHITESPACE_OR_CONTROL_MAX_CODE_POINT)
|
|
110
|
+
stripped += char;
|
|
111
|
+
}
|
|
112
|
+
stripped = stripped.toLowerCase();
|
|
113
|
+
return stripped.startsWith("javascript:") || stripped.startsWith("vbscript:") || stripped.startsWith("data:");
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Whether a `style` value carries a known CSS injection or CSS-escape-bypass vector:
|
|
117
|
+
* `expression(...)` (legacy IE script-in-CSS), a `javascript:`/`vbscript:` value inside a CSS
|
|
118
|
+
* `url(...)`, `-moz-binding`/`behavior:` (XBL/HTC script bindings), `@import` (pulls in a second,
|
|
119
|
+
* unvetted stylesheet), or ANY backslash — a CSS escape sequence (`\65` is `e`) can respell any of
|
|
120
|
+
* the above past a plain substring check, so a backslash anywhere in the value is rejected outright
|
|
121
|
+
* rather than trying to first decode every possible CSS escape. Plain `url(...)` values (background
|
|
122
|
+
* images) are otherwise allowed — authors here are the owner/admins, not anonymous input.
|
|
123
|
+
*
|
|
124
|
+
* @complexity O(n) in the value's length (one regex test).
|
|
125
|
+
*/
|
|
126
|
+
const UNSAFE_STYLE_PATTERN = /expression\(|javascript:|vbscript:|-moz-binding|behavior:|@import|\\/i;
|
|
127
|
+
function isUnsafeStyleValue(value) {
|
|
128
|
+
return UNSAFE_STYLE_PATTERN.test(value);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Classifies one already-tokenized `name`/`value` pair into a rejection reason, or `null` when it is
|
|
132
|
+
* accepted. Split out of {@link parseEmbedHtmlAttributes} into its own function, and into three
|
|
133
|
+
* single-purpose checks ({@link isAllowedEmbedHtmlAttributeName}, {@link isUnsafeUrlValue},
|
|
134
|
+
* {@link isUnsafeStyleValue}), so each rejection reason is its own directly testable branch and this
|
|
135
|
+
* function's own complexity stays low.
|
|
136
|
+
*
|
|
137
|
+
* Order matters: `on*` is checked FIRST (so an `onerror` always reports as `event-handler`, the more
|
|
138
|
+
* actionable reason, never the generic `disallowed-name`), then the URL-scheme check runs against
|
|
139
|
+
* EVERY value regardless of name (matching the pre-existing `javascript:` check this is ported from
|
|
140
|
+
* — the only allowlisted attribute with a URL-shaped value is `poster`, but checking unconditionally
|
|
141
|
+
* costs nothing and adds no gap), then allowlist membership, then — only for an already-allowed
|
|
142
|
+
* `style` — the CSS-specific unsafe-value check.
|
|
143
|
+
*
|
|
144
|
+
* @complexity O(1) — four fixed checks against one already-extracted token; each check's own O(n) in
|
|
145
|
+
* the token's length is linear, not nested.
|
|
146
|
+
*/
|
|
147
|
+
function classifyEmbedHtmlAttributeToken(name, value) {
|
|
148
|
+
if (name.startsWith("on"))
|
|
149
|
+
return { reason: "event-handler", attribute: name };
|
|
150
|
+
if (isUnsafeUrlValue(value))
|
|
151
|
+
return { reason: "unsafe-url", attribute: name };
|
|
152
|
+
if (!isAllowedEmbedHtmlAttributeName({ name: name }))
|
|
153
|
+
return { reason: "disallowed-name", attribute: name };
|
|
154
|
+
if (name === "style" && isUnsafeStyleValue(value))
|
|
155
|
+
return { reason: "unsafe-style", attribute: name };
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
/** Matches one `name`, or one `name="value"`/`name='value'`/`name=value` pair — the same loose shape
|
|
159
|
+
* real HTML attribute syntax allows, ported unchanged from the admin's tokenizer. */
|
|
160
|
+
const HTML_ATTRIBUTE_TOKEN = /([^\s="']+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/g;
|
|
161
|
+
/**
|
|
162
|
+
* Parses free-text HTML attributes (`name="value" name2="value2"`, or a bare boolean `name`) into a
|
|
163
|
+
* validated attribute map, per-token: an accepted token lands in `attributes`, a rejected one lands
|
|
164
|
+
* in `errors` and is simply omitted — this string never fails closed as a whole EXCEPT when the
|
|
165
|
+
* tokenizer itself loses sync (a stray quote or other unparseable fragment between/after matches),
|
|
166
|
+
* in which case `attributes` is `{}` and the sole entry in `errors` has `reason: "malformed"` (see
|
|
167
|
+
* this file's header for why that one case is different).
|
|
168
|
+
*
|
|
169
|
+
* This is a SECURITY boundary, not a syntax convenience: every string this parses was authored in an
|
|
170
|
+
* admin/owner context but rendered on the public site, so free-text HTML-attribute pass-through is a
|
|
171
|
+
* stored-XSS vector the moment it reaches a public page. {@link isAllowedEmbedHtmlAttributeName} is
|
|
172
|
+
* therefore the ONLY path to acceptance for a name; nothing here tries to sanitize an otherwise-
|
|
173
|
+
* disallowed name or value into something safe.
|
|
174
|
+
*
|
|
175
|
+
* @complexity Time O(n) in `text`'s length (one regex pass over it), space O(k) for k accepted
|
|
176
|
+
* attributes plus O(e) for e rejected tokens.
|
|
177
|
+
*/
|
|
178
|
+
export function parseEmbedHtmlAttributes({ text }, _optional = {}) {
|
|
179
|
+
const trimmed = text.trim();
|
|
180
|
+
if (trimmed === "")
|
|
181
|
+
return { attributes: {}, errors: [], error: null };
|
|
182
|
+
const attributes = {};
|
|
183
|
+
const errors = [];
|
|
184
|
+
HTML_ATTRIBUTE_TOKEN.lastIndex = 0;
|
|
185
|
+
let consumed = 0;
|
|
186
|
+
// A `for` loop (rather than `while ((match = …exec(trimmed)) !== null)`) keeps every assignment
|
|
187
|
+
// to `match` in statement position, never inside the loop's own test expression.
|
|
188
|
+
for (let match = HTML_ATTRIBUTE_TOKEN.exec(trimmed); match !== null; match = HTML_ATTRIBUTE_TOKEN.exec(trimmed)) {
|
|
189
|
+
// Non-whitespace text between the previous match and this one is a fragment the token pattern
|
|
190
|
+
// could not parse as a name (e.g. a stray quote) — the tokenizer has lost sync, so everything
|
|
191
|
+
// parsed so far is discarded rather than trusted (see this file's header).
|
|
192
|
+
const skipped = trimmed.slice(consumed, match.index);
|
|
193
|
+
if (skipped.trim() !== "") {
|
|
194
|
+
const malformed = { reason: "malformed", attribute: skipped.trim() };
|
|
195
|
+
errors.push(malformed);
|
|
196
|
+
return { attributes: {}, errors, error: malformed };
|
|
197
|
+
}
|
|
198
|
+
consumed = match.index + match[0].length;
|
|
199
|
+
const name = (match[1] ?? "").toLowerCase();
|
|
200
|
+
const value = match[2] ?? match[3] ?? match[4] ?? "";
|
|
201
|
+
const rejection = classifyEmbedHtmlAttributeToken(name, value);
|
|
202
|
+
if (rejection) {
|
|
203
|
+
errors.push(rejection);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
attributes[name] = value;
|
|
207
|
+
}
|
|
208
|
+
const trailing = trimmed.slice(consumed);
|
|
209
|
+
if (trailing.trim() !== "") {
|
|
210
|
+
const malformed = { reason: "malformed", attribute: trailing.trim() };
|
|
211
|
+
errors.push(malformed);
|
|
212
|
+
return { attributes: {}, errors, error: malformed };
|
|
213
|
+
}
|
|
214
|
+
return { attributes, errors, error: errors[0] ?? null };
|
|
215
|
+
}
|
|
216
|
+
//# sourceMappingURL=html-attributes.js.map
|