@marianmeres/safe-html 0.2.0 → 0.3.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/AGENTS.md +17 -5
- package/API.md +7 -0
- package/README.md +24 -1
- package/dist/helpers.js +7 -3
- package/dist/kit.js +6 -2
- package/dist/render.d.ts +4 -0
- package/dist/render.js +16 -3
- package/dist/scanner.d.ts +15 -1
- package/dist/scanner.js +121 -9
- package/dist/trusted.d.ts +16 -4
- package/dist/trusted.js +31 -5
- package/docs/design.md +90 -21
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -18,11 +18,14 @@ src/types.ts public types (Trusted, Renderable, SlotContext, HtmlKit, …)
|
|
|
18
18
|
src/trusted.ts TrustedValue (brand via Symbol.for keys, private fields), isTrusted
|
|
19
19
|
src/escape.ts escapeHtml
|
|
20
20
|
src/url.ts URL policy: urlScheme (URL-parser-accurate), vetUrl, createPolicy
|
|
21
|
-
src/scanner.ts context analysis: tokenizer model, slot rules, neutrality,
|
|
21
|
+
src/scanner.ts context analysis: tokenizer model, slot rules, neutrality, <noscript>,
|
|
22
|
+
<select>, cache
|
|
22
23
|
src/render.ts value × context rules (renderValue)
|
|
23
24
|
src/helpers.ts policy-free helpers: jsonScript, scriptText, styleText, unsafeRaw, join
|
|
24
25
|
src/kit.ts createHtml (html, attrs, url, srcset bound to a policy) + default kit
|
|
25
|
-
tests/ golden scanner tests, render table, helpers, SVG/MathML,
|
|
26
|
+
tests/ golden scanner tests, render table, helpers, SVG/MathML, <noscript>, <select>,
|
|
27
|
+
parse5
|
|
28
|
+
invariance (parsed with scripting enabled and disabled)
|
|
26
29
|
bench/ deno bench vs hand-written concatenation
|
|
27
30
|
docs/design.md normative rules (read before changing scanner or render behavior)
|
|
28
31
|
```
|
|
@@ -66,10 +69,19 @@ docs/design.md normative rules (read before changing scanner or render behavio
|
|
|
66
69
|
1. Read docs/design.md §3; locate the state in `Scanner.chunk()`, the slot rule in
|
|
67
70
|
`Scanner.slot()` / `valueSlot()`, tag effects in `emitTag()` / `endTag()`.
|
|
68
71
|
2. Add golden tests (context or exact error substring), and a corpus template in
|
|
69
|
-
`tests/invariance.test.ts` if a new context shape is involved.
|
|
72
|
+
`tests/invariance.test.ts` if a new context shape is involved. Check the fuzz alphabet
|
|
73
|
+
(`PIECES`, `VECTORS` in `tests/_util.ts`) can spell the breakout, and that the vector is in
|
|
74
|
+
the nesting test's sample: break the rule on purpose and see the suite fail.
|
|
70
75
|
3. Consider the neutrality scan (`new Scanner(strings, true)`): a rule that differs between
|
|
71
|
-
HTML and foreign content must make the two scans disagree, not silently match.
|
|
72
|
-
|
|
76
|
+
HTML and foreign content must make the two scans disagree, not silently match. Consider
|
|
77
|
+
`<noscript>` too: its content must be safe both as markup and as raw text up to the first
|
|
78
|
+
`</noscript` (design §3.7). And tree-construction modes that ignore start tags (older
|
|
79
|
+
`<select>` parsing, design §3.8): parse5 parses that way, newer browsers don't.
|
|
80
|
+
4. A fragment property that a nested fragment can break (neutral, noscript-safe, select-safe)
|
|
81
|
+
is a flag on the trusted value, computed per template in `compute()` and propagated
|
|
82
|
+
through `RenderState` in `kit.ts` and `join()`. A new flag gets a new `Symbol.for` key; a
|
|
83
|
+
value from an older copy of the package must read as the refusing default.
|
|
84
|
+
5. Update docs/design.md (and §8 if it departs from earlier behavior).
|
|
73
85
|
|
|
74
86
|
### Change the value × context table
|
|
75
87
|
|
package/API.md
CHANGED
|
@@ -39,6 +39,13 @@ for the context its slot sits in, as worked out once per call site from the stat
|
|
|
39
39
|
Everywhere: `null`, `undefined`, `false` and `true` render nothing; `unsafeRaw()` is inserted
|
|
40
40
|
verbatim. Anything else throws `HtmlValueError`.
|
|
41
41
|
|
|
42
|
+
Three places restrict which `html` fragments a `text` slot accepts: inside `<svg>`/`<math>`
|
|
43
|
+
only fragments that parse the same there; inside `<noscript>` only fragments without `script`
|
|
44
|
+
or `style` slots or a `</noscript`; inside `<select>` only fragments without the tags that older
|
|
45
|
+
parsers ignore there (`<title>`, `<style>`, `<svg>`, …) (design.md §3.6–§3.8). The property
|
|
46
|
+
carries through nesting and `join()`. `<noscript>` content also refuses `script` and `style`
|
|
47
|
+
slots of its own, and `<select>` content those tags.
|
|
48
|
+
|
|
42
49
|
**Throws:** `HtmlTemplateError` if the template is refused (slot in an unquoted attribute,
|
|
43
50
|
event handler, tag name, comment, …) or `html` is not called as a tagged template;
|
|
44
51
|
`HtmlValueError` if a value has no rendering in its context.
|
package/README.md
CHANGED
|
@@ -23,6 +23,9 @@ deno add jsr:@marianmeres/safe-html
|
|
|
23
23
|
npm install @marianmeres/safe-html
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
`deno fmt` reformats the contents of `html`-tagged templates, which changes your output. Start
|
|
27
|
+
template files with `// deno-fmt-ignore-file` (see [Notes](#notes)).
|
|
28
|
+
|
|
26
29
|
## Usage
|
|
27
30
|
|
|
28
31
|
<!-- deno-fmt-ignore-start -->
|
|
@@ -93,6 +96,8 @@ html`<a href="javascript:go('${id}')">`; // HtmlTemplateError: static scheme n
|
|
|
93
96
|
html`<!-- ${note} -->`; // HtmlTemplateError: slot in comment
|
|
94
97
|
html`<div class="x">${a}<b title="`; // HtmlTemplateError: template ends inside a tag
|
|
95
98
|
html`<svg><style>${styleText(css)}</style></svg>`; // HtmlTemplateError: not raw text inside SVG
|
|
99
|
+
html`<noscript><script>${jsonScript(d)}</script></noscript>`; // HtmlTemplateError: script slot in <noscript>
|
|
100
|
+
html`<select><style>…</style></select>`; // HtmlTemplateError: older parsers ignore <style> there
|
|
96
101
|
html`<script>let n = ${n};</script>`; // HtmlValueError: script takes jsonScript()/scriptText()
|
|
97
102
|
html`<p title="${html`<b>x</b>`}">`; // HtmlValueError: html fragment in an attribute value
|
|
98
103
|
html`<p>${new Date()}</p>`; // HtmlValueError (and a type error)
|
|
@@ -113,7 +118,7 @@ include values.
|
|
|
113
118
|
- inject script through an event-handler attribute (those slots are rejected);
|
|
114
119
|
- put a URL with a scheme outside the allowlist into a URL attribute (default: `http`,
|
|
115
120
|
`https`, `mailto`, `tel`), including through `srcset` or `attrs()`;
|
|
116
|
-
- break out of `<script>`, `<style>`, `<title>` or `<
|
|
121
|
+
- break out of `<script>`, `<style>`, `<title>`, `<textarea>` or `<noscript>` content;
|
|
117
122
|
- change how SVG/MathML content parses.
|
|
118
123
|
|
|
119
124
|
This is tested against [parse5](https://github.com/inikulin/parse5), a spec-compliant HTML
|
|
@@ -174,6 +179,18 @@ export const { html, attrs, url, srcset } = createHtml({
|
|
|
174
179
|
| `{@html anythingElse}` | `${unsafeRaw(s)}` (review every one) |
|
|
175
180
|
| `{#await}` | not supported; await the data before rendering |
|
|
176
181
|
|
|
182
|
+
Differences in the output:
|
|
183
|
+
|
|
184
|
+
- **HTML comments are emitted.** Svelte strips `<!-- … -->` from SSR output; `html` keeps
|
|
185
|
+
them, so a comment in a template ships to every visitor. Move rationale into a JS comment
|
|
186
|
+
(e.g. the docblock of the function that returns the fragment).
|
|
187
|
+
- **Boolean attributes**: Svelte writes `selected=""`, `attrs({ selected: true })` writes
|
|
188
|
+
`selected`. Same DOM, different bytes.
|
|
189
|
+
- **Quotes in text**: Svelte writes `'` and `"` in element text as is; here they are `'`
|
|
190
|
+
and `"` in every context (see [docs/design.md](docs/design.md) §5.1). Same DOM.
|
|
191
|
+
|
|
192
|
+
Tests that pin markup bytes need adjusting for these; tests that compare parsed text don't.
|
|
193
|
+
|
|
177
194
|
## Notes
|
|
178
195
|
|
|
179
196
|
- **Booleans render nothing, `true` included** (as in JSX). Numbers render, so
|
|
@@ -181,6 +198,12 @@ export const { html, attrs, url, srcset } = createHtml({
|
|
|
181
198
|
- An **untagged** template literal around a fragment (`` `<p>${frag}</p>` ``) makes a plain
|
|
182
199
|
string, which `html` escapes: visibly double-escaped, never an injection.
|
|
183
200
|
- Every attribute value that contains a slot must be quoted.
|
|
201
|
+
- **`<noscript>`** content works like any other markup, except that it can't hold `<script>`
|
|
202
|
+
or `<style>` slots, or `html` fragments that have them (with scripting enabled, the element
|
|
203
|
+
ends at the first `</noscript`, even one inside a script string).
|
|
204
|
+
- **`<select>`** can't contain `<title>`, `<style>`, `<svg>`, `<math>`, `<template>` or the
|
|
205
|
+
other tags that older parsers ignore there (their content would be parsed as markup), nor
|
|
206
|
+
`html` fragments that have them. Options, optgroups, text and `<script>` are fine.
|
|
184
207
|
- `attrs()` output starts with a space. When an attribute appears both statically and in
|
|
185
208
|
`attrs()`, the first one in the tag wins (HTML parsing rules).
|
|
186
209
|
- Whitespace is preserved exactly; nothing is minified.
|
package/dist/helpers.js
CHANGED
|
@@ -5,7 +5,7 @@ import { describe, HtmlValueError } from "./errors.js";
|
|
|
5
5
|
import { escapeHtml } from "./escape.js";
|
|
6
6
|
import { renderValue } from "./render.js";
|
|
7
7
|
import { slotInfo } from "./scanner.js";
|
|
8
|
-
import { isNeutral, isTrusted, makeTrusted, trustedString } from "./trusted.js";
|
|
8
|
+
import { isNeutral, isNoscriptSafe, isSelectSafe, isTrusted, makeTrusted, trustedString, } from "./trusted.js";
|
|
9
9
|
const JSON_ESCAPES = {
|
|
10
10
|
"<": "\\u003c",
|
|
11
11
|
">": "\\u003e",
|
|
@@ -96,7 +96,7 @@ export function join(values, separator = "") {
|
|
|
96
96
|
if (!Array.isArray(values)) {
|
|
97
97
|
throw new HtmlValueError("join() takes an array", { received: describe(values) });
|
|
98
98
|
}
|
|
99
|
-
const st = { neutral: true };
|
|
99
|
+
const st = { neutral: true, noscript: true, select: true };
|
|
100
100
|
let sep;
|
|
101
101
|
if (typeof separator === "string")
|
|
102
102
|
sep = escapeHtml(separator);
|
|
@@ -104,6 +104,10 @@ export function join(values, separator = "") {
|
|
|
104
104
|
sep = trustedString(separator);
|
|
105
105
|
if (!isNeutral(separator))
|
|
106
106
|
st.neutral = false;
|
|
107
|
+
if (!isNoscriptSafe(separator))
|
|
108
|
+
st.noscript = false;
|
|
109
|
+
if (!isSelectSafe(separator))
|
|
110
|
+
st.select = false;
|
|
107
111
|
}
|
|
108
112
|
else {
|
|
109
113
|
throw new HtmlValueError("join(): the separator must be a string or html``", {
|
|
@@ -120,5 +124,5 @@ export function join(values, separator = "") {
|
|
|
120
124
|
out += first ? r : sep + r;
|
|
121
125
|
first = false;
|
|
122
126
|
}
|
|
123
|
-
return makeTrusted("html", out, st.neutral);
|
|
127
|
+
return makeTrusted("html", out, st.neutral, st.noscript, st.select);
|
|
124
128
|
}
|
package/dist/kit.js
CHANGED
|
@@ -29,7 +29,11 @@ export function createHtml(options = {}) {
|
|
|
29
29
|
if (values.length !== slots.length) {
|
|
30
30
|
throw templateError(strings, "html must be called as a tagged template: html`…`");
|
|
31
31
|
}
|
|
32
|
-
const st = {
|
|
32
|
+
const st = {
|
|
33
|
+
neutral: a.neutral,
|
|
34
|
+
noscript: a.noscript,
|
|
35
|
+
select: a.select,
|
|
36
|
+
};
|
|
33
37
|
let out = strings[0];
|
|
34
38
|
for (let i = 0; i < slots.length; i++) {
|
|
35
39
|
const slot = slots[i];
|
|
@@ -38,7 +42,7 @@ export function createHtml(options = {}) {
|
|
|
38
42
|
}
|
|
39
43
|
out += renderValue(values[i], slot, i, policy, st) + strings[i + 1];
|
|
40
44
|
}
|
|
41
|
-
return makeTrusted("html", out, st.neutral);
|
|
45
|
+
return makeTrusted("html", out, st.neutral, st.noscript, st.select);
|
|
42
46
|
}
|
|
43
47
|
function attrValue(lower, value, n) {
|
|
44
48
|
if (lower === "srcset" || lower === "imagesrcset") {
|
package/dist/render.d.ts
CHANGED
|
@@ -4,6 +4,10 @@ import { type UrlPolicy } from "./url.js";
|
|
|
4
4
|
export interface RenderState {
|
|
5
5
|
/** Stays true while every HTML fragment placed in a text slot is neutral. */
|
|
6
6
|
neutral: boolean;
|
|
7
|
+
/** Stays true while every HTML fragment placed in a text slot is noscript-safe. */
|
|
8
|
+
noscript: boolean;
|
|
9
|
+
/** Stays true while every HTML fragment placed in a text slot is select-safe. */
|
|
10
|
+
select: boolean;
|
|
7
11
|
}
|
|
8
12
|
/** Renders one interpolated value for a slot. */
|
|
9
13
|
export declare function renderValue(v: unknown, slot: SlotInfo, index: number | undefined, policy: UrlPolicy | undefined, st: RenderState, depth?: number): string;
|
package/dist/render.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { describe, HtmlValueError } from "./errors.js";
|
|
6
6
|
import { escapeHtml } from "./escape.js";
|
|
7
|
-
import { isNeutral, isTrusted, trustedString } from "./trusted.js";
|
|
7
|
+
import { isNeutral, isNoscriptSafe, isSelectSafe, isTrusted, trustedString, } from "./trusted.js";
|
|
8
8
|
import { vetUrl } from "./url.js";
|
|
9
9
|
/** Guards against cyclic arrays. */
|
|
10
10
|
const MAX_DEPTH = 100;
|
|
@@ -75,6 +75,16 @@ function renderTrusted(v, slot, st) {
|
|
|
75
75
|
return undefined;
|
|
76
76
|
st.neutral = false;
|
|
77
77
|
}
|
|
78
|
+
if (!isNoscriptSafe(v)) {
|
|
79
|
+
if (slot.noscript)
|
|
80
|
+
return undefined;
|
|
81
|
+
st.noscript = false;
|
|
82
|
+
}
|
|
83
|
+
if (!isSelectSafe(v)) {
|
|
84
|
+
if (slot.select)
|
|
85
|
+
return undefined;
|
|
86
|
+
st.select = false;
|
|
87
|
+
}
|
|
78
88
|
return s;
|
|
79
89
|
case "attrs":
|
|
80
90
|
return ctx === "attr-list" ? s : undefined;
|
|
@@ -107,8 +117,11 @@ function refuse(v, slot, index) {
|
|
|
107
117
|
}
|
|
108
118
|
else if (isTrusted(v)) {
|
|
109
119
|
if (v.kind === "html" && ctx === "text") {
|
|
110
|
-
msg =
|
|
111
|
-
"this HTML fragment can't go inside <svg>/<math>: it contains markup that parses differently there (e.g. <style>, <p>)"
|
|
120
|
+
msg = slot.foreign && !isNeutral(v)
|
|
121
|
+
? "this HTML fragment can't go inside <svg>/<math>: it contains markup that parses differently there (e.g. <style>, <p>)"
|
|
122
|
+
: slot.noscript && !isNoscriptSafe(v)
|
|
123
|
+
? 'this HTML fragment can\'t go inside <noscript>: it has a <script>/<style> slot or a "</noscript" that would end the element'
|
|
124
|
+
: "this HTML fragment can't go inside <select>: it contains a tag that older parsers ignore there (e.g. <style>, <title>, <svg>)";
|
|
112
125
|
}
|
|
113
126
|
else if (v.kind === "html" && (ctx === "attr-value" || ctx === "url")) {
|
|
114
127
|
msg = "an html fragment can't be an attribute value";
|
package/dist/scanner.d.ts
CHANGED
|
@@ -13,6 +13,12 @@
|
|
|
13
13
|
* content. That makes a fragment safe to drop into another template's text slot.
|
|
14
14
|
* - Foreign content: a fragment is "neutral" when a second scan that starts inside SVG/MathML
|
|
15
15
|
* agrees with the HTML scan; only neutral fragments may go into SVG/MathML text slots.
|
|
16
|
+
* - `<noscript>` content is scanned as markup (scripting disabled) and must end where the
|
|
17
|
+
* raw-text reading (scripting enabled) ends it: at its first `</noscript`. Only fragments
|
|
18
|
+
* that can't produce a `</noscript` may go into its text slots.
|
|
19
|
+
* - Inside `<select>`, older parsers ignore the start tags in `SELECT_REFUSED` and parse their
|
|
20
|
+
* content as markup, so those are refused there, and only fragments without them may go
|
|
21
|
+
* into its text slots.
|
|
16
22
|
*/
|
|
17
23
|
import { HtmlTemplateError } from "./errors.js";
|
|
18
24
|
import type { SlotContext } from "./types.js";
|
|
@@ -27,14 +33,22 @@ export interface SlotInfo {
|
|
|
27
33
|
readonly scheme: string;
|
|
28
34
|
/** A `text` slot inside SVG/MathML: HTML fragments must be neutral. */
|
|
29
35
|
readonly foreign: boolean;
|
|
36
|
+
/** A `text` slot inside `<noscript>`: HTML fragments must be noscript-safe. */
|
|
37
|
+
readonly noscript: boolean;
|
|
38
|
+
/** A `text` slot inside `<select>`: HTML fragments must be select-safe. */
|
|
39
|
+
readonly select: boolean;
|
|
30
40
|
}
|
|
31
41
|
/** Creates a slot description (always the same shape, which keeps rendering monomorphic). */
|
|
32
|
-
export declare function slotInfo(context: SlotContext, tag?: string, attr?: string, scheme?: string, foreign?: boolean): SlotInfo;
|
|
42
|
+
export declare function slotInfo(context: SlotContext, tag?: string, attr?: string, scheme?: string, foreign?: boolean, noscript?: boolean, select?: boolean): SlotInfo;
|
|
33
43
|
/** The cached result for one call site. */
|
|
34
44
|
export interface Analysis {
|
|
35
45
|
readonly slots: readonly SlotInfo[];
|
|
36
46
|
/** Parses the same inside SVG/MathML as in HTML (see module doc). */
|
|
37
47
|
readonly neutral: boolean;
|
|
48
|
+
/** May go inside `<noscript>`: can't produce a `</noscript` (see `noscriptSafe`). */
|
|
49
|
+
readonly noscript: boolean;
|
|
50
|
+
/** May go inside `<select>`: has no start tag from `SELECT_REFUSED`. */
|
|
51
|
+
readonly select: boolean;
|
|
38
52
|
}
|
|
39
53
|
/** Attributes whose value is a URL (plus `data` on `<object>`). */
|
|
40
54
|
export declare const URL_ATTRIBUTES: ReadonlySet<string>;
|
package/dist/scanner.js
CHANGED
|
@@ -13,19 +13,28 @@
|
|
|
13
13
|
* content. That makes a fragment safe to drop into another template's text slot.
|
|
14
14
|
* - Foreign content: a fragment is "neutral" when a second scan that starts inside SVG/MathML
|
|
15
15
|
* agrees with the HTML scan; only neutral fragments may go into SVG/MathML text slots.
|
|
16
|
+
* - `<noscript>` content is scanned as markup (scripting disabled) and must end where the
|
|
17
|
+
* raw-text reading (scripting enabled) ends it: at its first `</noscript`. Only fragments
|
|
18
|
+
* that can't produce a `</noscript` may go into its text slots.
|
|
19
|
+
* - Inside `<select>`, older parsers ignore the start tags in `SELECT_REFUSED` and parse their
|
|
20
|
+
* content as markup, so those are refused there, and only fragments without them may go
|
|
21
|
+
* into its text slots.
|
|
16
22
|
*/
|
|
17
23
|
import { HtmlTemplateError } from "./errors.js";
|
|
18
24
|
import { LEADING_C0_SPACE, ONLY_C0_SPACE } from "./url.js";
|
|
19
25
|
/** Creates a slot description (always the same shape, which keeps rendering monomorphic). */
|
|
20
|
-
export function slotInfo(context, tag = "", attr = "", scheme = "", foreign = false) {
|
|
21
|
-
return Object.freeze({ context, tag, attr, scheme, foreign });
|
|
26
|
+
export function slotInfo(context, tag = "", attr = "", scheme = "", foreign = false, noscript = false, select = false) {
|
|
27
|
+
return Object.freeze({ context, tag, attr, scheme, foreign, noscript, select });
|
|
22
28
|
}
|
|
23
29
|
// ---- character classes ------------------------------------------------------------------
|
|
24
30
|
const isWs = (c) => c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
|
|
25
31
|
const isAlpha = (c) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
|
|
26
32
|
const isSchemeChar = (c) => isAlpha(c) || (c >= "0" && c <= "9") || c === "+" || c === "." || c === "-";
|
|
27
33
|
const WS_ONLY = /^[\t\n\f\r ]*$/;
|
|
28
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Elements whose start tag switches the tokenizer (HTML mode only). `<noscript>` is not here:
|
|
36
|
+
* it is handled on its own (see `Scanner.noscript`).
|
|
37
|
+
*/
|
|
29
38
|
const RAW_KIND = new Map([
|
|
30
39
|
["script", "script"],
|
|
31
40
|
["style", "style"],
|
|
@@ -34,10 +43,28 @@ const RAW_KIND = new Map([
|
|
|
34
43
|
["iframe", "unsupported"],
|
|
35
44
|
["noembed", "unsupported"],
|
|
36
45
|
["noframes", "unsupported"],
|
|
37
|
-
["noscript", "unsupported"],
|
|
38
46
|
["xmp", "unsupported"],
|
|
39
47
|
["plaintext", "plaintext"],
|
|
40
48
|
]);
|
|
49
|
+
/**
|
|
50
|
+
* Start tags that older parsers ("in select" insertion mode) ignore inside `<select>`, parsing
|
|
51
|
+
* their content as markup: raw text, RCDATA and foreign content that the scanner would model
|
|
52
|
+
* otherwise. `<template>` is processed there, but a `</select>` inside it is ignored, so the
|
|
53
|
+
* scanner would leave the `<select>` too early. (`<script>` and `<textarea>` parse the same.)
|
|
54
|
+
*/
|
|
55
|
+
const SELECT_REFUSED = new Set([
|
|
56
|
+
"title",
|
|
57
|
+
"style",
|
|
58
|
+
"xmp",
|
|
59
|
+
"iframe",
|
|
60
|
+
"noembed",
|
|
61
|
+
"noframes",
|
|
62
|
+
"noscript",
|
|
63
|
+
"plaintext",
|
|
64
|
+
"svg",
|
|
65
|
+
"math",
|
|
66
|
+
"template",
|
|
67
|
+
]);
|
|
41
68
|
/** Start tags that end SVG/MathML foreign content (`font` only with some attributes; always here). */
|
|
42
69
|
const BREAKOUT = new Set(("b big blockquote body br center code dd div dl dt em embed font h1 h2 h3 h4 h5 h6 head " +
|
|
43
70
|
"hr i img li listing menu meta nobr ol p pre ruby s small span strong strike sub sup " +
|
|
@@ -124,6 +151,13 @@ function endsWithPartial(text, seq) {
|
|
|
124
151
|
const lt = text.lastIndexOf("<");
|
|
125
152
|
return lt !== -1 && seq.startsWith(text.slice(lt).toLowerCase());
|
|
126
153
|
}
|
|
154
|
+
/** Index of the first `</noscript` end tag in `s` at or after `from`, else `Infinity`. */
|
|
155
|
+
function noscriptEnd(s, from) {
|
|
156
|
+
const re = closerRe("noscript");
|
|
157
|
+
re.lastIndex = from;
|
|
158
|
+
const m = re.exec(s);
|
|
159
|
+
return m ? m.index : Infinity;
|
|
160
|
+
}
|
|
127
161
|
/**
|
|
128
162
|
* The static text before the first slot of a URL attribute (normalized, non-empty):
|
|
129
163
|
* returns the lowercased scheme it writes, `""` if it settles the URL as relative, or `false`
|
|
@@ -163,6 +197,15 @@ class Scanner {
|
|
|
163
197
|
raw = "";
|
|
164
198
|
rawKind = "script";
|
|
165
199
|
rawStart = 0;
|
|
200
|
+
// <noscript> (HTML mode). Its content is scanned as markup, which is how it parses with
|
|
201
|
+
// scripting disabled. With scripting enabled it is raw text up to the first "</noscript",
|
|
202
|
+
// so that must be where the markup reading ends it too: nsEnd is the index of that
|
|
203
|
+
// "</noscript" in the current chunk, and the scan must reach it in text.
|
|
204
|
+
noscript = false;
|
|
205
|
+
nsEnd = Infinity;
|
|
206
|
+
// <select> (HTML mode), and whether the template has a start tag refused inside it
|
|
207
|
+
select = false;
|
|
208
|
+
selectUnsafe = false;
|
|
166
209
|
// foreign content: ns is the namespace of the outermost <svg>/<math> ("generic" for the
|
|
167
210
|
// virtual one a neutrality scan starts inside); stack holds the open svg/math elements
|
|
168
211
|
ns = "";
|
|
@@ -192,6 +235,8 @@ class Scanner {
|
|
|
192
235
|
}
|
|
193
236
|
chunk(s) {
|
|
194
237
|
this.rawStart = 0;
|
|
238
|
+
if (this.noscript)
|
|
239
|
+
this.nsEnd = noscriptEnd(s, 0);
|
|
195
240
|
const len = s.length;
|
|
196
241
|
let i = 0;
|
|
197
242
|
while (i < len) {
|
|
@@ -202,6 +247,8 @@ class Scanner {
|
|
|
202
247
|
if (j === -1)
|
|
203
248
|
i = len;
|
|
204
249
|
else {
|
|
250
|
+
if (j === this.nsEnd)
|
|
251
|
+
this.nsEnd = Infinity; // reached in text: the end tag
|
|
205
252
|
this.state = TAG_OPEN;
|
|
206
253
|
i = j + 1;
|
|
207
254
|
}
|
|
@@ -369,6 +416,9 @@ class Scanner {
|
|
|
369
416
|
default: // COMMENT, MARKUP_DECL: unterminated in this chunk
|
|
370
417
|
i = len;
|
|
371
418
|
}
|
|
419
|
+
if (i > this.nsEnd) {
|
|
420
|
+
this.fail('"</noscript" inside <noscript> that is not its end tag: with scripting enabled, it would end the element there', this.nsEnd);
|
|
421
|
+
}
|
|
372
422
|
}
|
|
373
423
|
}
|
|
374
424
|
startTag(c, isEnd) {
|
|
@@ -459,6 +509,12 @@ class Scanner {
|
|
|
459
509
|
}
|
|
460
510
|
return i + 1;
|
|
461
511
|
}
|
|
512
|
+
if (SELECT_REFUSED.has(tag)) {
|
|
513
|
+
if (this.select) {
|
|
514
|
+
this.fail(`<${tag}> inside <select> is not supported: older parsers ignore it there and parse its content as markup`, i);
|
|
515
|
+
}
|
|
516
|
+
this.selectUnsafe = true;
|
|
517
|
+
}
|
|
462
518
|
if (tag === "svg" || tag === "math") {
|
|
463
519
|
if (!this.selfClosing) {
|
|
464
520
|
this.ns = tag;
|
|
@@ -466,7 +522,18 @@ class Scanner {
|
|
|
466
522
|
}
|
|
467
523
|
return i + 1;
|
|
468
524
|
}
|
|
469
|
-
|
|
525
|
+
// the self-closing flag is ignored below, as in HTML
|
|
526
|
+
if (tag === "select")
|
|
527
|
+
this.select = true;
|
|
528
|
+
if (tag === "noscript") {
|
|
529
|
+
if (this.noscript) {
|
|
530
|
+
this.fail("<noscript> inside <noscript> is not supported", i);
|
|
531
|
+
}
|
|
532
|
+
this.noscript = true;
|
|
533
|
+
this.nsEnd = noscriptEnd(this.strings[this.k], i + 1);
|
|
534
|
+
return i + 1;
|
|
535
|
+
}
|
|
536
|
+
const kind = RAW_KIND.get(tag);
|
|
470
537
|
if (kind) {
|
|
471
538
|
this.raw = tag;
|
|
472
539
|
this.rawKind = kind;
|
|
@@ -487,8 +554,15 @@ class Scanner {
|
|
|
487
554
|
if (tag === "svg" || tag === "math") {
|
|
488
555
|
this.fail(`</${tag}> without a matching <${tag}> in the same template`, i);
|
|
489
556
|
}
|
|
557
|
+
if (tag === "noscript")
|
|
558
|
+
this.noscript = false;
|
|
559
|
+
else if (tag === "select")
|
|
560
|
+
this.select = false;
|
|
490
561
|
return;
|
|
491
562
|
}
|
|
563
|
+
if (tag === "noscript" && this.noscript) {
|
|
564
|
+
this.fail("</noscript> inside <svg>/<math>: close the <svg>/<math> first", i);
|
|
565
|
+
}
|
|
492
566
|
if (tag === "br" || tag === "p") {
|
|
493
567
|
this.fail(`</${tag}> inside <svg>/<math> ends the foreign content`, i);
|
|
494
568
|
}
|
|
@@ -515,16 +589,21 @@ class Scanner {
|
|
|
515
589
|
if (this.foreignRaw) {
|
|
516
590
|
fail(`slot inside <${this.foreignRaw}> within <svg>/<math> is not supported`);
|
|
517
591
|
}
|
|
518
|
-
this.slots.push(slotInfo("text", "", "", "", this.integration ? !this.integrationHtml : this.ns !== ""));
|
|
592
|
+
this.slots.push(slotInfo("text", "", "", "", this.integration ? !this.integrationHtml : this.ns !== "", this.noscript, this.select));
|
|
519
593
|
return;
|
|
520
594
|
case RAW: {
|
|
521
595
|
const kind = this.rawKind;
|
|
522
596
|
if (kind === "unsupported" || kind === "plaintext") {
|
|
523
597
|
fail(`slot inside <${this.raw}> is not supported`);
|
|
524
598
|
}
|
|
599
|
+
if (this.noscript && (kind === "script" || kind === "style")) {
|
|
600
|
+
// scriptText()/styleText() may emit "</noscript", which ends the <noscript>
|
|
601
|
+
fail(`slot inside <${this.raw}> within <noscript> is not supported`);
|
|
602
|
+
}
|
|
525
603
|
const before = this.strings[k].slice(this.rawStart);
|
|
526
604
|
if (endsWithPartial(before, "</" + this.raw) ||
|
|
527
|
-
(kind === "script" && endsWithPartial(before, "<!--"))
|
|
605
|
+
(kind === "script" && endsWithPartial(before, "<!--")) ||
|
|
606
|
+
(this.noscript && endsWithPartial(before, "</noscript"))) {
|
|
528
607
|
fail(`slot right after "<" inside <${this.raw}>: add a space between them`);
|
|
529
608
|
}
|
|
530
609
|
this.slots.push(slotInfo(kind, this.raw));
|
|
@@ -583,6 +662,9 @@ class Scanner {
|
|
|
583
662
|
if (SVG_ANIMATION.has(tag) && SVG_ANIMATION_ATTRS.has(attr)) {
|
|
584
663
|
fail(`slot in "${attr}" on <${tag}> is not supported (SVG animation can set href)`);
|
|
585
664
|
}
|
|
665
|
+
if (this.noscript && endsWithPartial(this.valueStatic, "</noscript")) {
|
|
666
|
+
fail('slot right after "<" inside <noscript>: add a space between them');
|
|
667
|
+
}
|
|
586
668
|
const q = next.indexOf(this.quote);
|
|
587
669
|
const after = q === -1 ? null : next.slice(0, q); // null: another slot comes first
|
|
588
670
|
let info;
|
|
@@ -642,6 +724,12 @@ class Scanner {
|
|
|
642
724
|
}
|
|
643
725
|
if (this.integration)
|
|
644
726
|
fail(`template ends inside <${this.integration}>`);
|
|
727
|
+
if (this.noscript) {
|
|
728
|
+
fail("template ends inside <noscript>: close it in the same template");
|
|
729
|
+
}
|
|
730
|
+
if (this.select) {
|
|
731
|
+
fail("template ends inside <select>: close it in the same template");
|
|
732
|
+
}
|
|
645
733
|
if (this.foreignRaw)
|
|
646
734
|
fail(`template ends inside <${this.foreignRaw}>`);
|
|
647
735
|
if (this.stack.length) {
|
|
@@ -656,6 +744,24 @@ let analyses = 0;
|
|
|
656
744
|
export function analysisCount() {
|
|
657
745
|
return analyses;
|
|
658
746
|
}
|
|
747
|
+
/**
|
|
748
|
+
* May the template's markup go inside `<noscript>`? With scripting enabled, everything there up
|
|
749
|
+
* to the first `</noscript` is text, so the template must not contain one (a nested
|
|
750
|
+
* `<noscript>` included), must not be able to form one with a value (a chunk ending with a
|
|
751
|
+
* prefix of it before a slot), and must not have script or style slots (`scriptText()` and
|
|
752
|
+
* `styleText()` may emit one). Other slots render no `<`.
|
|
753
|
+
*/
|
|
754
|
+
function noscriptSafe(strings, slots) {
|
|
755
|
+
const re = closerRe("noscript");
|
|
756
|
+
for (let k = 0; k < strings.length; k++) {
|
|
757
|
+
re.lastIndex = 0;
|
|
758
|
+
if (re.test(strings[k]))
|
|
759
|
+
return false;
|
|
760
|
+
if (k < slots.length && endsWithPartial(strings[k], "</noscript"))
|
|
761
|
+
return false;
|
|
762
|
+
}
|
|
763
|
+
return slots.every((s) => s.context !== "script" && s.context !== "style");
|
|
764
|
+
}
|
|
659
765
|
function compute(strings) {
|
|
660
766
|
for (let i = 0; i < strings.length; i++) {
|
|
661
767
|
if (typeof strings[i] !== "string") {
|
|
@@ -663,7 +769,8 @@ function compute(strings) {
|
|
|
663
769
|
}
|
|
664
770
|
}
|
|
665
771
|
try {
|
|
666
|
-
const
|
|
772
|
+
const scan = new Scanner(strings, false);
|
|
773
|
+
const slots = scan.run();
|
|
667
774
|
let neutral = false;
|
|
668
775
|
try {
|
|
669
776
|
const f = new Scanner(strings, true).run();
|
|
@@ -680,7 +787,12 @@ function compute(strings) {
|
|
|
680
787
|
if (!(e instanceof ScanError))
|
|
681
788
|
throw e;
|
|
682
789
|
}
|
|
683
|
-
return {
|
|
790
|
+
return {
|
|
791
|
+
slots,
|
|
792
|
+
neutral,
|
|
793
|
+
noscript: noscriptSafe(strings, slots),
|
|
794
|
+
select: !scan.selectUnsafe,
|
|
795
|
+
};
|
|
684
796
|
}
|
|
685
797
|
catch (e) {
|
|
686
798
|
if (e instanceof ScanError)
|
package/dist/trusted.d.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import type { Trusted, TrustedKind } from "./types.js";
|
|
6
6
|
declare const VALUE: unique symbol;
|
|
7
7
|
declare const NEUTRAL: unique symbol;
|
|
8
|
+
declare const NOSCRIPT: unique symbol;
|
|
9
|
+
declare const SELECT: unique symbol;
|
|
8
10
|
/**
|
|
9
11
|
* The implementation. State lives in private fields behind getters, so a value is immutable
|
|
10
12
|
* without the cost of `Object.freeze` (there is no setter, and nothing outside this class can
|
|
@@ -13,23 +15,33 @@ declare const NEUTRAL: unique symbol;
|
|
|
13
15
|
*/
|
|
14
16
|
export declare class TrustedValue<K extends TrustedKind = TrustedKind> implements Trusted<K> {
|
|
15
17
|
#private;
|
|
16
|
-
constructor(kind: K, value: string, neutral: boolean);
|
|
18
|
+
constructor(kind: K, value: string, neutral: boolean, noscript: boolean, select: boolean);
|
|
17
19
|
get kind(): K;
|
|
18
20
|
get [VALUE](): string;
|
|
19
21
|
get [NEUTRAL](): boolean;
|
|
22
|
+
get [NOSCRIPT](): boolean;
|
|
23
|
+
get [SELECT](): boolean;
|
|
20
24
|
toString(): string;
|
|
21
25
|
toJSON(): string;
|
|
22
26
|
[Symbol.toPrimitive](): string;
|
|
23
27
|
}
|
|
24
28
|
/**
|
|
25
|
-
* Creates a trusted value.
|
|
26
|
-
* `<svg>`/`<math>` as in HTML
|
|
29
|
+
* Creates a trusted value. For html only: `neutral` marks markup that parses the same inside
|
|
30
|
+
* `<svg>`/`<math>` as in HTML, `noscript` markup that may go inside `<noscript>`, `select`
|
|
31
|
+
* markup that may go inside `<select>` (see scanner.ts).
|
|
27
32
|
*/
|
|
28
|
-
export declare function makeTrusted<K extends TrustedKind>(kind: K, value: string, neutral?: boolean): Trusted<K>;
|
|
33
|
+
export declare function makeTrusted<K extends TrustedKind>(kind: K, value: string, neutral?: boolean, noscript?: boolean, select?: boolean): Trusted<K>;
|
|
29
34
|
/** Is `v` a value produced by this package (optionally, of the given kind)? */
|
|
30
35
|
export declare function isTrusted<K extends TrustedKind>(v: unknown, kind?: K): v is Trusted<K>;
|
|
31
36
|
/** The string of a value already known to be trusted. */
|
|
32
37
|
export declare function trustedString(v: Trusted): string;
|
|
33
38
|
/** Whether trusted markup may be placed into SVG/MathML text. */
|
|
34
39
|
export declare function isNeutral(v: Trusted): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Whether trusted markup may be placed into `<noscript>` text. Values from a copy of the
|
|
42
|
+
* package that predates the flag read as `false` (refused there).
|
|
43
|
+
*/
|
|
44
|
+
export declare function isNoscriptSafe(v: Trusted): boolean;
|
|
45
|
+
/** Whether trusted markup may be placed into `<select>` text (older copies: `false`). */
|
|
46
|
+
export declare function isSelectSafe(v: Trusted): boolean;
|
|
35
47
|
export {};
|
package/dist/trusted.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
const BRAND = Symbol.for("@marianmeres/safe-html/brand");
|
|
2
2
|
const VALUE = Symbol.for("@marianmeres/safe-html/value");
|
|
3
3
|
const NEUTRAL = Symbol.for("@marianmeres/safe-html/neutral");
|
|
4
|
+
const NOSCRIPT = Symbol.for("@marianmeres/safe-html/noscript");
|
|
5
|
+
const SELECT = Symbol.for("@marianmeres/safe-html/select");
|
|
4
6
|
/**
|
|
5
7
|
* The implementation. State lives in private fields behind getters, so a value is immutable
|
|
6
8
|
* without the cost of `Object.freeze` (there is no setter, and nothing outside this class can
|
|
@@ -11,10 +13,14 @@ export class TrustedValue {
|
|
|
11
13
|
#kind;
|
|
12
14
|
#value;
|
|
13
15
|
#neutral;
|
|
14
|
-
|
|
16
|
+
#noscript;
|
|
17
|
+
#select;
|
|
18
|
+
constructor(kind, value, neutral, noscript, select) {
|
|
15
19
|
this.#kind = kind;
|
|
16
20
|
this.#value = value;
|
|
17
21
|
this.#neutral = neutral;
|
|
22
|
+
this.#noscript = noscript;
|
|
23
|
+
this.#select = select;
|
|
18
24
|
}
|
|
19
25
|
get kind() {
|
|
20
26
|
return this.#kind;
|
|
@@ -25,6 +31,12 @@ export class TrustedValue {
|
|
|
25
31
|
get [NEUTRAL]() {
|
|
26
32
|
return this.#neutral;
|
|
27
33
|
}
|
|
34
|
+
get [NOSCRIPT]() {
|
|
35
|
+
return this.#noscript;
|
|
36
|
+
}
|
|
37
|
+
get [SELECT]() {
|
|
38
|
+
return this.#select;
|
|
39
|
+
}
|
|
28
40
|
toString() {
|
|
29
41
|
return this.#value;
|
|
30
42
|
}
|
|
@@ -38,11 +50,12 @@ export class TrustedValue {
|
|
|
38
50
|
Object.defineProperty(TrustedValue.prototype, BRAND, { value: true });
|
|
39
51
|
Object.freeze(TrustedValue.prototype);
|
|
40
52
|
/**
|
|
41
|
-
* Creates a trusted value.
|
|
42
|
-
* `<svg>`/`<math>` as in HTML
|
|
53
|
+
* Creates a trusted value. For html only: `neutral` marks markup that parses the same inside
|
|
54
|
+
* `<svg>`/`<math>` as in HTML, `noscript` markup that may go inside `<noscript>`, `select`
|
|
55
|
+
* markup that may go inside `<select>` (see scanner.ts).
|
|
43
56
|
*/
|
|
44
|
-
export function makeTrusted(kind, value, neutral = false) {
|
|
45
|
-
return new TrustedValue(kind, value, neutral);
|
|
57
|
+
export function makeTrusted(kind, value, neutral = false, noscript = false, select = false) {
|
|
58
|
+
return new TrustedValue(kind, value, neutral, noscript, select);
|
|
46
59
|
}
|
|
47
60
|
/** Is `v` a value produced by this package (optionally, of the given kind)? */
|
|
48
61
|
export function isTrusted(v, kind) {
|
|
@@ -66,3 +79,16 @@ export function isNeutral(v) {
|
|
|
66
79
|
// deno-lint-ignore no-explicit-any
|
|
67
80
|
return v[NEUTRAL] === true;
|
|
68
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether trusted markup may be placed into `<noscript>` text. Values from a copy of the
|
|
84
|
+
* package that predates the flag read as `false` (refused there).
|
|
85
|
+
*/
|
|
86
|
+
export function isNoscriptSafe(v) {
|
|
87
|
+
// deno-lint-ignore no-explicit-any
|
|
88
|
+
return v[NOSCRIPT] === true;
|
|
89
|
+
}
|
|
90
|
+
/** Whether trusted markup may be placed into `<select>` text (older copies: `false`). */
|
|
91
|
+
export function isSelectSafe(v) {
|
|
92
|
+
// deno-lint-ignore no-explicit-any
|
|
93
|
+
return v[SELECT] === true;
|
|
94
|
+
}
|
package/docs/design.md
CHANGED
|
@@ -40,7 +40,7 @@ Two phases:
|
|
|
40
40
|
- inject script through an event-handler attribute (those slots are rejected);
|
|
41
41
|
- put a URL with a scheme outside the allowlist into a URL attribute (default: `http`,
|
|
42
42
|
`https`, `mailto`, `tel`), including through `srcset` or `attrs()`;
|
|
43
|
-
- break out of `<script>`, `<style>`, `<title>` or `<
|
|
43
|
+
- break out of `<script>`, `<style>`, `<title>`, `<textarea>` or `<noscript>` content;
|
|
44
44
|
- change how SVG/MathML content parses.
|
|
45
45
|
|
|
46
46
|
**Not guaranteed:**
|
|
@@ -69,21 +69,22 @@ scan then continues from the same state, because §4 guarantees no value can cha
|
|
|
69
69
|
|
|
70
70
|
### 3.1 States → contexts
|
|
71
71
|
|
|
72
|
-
| Scanner state at the slot
|
|
73
|
-
|
|
|
74
|
-
| Text content
|
|
75
|
-
| `<title>`, `<textarea>` content (RCDATA)
|
|
76
|
-
| In a start tag where an attribute may begin
|
|
77
|
-
| Just after `<` or `</`, or in a tag name
|
|
78
|
-
| In an attribute name, or right after one with no whitespace
|
|
79
|
-
| After `=`, or in an unquoted value
|
|
80
|
-
| In a `"…"` or `'…'` value
|
|
81
|
-
| `<script>` raw text
|
|
82
|
-
| `<style>` raw text
|
|
83
|
-
|
|
|
84
|
-
|
|
|
85
|
-
|
|
|
86
|
-
|
|
|
72
|
+
| Scanner state at the slot | Example | Context |
|
|
73
|
+
| ----------------------------------------------------------- | -------------------------------------- | -------------------- |
|
|
74
|
+
| Text content | `<p>${…}</p>` | `text` |
|
|
75
|
+
| `<title>`, `<textarea>` content (RCDATA) | `<title>${…}</title>` | `rcdata` |
|
|
76
|
+
| In a start tag where an attribute may begin | `<div ${…}>`, `<a href="x"${…}>` | `attr-list` |
|
|
77
|
+
| Just after `<` or `</`, or in a tag name | `<${…}>`, `<h${…}>` | error |
|
|
78
|
+
| In an attribute name, or right after one with no whitespace | `<div data-${…}="1">`, `<input a${…}>` | error |
|
|
79
|
+
| After `=`, or in an unquoted value | `<a href=${…}>`, `<a href=x${…}>` | error |
|
|
80
|
+
| In a `"…"` or `'…'` value | `<p title="${…}">` | per attribute (§3.3) |
|
|
81
|
+
| `<script>` raw text | `<script>${…}</script>` | `script` |
|
|
82
|
+
| `<style>` raw text | `<style>${…}</style>` | `style` |
|
|
83
|
+
| `<noscript>` content | `<noscript>${…}</noscript>` | as outside (§3.7) |
|
|
84
|
+
| `iframe`, `noembed`, `noframes`, `xmp`, `plaintext` content | `<iframe>${…}</iframe>` | error |
|
|
85
|
+
| Comment, doctype, `<!…>`, `<?…>`, `</ …>` | `<!-- ${…} -->` | error |
|
|
86
|
+
| Inside an end tag | `</div ${…}>` | error |
|
|
87
|
+
| Right after `/` in a tag | `<br/${…}>` | error |
|
|
87
88
|
|
|
88
89
|
Tokenizer details:
|
|
89
90
|
|
|
@@ -91,6 +92,8 @@ Tokenizer details:
|
|
|
91
92
|
- `<script>`, `<style>`, `<title>`, `<textarea>` and the unsupported raw-text elements are
|
|
92
93
|
scanned to their end tag (`</name` followed by whitespace, `/` or `>`, case-insensitive),
|
|
93
94
|
including after `<script/>` (the self-closing flag is ignored on non-void elements).
|
|
95
|
+
`<noscript>` content is scanned as markup (§3.7). Inside `<select>`, some start tags are
|
|
96
|
+
refused (§3.8).
|
|
94
97
|
- A comment runs from `<!--` to `-->` or `--!>`; `<!-->` and `<!--->` are complete comments.
|
|
95
98
|
`<!doctype …>`, other `<!…>`, `<?…>` and `</` + non-letter run to the next `>`.
|
|
96
99
|
- `<![CDATA[` anywhere is an error.
|
|
@@ -161,8 +164,8 @@ stripped (what the URL parser ignores).
|
|
|
161
164
|
### 3.5 End of template
|
|
162
165
|
|
|
163
166
|
After the last static chunk the scanner must be back in text: outside any tag, comment,
|
|
164
|
-
declaration, raw text, RCDATA, and outside `<svg>`/`<math>`.
|
|
165
|
-
fine. A stray `</svg>` or `</math>` is an error.
|
|
167
|
+
declaration, raw text, RCDATA, `<noscript>`, `<select>`, and outside `<svg>`/`<math>`.
|
|
168
|
+
Unclosed normal elements are fine. A stray `</svg>` or `</math>` is an error.
|
|
166
169
|
|
|
167
170
|
This makes a `SafeHtml` fragment safe to drop into another template's `text` slot: it starts
|
|
168
171
|
and ends in text and every value inside was rendered for its own context.
|
|
@@ -198,13 +201,61 @@ neutral if its template is and every fragment rendered into its text slots is. `
|
|
|
198
201
|
r="${r}"/>`, `<g>…</g>`, `<title>${t}</title>` and static `<style>` are neutral; `<p>`,
|
|
199
202
|
`<style>${styleText(…)}</style>` and `<textarea><a href="${u}"></textarea>` are not.
|
|
200
203
|
|
|
201
|
-
### 3.7
|
|
204
|
+
### 3.7 `<noscript>`
|
|
205
|
+
|
|
206
|
+
`<noscript>` parses two ways. With scripting enabled (every browser, by default) its content
|
|
207
|
+
is raw text up to the first `</noscript` (followed by whitespace, `/` or `>`,
|
|
208
|
+
case-insensitive). With scripting disabled it is ordinary markup. A template must be safe
|
|
209
|
+
under both, so:
|
|
210
|
+
|
|
211
|
+
- The content is scanned as markup, and slots get the contexts they would get outside.
|
|
212
|
+
- The first `</noscript` after the start tag must be the element's end tag in the markup
|
|
213
|
+
reading too: reached in text, outside any tag, comment, raw text or RCDATA, and outside
|
|
214
|
+
`<svg>`/`<math>`. A `</noscript` in an attribute value, comment, `<textarea>` or static
|
|
215
|
+
script is an error, with or without slots.
|
|
216
|
+
- `script` and `style` slots are errors: `scriptText()` and `styleText()` may emit
|
|
217
|
+
`</noscript`, which would end the element in the raw-text reading.
|
|
218
|
+
- Static text before a slot must not end with a non-empty prefix of `</noscript` (§3.2):
|
|
219
|
+
`title="a <${x}"` is refused inside it.
|
|
220
|
+
- `<noscript>` inside `<noscript>` is an error. Inside SVG/MathML, `<noscript>` is an ordinary
|
|
221
|
+
foreign element and none of this applies.
|
|
222
|
+
|
|
223
|
+
Every other value renders without a `<`, so in the raw-text reading everything up to the real
|
|
224
|
+
end tag stays text, and in the markup reading the usual rules hold.
|
|
225
|
+
|
|
226
|
+
**Fragments.** An `html` fragment in a `text` slot inside `<noscript>` must be
|
|
227
|
+
**noscript-safe**: its template has no `script` or `style` slot, contains no `</noscript`, and
|
|
228
|
+
has no chunk ending with a prefix of it before a slot; and every fragment rendered into its text
|
|
229
|
+
slots is noscript-safe (as for neutrality, §3.6). `join()` propagates the property. A fragment
|
|
230
|
+
can't end with such a prefix, since it ends in text (§3.5).
|
|
231
|
+
|
|
232
|
+
The two readings also build different trees: with scripting enabled, `<noscript>` holds one
|
|
233
|
+
text node. That is the template's static choice, not something a value can change.
|
|
234
|
+
|
|
235
|
+
### 3.8 `<select>`
|
|
236
|
+
|
|
237
|
+
Older parsers (and parse5) parse `<select>` content in the "in select" insertion mode, which
|
|
238
|
+
ignores most start tags. An ignored `<title>` or `<style>` does not switch the tokenizer, so
|
|
239
|
+
its content is markup there: `<select><title><script>${x}</script></title></select>` runs
|
|
240
|
+
`x`, and `styleText()` inside `<select><style>` can create elements. Newer parsers switch the
|
|
241
|
+
tokenizer as usual. A template must be safe under both, so inside `<select>` (HTML mode,
|
|
242
|
+
until the first `</select>`) these start tags are errors: `title`, `style`, `xmp`, `iframe`,
|
|
243
|
+
`noembed`, `noframes`, `noscript`, `plaintext`, `svg`, `math`, and `template` (processed
|
|
244
|
+
there, but a `</select>` inside it is ignored, so the scanner would leave the `<select>` too
|
|
245
|
+
early). `<script>`, `<textarea>`, `<option>`, `<optgroup>` and other elements parse the same
|
|
246
|
+
in both.
|
|
247
|
+
|
|
248
|
+
**Fragments.** An `html` fragment in a `text` slot inside `<select>` must be **select-safe**:
|
|
249
|
+
its template has none of these start tags (anywhere, in HTML mode), and every fragment
|
|
250
|
+
rendered into its text slots is select-safe. `join()` propagates the property.
|
|
251
|
+
|
|
252
|
+
### 3.9 Strictness
|
|
202
253
|
|
|
203
254
|
Anything the scanner does not model is an error, never a guess. Analysis errors depend on the
|
|
204
255
|
template only, so a test that renders every template once surfaces them all. Relaxing a rule
|
|
205
256
|
later is a minor release; tightening one breaks templates.
|
|
206
257
|
|
|
207
|
-
### 3.
|
|
258
|
+
### 3.10 Excerpts
|
|
208
259
|
|
|
209
260
|
`HtmlTemplateError.excerpt` is the static text with slots shown as `${…}`, the failing slot
|
|
210
261
|
as `${⟨here⟩}` (or `⟨here⟩` at an offset for errors in static text), whitespace collapsed,
|
|
@@ -230,7 +281,8 @@ trimmed to about 90 characters. It never contains a value.
|
|
|
230
281
|
| `unsafe` | verbatim | verbatim | verbatim | verbatim | verbatim | verbatim | verbatim | verbatim |
|
|
231
282
|
| object, function, symbol, date, promise | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
|
|
232
283
|
|
|
233
|
-
¹ In SVG/MathML text slots only neutral fragments (§3.6)
|
|
284
|
+
¹ In SVG/MathML text slots only neutral fragments (§3.6); in `<noscript>` text slots only
|
|
285
|
+
noscript-safe ones (§3.7); in `<select>` text slots only select-safe ones (§3.8).
|
|
234
286
|
|
|
235
287
|
- **Booleans render nothing, `true` included** (as in JSX), so ``${cond && html`…`}`` works.
|
|
236
288
|
Numbers render, so ``${count && html`…`}`` prints `0` when the count is zero: write
|
|
@@ -256,6 +308,11 @@ trimmed to about 90 characters. It never contains a value.
|
|
|
256
308
|
RCDATA and both quoted attribute forms (not in unquoted values, which are analysis errors).
|
|
257
309
|
Strings with none of these are returned unchanged.
|
|
258
310
|
|
|
311
|
+
Quotes are escaped in text and RCDATA too, where only `&` and `<` are needed. It costs a few
|
|
312
|
+
bytes (and differs from Svelte's output) but is a second line of defense: should a parser ever
|
|
313
|
+
read a text slot as part of a quoted attribute value, in disagreement with the scanner's model,
|
|
314
|
+
the value still can't close it.
|
|
315
|
+
|
|
259
316
|
### 5.2 URL policy
|
|
260
317
|
|
|
261
318
|
1. Detect the scheme as the URL parser would: skip leading C0 controls and space, ignore
|
|
@@ -344,3 +401,15 @@ The first draft of this design was revised during implementation:
|
|
|
344
401
|
rest of a small render.
|
|
345
402
|
11. **Tests** use a seeded generator instead of `fast-check` (one dev dependency, `parse5`,
|
|
346
403
|
and deterministic runs), plus value round-trips through `parse5`.
|
|
404
|
+
|
|
405
|
+
Changes after the first release:
|
|
406
|
+
|
|
407
|
+
12. **`<noscript>` content is modeled** (§3.7), after a port hit it with a localized
|
|
408
|
+
fallback button. It was an unsupported raw-text element: no slots, content scanned as raw
|
|
409
|
+
text only. That also accepted a static `</noscript` in an attribute value or comment inside
|
|
410
|
+
it, where the two readings disagree; that is now an error. The invariance tests parse
|
|
411
|
+
every template with scripting enabled and disabled.
|
|
412
|
+
13. **`<select>` is modeled** (§3.8). Earlier versions accepted `<title>`, `<style>` and
|
|
413
|
+
`<svg>` inside it, where older parsers ignore the tag and parse its content as markup:
|
|
414
|
+
`<select><title><script>${x}</script></title></select>` ran `x`. Those tags are now
|
|
415
|
+
errors inside `<select>`, and a template must close its `<select>`.
|