@bygghammar/cms-platform 0.95.0 → 0.96.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/CHANGELOG.md +72 -0
- package/package.json +1 -1
- package/src/backoffice/PageEditorView.tsx +47 -24
- package/src/forms/form-audit.ts +22 -2
- package/src/html/html-tokenizer.ts +254 -0
- package/src/html/sanitize-html.ts +71 -36
- package/src/server/forms-core.ts +9 -0
- package/src/server/forms-delivery-core.ts +29 -22
- package/src/server/forms-submissions-core.ts +8 -5
- package/src/server/media-usage-core.ts +53 -13
- package/src/server/outbound-address.ts +131 -0
- package/src/server/outbound-http.ts +565 -0
- package/src/server/popup-optin-webhook-core.ts +79 -140
- package/src/site/preview-messaging.ts +232 -0
- package/src/site/routes/preview-page.tsx +37 -29
- package/src/util/redact-url.ts +18 -0
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,78 @@ matches `package.json` exactly. See
|
|
|
8
8
|
Versions 0.1.0 through 0.78.0 predate this repository and were reconstructed
|
|
9
9
|
from the published npm tarballs.
|
|
10
10
|
|
|
11
|
+
## 0.96.0
|
|
12
|
+
|
|
13
|
+
Security release. No migration, and no export was added or removed.
|
|
14
|
+
|
|
15
|
+
- **Rich text is sanitized by a parser, not by patterns** (`./html/sanitize-html`).
|
|
16
|
+
The old sanitizer copied the text between the tags it recognised through as
|
|
17
|
+
written, so an unterminated tag at the end of a fragment (for example an image
|
|
18
|
+
tag with an event handler and no closing `>`) survived, and in server
|
|
19
|
+
rendering the markup after the fragment completed it into a live element.
|
|
20
|
+
Markup is now read the way a browser's HTML tokenizer reads it (every quoting
|
|
21
|
+
form, comments, DOCTYPE, CDATA, raw-text elements, a tag cut off by the end of
|
|
22
|
+
the input) and the output is written from scratch: allowlisted elements and
|
|
23
|
+
attributes only, attributes decoded once and escaped once, and every `<` and
|
|
24
|
+
`>` in text escaped. The editor's allowlist, URL rules and booking-link
|
|
25
|
+
`&` handling are unchanged.
|
|
26
|
+
- Tightened: a `data:` image is accepted only in an image's `src`, never as a
|
|
27
|
+
link target. `svg`, `math`, `template`, `textarea`, `title`, `xmp`,
|
|
28
|
+
`noembed`, `noframes` and `plaintext` are dropped together with their
|
|
29
|
+
content.
|
|
30
|
+
- `htmlToText` now returns plain text, with entities decoded.
|
|
31
|
+
- **The builder and its preview only talk to each other**
|
|
32
|
+
(`./site/routes/preview-page`, the page builder). The public preview route
|
|
33
|
+
used to render blocks posted by any window that put a marker string in its
|
|
34
|
+
message, and both ends posted to `"*"`. Now:
|
|
35
|
+
- the preview accepts the builder's messages only from its parent frame,
|
|
36
|
+
from its own origin or an origin the host lists, in one builder session
|
|
37
|
+
with increasing sequence numbers, shape-checked and at most 4 MB;
|
|
38
|
+
- the builder accepts the preview's messages only from its own iframe, and
|
|
39
|
+
every message names its target origin.
|
|
40
|
+
- **Host change, only for a builder on another origin:** list that origin in
|
|
41
|
+
the preview page deps as `trustedEditorOrigins`. A builder on the site's own
|
|
42
|
+
origin (Voyara's) needs no configuration.
|
|
43
|
+
- **One outbound request policy** for every address staff or configuration
|
|
44
|
+
chooses: media import, form webhooks and popup opt-in webhooks. Each of these
|
|
45
|
+
was a server-side request forgery primitive. Every request now:
|
|
46
|
+
- allows only the schemes its policy names (webhooks: https only), with no
|
|
47
|
+
credentials in the URL, no local or single-label host names and no
|
|
48
|
+
privileged ports other than 80 and 443;
|
|
49
|
+
- looks the host up and goes ahead only if every address it resolves to is
|
|
50
|
+
public (IPv4 and IPv6 special-purpose ranges, including mapped, NAT64 and
|
|
51
|
+
6to4 forms), then connects to the address it checked (pinned, so a DNS
|
|
52
|
+
answer that changes in between cannot redirect it), with TLS still verified
|
|
53
|
+
for the host name;
|
|
54
|
+
- follows redirects itself, checking and pinning every hop, up to a cap;
|
|
55
|
+
- runs under one deadline for the whole request, which also holds when a
|
|
56
|
+
lookup, connection or response body ignores cancellation (Node's DNS
|
|
57
|
+
lookup cannot be cancelled), and reads at most a fixed number of bytes,
|
|
58
|
+
streaming, whatever `Content-Length` says;
|
|
59
|
+
- reports failures by category, with messages that carry no address, query
|
|
60
|
+
string or response body.
|
|
61
|
+
- **What each path does with that policy:**
|
|
62
|
+
- **Media import:** a downloaded file is stored as what its first bytes say
|
|
63
|
+
it is (JPEG, PNG, GIF, WebP, AVIF, MP4, MOV, M4V, WebM, MP3, WAV, Ogg or
|
|
64
|
+
PDF), with that extension, never as the server's content type or the URL's
|
|
65
|
+
extension. SVG and HTML are refused. Public redirects to other sites are
|
|
66
|
+
still followed, carrying only plain request headers.
|
|
67
|
+
- **Form and popup webhooks never follow a redirect to another site.** A
|
|
68
|
+
signed body, its signature, timestamp, event id and idempotency key reach
|
|
69
|
+
only the configured origin (a redirect within that origin is followed;
|
|
70
|
+
popup webhooks report the refusal as `blocked-endpoint`). When a redirect
|
|
71
|
+
turns a request into a GET, entity and signature headers are dropped.
|
|
72
|
+
**Receivers that redirect to another host must be configured with their
|
|
73
|
+
final address.**
|
|
74
|
+
- **Form webhooks** are checked when the form is saved, report a failure by
|
|
75
|
+
status only (the receiver's reply is never copied into it), and are never
|
|
76
|
+
sent unsigned when signing fails.
|
|
77
|
+
- **Addresses in records keep the origin only.** Webhook delivery records,
|
|
78
|
+
errors and the form audit trail show a webhook as its origin plus a fixed
|
|
79
|
+
`/[redacted]` marker, never its path, query string or credentials, because
|
|
80
|
+
receivers put keys in paths as well as in query strings. Settings whose name
|
|
81
|
+
says they are a secret are recorded as changed, never with a value.
|
|
82
|
+
|
|
11
83
|
## 0.95.0
|
|
12
84
|
|
|
13
85
|
- **Pages can be link-only, and unpublished from the page itself.** Listing is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.96.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -56,6 +56,12 @@ import {
|
|
|
56
56
|
type InsertRefusal,
|
|
57
57
|
} from "../blocks/block-tree";
|
|
58
58
|
import { subscribeSharedDrafts } from "../cms/shared-draft-store";
|
|
59
|
+
import {
|
|
60
|
+
acceptPreviewMessage,
|
|
61
|
+
EDITOR_SOURCE,
|
|
62
|
+
newPreviewSession,
|
|
63
|
+
previewOriginOf,
|
|
64
|
+
} from "../site/preview-messaging";
|
|
59
65
|
import { SaveGlobalDialog, type SaveGlobalResult } from "./SaveGlobalDialog";
|
|
60
66
|
|
|
61
67
|
function uid(prefix = "b") {
|
|
@@ -570,6 +576,27 @@ export function PageEditorView({
|
|
|
570
576
|
);
|
|
571
577
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
572
578
|
const [previewReady, setPreviewReady] = useState(false);
|
|
579
|
+
// The canvas is spoken to only at its own origin, in one session, in order
|
|
580
|
+
// (see preview-messaging); its answers count only from its own window.
|
|
581
|
+
const previewSession = useMemo(() => newPreviewSession(), []);
|
|
582
|
+
const previewSeq = useRef(0);
|
|
583
|
+
const previewOrigin = useMemo(
|
|
584
|
+
() =>
|
|
585
|
+
typeof window === "undefined" ? null : previewOriginOf(previewUrl(pageKey), window.location.href),
|
|
586
|
+
[previewUrl, pageKey],
|
|
587
|
+
);
|
|
588
|
+
const postToPreview = useCallback(
|
|
589
|
+
(message: Record<string, unknown>) => {
|
|
590
|
+
const frame = iframeRef.current?.contentWindow;
|
|
591
|
+
if (!frame || !previewOrigin) return;
|
|
592
|
+
previewSeq.current += 1;
|
|
593
|
+
frame.postMessage(
|
|
594
|
+
{ source: EDITOR_SOURCE, session: previewSession, seq: previewSeq.current, ...message },
|
|
595
|
+
previewOrigin,
|
|
596
|
+
);
|
|
597
|
+
},
|
|
598
|
+
[previewOrigin, previewSession],
|
|
599
|
+
);
|
|
573
600
|
|
|
574
601
|
const currentBlocks = controlledBlocks ?? blocks;
|
|
575
602
|
|
|
@@ -581,19 +608,15 @@ export function PageEditorView({
|
|
|
581
608
|
// Push draft to iframe whenever blocks/locale/selection change.
|
|
582
609
|
useEffect(() => {
|
|
583
610
|
if (!previewReady) return;
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
},
|
|
594
|
-
"*",
|
|
595
|
-
);
|
|
596
|
-
}, [currentBlocks, globals, selectedId, locale, previewReady, sharedDrafts]);
|
|
611
|
+
postToPreview({
|
|
612
|
+
type: "draft",
|
|
613
|
+
blocks: currentBlocks,
|
|
614
|
+
globals,
|
|
615
|
+
selectedId,
|
|
616
|
+
locale,
|
|
617
|
+
sharedContent: sharedDrafts,
|
|
618
|
+
});
|
|
619
|
+
}, [currentBlocks, globals, selectedId, locale, previewReady, sharedDrafts, postToPreview]);
|
|
597
620
|
|
|
598
621
|
useEffect(() => {
|
|
599
622
|
const t = setTimeout(() => {
|
|
@@ -604,27 +627,27 @@ export function PageEditorView({
|
|
|
604
627
|
}, [pageKey, currentBlocks, globals, locale]);
|
|
605
628
|
|
|
606
629
|
/** Select a block from the outline and scroll the preview to it. */
|
|
607
|
-
const selectAndReveal = useCallback(
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
{
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
630
|
+
const selectAndReveal = useCallback(
|
|
631
|
+
(id: string) => {
|
|
632
|
+
setSelectedId(id);
|
|
633
|
+
postToPreview({ type: "scrollTo", blockId: id });
|
|
634
|
+
},
|
|
635
|
+
[postToPreview],
|
|
636
|
+
);
|
|
614
637
|
|
|
615
638
|
// Listen for iframe messages.
|
|
616
639
|
useEffect(() => {
|
|
617
640
|
const onMsg = (ev: MessageEvent) => {
|
|
618
|
-
const d = ev
|
|
619
|
-
if (d
|
|
641
|
+
const d = acceptPreviewMessage(ev, { frame: iframeRef.current?.contentWindow, previewOrigin });
|
|
642
|
+
if (!d) return;
|
|
620
643
|
if (d.type === "ready") setPreviewReady(true);
|
|
621
|
-
if (d.type === "select"
|
|
644
|
+
if (d.type === "select") {
|
|
622
645
|
if (findBlockDeep(currentBlocks, d.blockId)) setSelectedId(d.blockId);
|
|
623
646
|
}
|
|
624
647
|
};
|
|
625
648
|
window.addEventListener("message", onMsg);
|
|
626
649
|
return () => window.removeEventListener("message", onMsg);
|
|
627
|
-
}, [currentBlocks]);
|
|
650
|
+
}, [currentBlocks, previewOrigin]);
|
|
628
651
|
|
|
629
652
|
// Keep the outline highlight visible whenever the selection changes.
|
|
630
653
|
useEffect(() => {
|
package/src/forms/form-audit.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Every backoffice change to a form (settings + fields) is turned into a list
|
|
3
3
|
// of short change descriptions so the Activity view answers who/what/when.
|
|
4
4
|
|
|
5
|
+
import { redactUrl } from "../util/redact-url";
|
|
6
|
+
|
|
5
7
|
export type FormAuditChange = {
|
|
6
8
|
/** Machine scope: "form" | "settings" | "field" */
|
|
7
9
|
scope: "form" | "settings" | "field";
|
|
@@ -26,6 +28,24 @@ export type FormSnapshotInput = {
|
|
|
26
28
|
|
|
27
29
|
const MAX_LEN = 160;
|
|
28
30
|
|
|
31
|
+
/** Delivery settings whose value is a credential: the audit says it changed, never what to. */
|
|
32
|
+
const SECRET_SETTING = /secret|token|password|apikey|api_key/i;
|
|
33
|
+
|
|
34
|
+
/** An address as the audit shows it: its origin only — keys travel in paths and query strings. */
|
|
35
|
+
function addressOnly(v: unknown): unknown {
|
|
36
|
+
if (typeof v !== "string" || v.trim() === "") return v;
|
|
37
|
+
const redacted = redactUrl(v.trim());
|
|
38
|
+
return redacted === "(invalid address)" ? "(not a valid address)" : redacted;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A delivery setting's value as the audit may record it. */
|
|
42
|
+
function auditable(group: string, key: string, v: unknown): unknown {
|
|
43
|
+
if (group !== "private_settings") return v;
|
|
44
|
+
if (SECRET_SETTING.test(key)) return v == null || v === "" ? v : "(hidden)";
|
|
45
|
+
if (/url$/i.test(key)) return addressOnly(v);
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
function short(v: unknown): string {
|
|
30
50
|
if (v == null) return "—";
|
|
31
51
|
if (typeof v === "string") return v.length > MAX_LEN ? `${v.slice(0, MAX_LEN)}…` : v || "—";
|
|
@@ -143,8 +163,8 @@ export function diffForms(
|
|
|
143
163
|
out.push({
|
|
144
164
|
scope: "settings",
|
|
145
165
|
label: `${label}: ${humanKey(k)}`,
|
|
146
|
-
from: short(a[k]),
|
|
147
|
-
to: short(b[k]),
|
|
166
|
+
from: short(auditable(group, k, a[k])),
|
|
167
|
+
to: short(auditable(group, k, b[k])),
|
|
148
168
|
});
|
|
149
169
|
}
|
|
150
170
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An HTML tokenizer for the sanitizer, following the WHATWG tokenizer states
|
|
3
|
+
* that matter for a fragment of body content. No DOM is needed, so it runs the
|
|
4
|
+
* same on the server and in the browser.
|
|
5
|
+
*
|
|
6
|
+
* It splits markup into text, start tags and end tags, the way a browser
|
|
7
|
+
* would. Comments, DOCTYPEs, CDATA sections and processing instructions are
|
|
8
|
+
* consumed and produce nothing. A tag that runs into the end of the input is
|
|
9
|
+
* dropped, as a browser drops it. The content of raw-text and RCDATA elements
|
|
10
|
+
* (`<script>`, `<style>`, `<textarea>`, …) is returned as one text token, as
|
|
11
|
+
* the browser's tree builder makes the tokenizer read it.
|
|
12
|
+
*
|
|
13
|
+
* Values are returned exactly as written — text and attribute values are not
|
|
14
|
+
* decoded here. The sanitizer re-serializes everything it keeps, so nothing
|
|
15
|
+
* from the input reaches its output without being re-encoded.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type HtmlToken =
|
|
19
|
+
| { kind: "text"; raw: string }
|
|
20
|
+
| {
|
|
21
|
+
kind: "start";
|
|
22
|
+
name: string;
|
|
23
|
+
/** In source order; a repeated name keeps its first value, as browsers do. */
|
|
24
|
+
attrs: Array<{ name: string; raw: string }>;
|
|
25
|
+
selfClosing: boolean;
|
|
26
|
+
}
|
|
27
|
+
| { kind: "end"; name: string };
|
|
28
|
+
|
|
29
|
+
/** Elements whose content the browser reads as text up to their own end tag. */
|
|
30
|
+
const RAW_TEXT = new Set([
|
|
31
|
+
"script",
|
|
32
|
+
"style",
|
|
33
|
+
"xmp",
|
|
34
|
+
"iframe",
|
|
35
|
+
"noembed",
|
|
36
|
+
"noframes",
|
|
37
|
+
"noscript",
|
|
38
|
+
"title",
|
|
39
|
+
"textarea",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const isWhitespace = (c: string) =>
|
|
43
|
+
c === " " || c === "\t" || c === "\n" || c === "\f" || c === "\r";
|
|
44
|
+
const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
|
|
45
|
+
|
|
46
|
+
/** Lower-cases ASCII only, as the HTML tokenizer does for names. */
|
|
47
|
+
function asciiLower(value: string): string {
|
|
48
|
+
return value.replace(/[A-Z]+/g, (m) => m.toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function tokenizeHtml(input: string): HtmlToken[] {
|
|
52
|
+
const html = input.replace(/\r\n?/g, "\n").replace(/\u0000/g, "�");
|
|
53
|
+
const tokens: HtmlToken[] = [];
|
|
54
|
+
const n = html.length;
|
|
55
|
+
let lower: string | undefined;
|
|
56
|
+
let i = 0;
|
|
57
|
+
let textStart = 0;
|
|
58
|
+
|
|
59
|
+
const flushText = (end: number) => {
|
|
60
|
+
if (end > textStart) tokens.push({ kind: "text", raw: html.slice(textStart, end) });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** Skips a bogus comment: everything up to and including the next `>`. */
|
|
64
|
+
const skipBogusComment = (from: number): number => {
|
|
65
|
+
const end = html.indexOf(">", from);
|
|
66
|
+
return end < 0 ? n : end + 1;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** Skips a comment whose `<!--` ends at `from`. */
|
|
70
|
+
const skipComment = (from: number): number => {
|
|
71
|
+
if (html.startsWith(">", from)) return from + 1; // <!-->
|
|
72
|
+
if (html.startsWith("->", from)) return from + 2; // <!--->
|
|
73
|
+
let j = from;
|
|
74
|
+
while (j < n) {
|
|
75
|
+
if (html.startsWith("-->", j)) return j + 3;
|
|
76
|
+
if (html.startsWith("--!>", j)) return j + 4;
|
|
77
|
+
j++;
|
|
78
|
+
}
|
|
79
|
+
return n;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Reads a tag whose name starts at `from`. Returns the token and the index
|
|
84
|
+
* after the tag, or null when the input ends inside it.
|
|
85
|
+
*/
|
|
86
|
+
const readTag = (
|
|
87
|
+
from: number,
|
|
88
|
+
closing: boolean,
|
|
89
|
+
): { token: HtmlToken; next: number } | null => {
|
|
90
|
+
let j = from;
|
|
91
|
+
while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">") j++;
|
|
92
|
+
const name = asciiLower(html.slice(from, j));
|
|
93
|
+
const attrs: Array<{ name: string; raw: string }> = [];
|
|
94
|
+
const seen = new Set<string>();
|
|
95
|
+
let selfClosing = false;
|
|
96
|
+
|
|
97
|
+
for (;;) {
|
|
98
|
+
// Before attribute name.
|
|
99
|
+
while (j < n && (isWhitespace(html[j]!) || html[j] === "/")) {
|
|
100
|
+
if (html[j] === "/") {
|
|
101
|
+
// Self-closing start tag state.
|
|
102
|
+
if (html[j + 1] === ">") {
|
|
103
|
+
selfClosing = true;
|
|
104
|
+
j++;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
j++;
|
|
109
|
+
}
|
|
110
|
+
if (j >= n) return null;
|
|
111
|
+
if (html[j] === ">") {
|
|
112
|
+
j++;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
// Attribute name: a leading `=` is part of the name.
|
|
116
|
+
const nameStart = j;
|
|
117
|
+
j++;
|
|
118
|
+
while (j < n && !isWhitespace(html[j]!) && html[j] !== "/" && html[j] !== ">" && html[j] !== "=") {
|
|
119
|
+
j++;
|
|
120
|
+
}
|
|
121
|
+
const attrName = asciiLower(html.slice(nameStart, j));
|
|
122
|
+
// After attribute name.
|
|
123
|
+
while (j < n && isWhitespace(html[j]!)) j++;
|
|
124
|
+
let raw = "";
|
|
125
|
+
if (j < n && html[j] === "=") {
|
|
126
|
+
j++;
|
|
127
|
+
while (j < n && isWhitespace(html[j]!)) j++;
|
|
128
|
+
if (j >= n) return null;
|
|
129
|
+
const quote = html[j];
|
|
130
|
+
if (quote === '"' || quote === "'") {
|
|
131
|
+
const end = html.indexOf(quote, j + 1);
|
|
132
|
+
if (end < 0) return null;
|
|
133
|
+
raw = html.slice(j + 1, end);
|
|
134
|
+
j = end + 1;
|
|
135
|
+
} else if (html[j] !== ">") {
|
|
136
|
+
const start = j;
|
|
137
|
+
while (j < n && !isWhitespace(html[j]!) && html[j] !== ">") j++;
|
|
138
|
+
raw = html.slice(start, j);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (j >= n) return null;
|
|
142
|
+
if (!seen.has(attrName)) {
|
|
143
|
+
seen.add(attrName);
|
|
144
|
+
attrs.push({ name: attrName, raw });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const token: HtmlToken = closing
|
|
149
|
+
? { kind: "end", name }
|
|
150
|
+
: { kind: "start", name, attrs, selfClosing };
|
|
151
|
+
return { token, next: j };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
while (i < n) {
|
|
155
|
+
const lt = html.indexOf("<", i);
|
|
156
|
+
if (lt < 0) break;
|
|
157
|
+
const c = html[lt + 1] ?? "";
|
|
158
|
+
|
|
159
|
+
if (isAlpha(c)) {
|
|
160
|
+
const tag = readTag(lt + 1, false);
|
|
161
|
+
flushText(lt);
|
|
162
|
+
if (!tag) {
|
|
163
|
+
// The input ends inside the tag: the browser drops it.
|
|
164
|
+
i = textStart = n;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
tokens.push(tag.token);
|
|
168
|
+
i = textStart = tag.next;
|
|
169
|
+
const name = tag.token.kind === "start" ? tag.token.name : "";
|
|
170
|
+
if (name === "plaintext") {
|
|
171
|
+
flushText(n);
|
|
172
|
+
textStart = n;
|
|
173
|
+
i = n;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (RAW_TEXT.has(name)) {
|
|
177
|
+
// Read the content as text, up to `</name` followed by a delimiter.
|
|
178
|
+
// ASCII-only lower-casing keeps every index the same as in `html`.
|
|
179
|
+
lower ??= asciiLower(html);
|
|
180
|
+
let j = i;
|
|
181
|
+
let end = -1;
|
|
182
|
+
while ((j = lower.indexOf(`</${name}`, j)) >= 0) {
|
|
183
|
+
const after = html[j + 2 + name.length] ?? "";
|
|
184
|
+
if (after === "" || isWhitespace(after) || after === "/" || after === ">") {
|
|
185
|
+
end = j;
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
j += 2 + name.length;
|
|
189
|
+
}
|
|
190
|
+
if (end < 0) {
|
|
191
|
+
if (i < n) tokens.push({ kind: "text", raw: html.slice(i) });
|
|
192
|
+
i = textStart = n;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
if (end > i) tokens.push({ kind: "text", raw: html.slice(i, end) });
|
|
196
|
+
const close = readTag(end + 2, true);
|
|
197
|
+
tokens.push({ kind: "end", name });
|
|
198
|
+
i = textStart = close ? close.next : n;
|
|
199
|
+
}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (c === "/") {
|
|
204
|
+
const d = html[lt + 2] ?? "";
|
|
205
|
+
if (isAlpha(d)) {
|
|
206
|
+
const tag = readTag(lt + 2, true);
|
|
207
|
+
flushText(lt);
|
|
208
|
+
if (!tag) {
|
|
209
|
+
i = textStart = n;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
tokens.push(tag.token);
|
|
213
|
+
i = textStart = tag.next;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (d === ">") {
|
|
217
|
+
flushText(lt);
|
|
218
|
+
i = textStart = lt + 3;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (d === "") {
|
|
222
|
+
// `</` at the end of the input is text.
|
|
223
|
+
i = n;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
flushText(lt);
|
|
227
|
+
i = textStart = skipBogusComment(lt + 2);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (c === "!") {
|
|
232
|
+
flushText(lt);
|
|
233
|
+
if (html.startsWith("--", lt + 2)) {
|
|
234
|
+
i = textStart = skipComment(lt + 4);
|
|
235
|
+
} else {
|
|
236
|
+
// DOCTYPE, CDATA and anything else after `<!` end at the next `>`.
|
|
237
|
+
i = textStart = skipBogusComment(lt + 2);
|
|
238
|
+
}
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (c === "?") {
|
|
243
|
+
flushText(lt);
|
|
244
|
+
i = textStart = skipBogusComment(lt + 1);
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// A `<` that starts nothing is text.
|
|
249
|
+
i = lt + 1;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
flushText(n);
|
|
253
|
+
return tokens;
|
|
254
|
+
}
|
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
* only staff can author it, the output is sanitized on every render so a
|
|
6
6
|
* pasted `<script>`, inline event handler or `javascript:` URL can never end
|
|
7
7
|
* up in a page.
|
|
8
|
+
*
|
|
9
|
+
* The input is tokenized the way a browser tokenizes it (`html-tokenizer`),
|
|
10
|
+
* and the output is written from scratch: allowed elements with allowed,
|
|
11
|
+
* re-encoded attributes, and text with every `<` and `>` escaped. Nothing from
|
|
12
|
+
* the input is ever copied through as written, so markup the sanitizer does
|
|
13
|
+
* not recognise — an unterminated tag, a stray `<` — reaches the page as
|
|
14
|
+
* visible text and can never be completed into an element by the markup that
|
|
15
|
+
* follows it.
|
|
8
16
|
*/
|
|
17
|
+
import { tokenizeHtml } from "./html-tokenizer";
|
|
9
18
|
|
|
10
19
|
const ALLOWED_TAGS = new Set([
|
|
11
20
|
"p", "br", "hr", "span", "div",
|
|
@@ -19,9 +28,13 @@ const ALLOWED_TAGS = new Set([
|
|
|
19
28
|
|
|
20
29
|
/** Tags whose entire contents are dropped. */
|
|
21
30
|
const DROP_WITH_CONTENT = new Set([
|
|
22
|
-
"script", "style", "iframe", "object", "
|
|
31
|
+
"script", "style", "iframe", "object", "noscript", "template", "svg", "math",
|
|
32
|
+
"textarea", "title", "xmp", "noembed", "noframes", "plaintext",
|
|
23
33
|
]);
|
|
24
34
|
|
|
35
|
+
/** Dropped elements that can contain themselves, so their end tags are counted. */
|
|
36
|
+
const NESTING = new Set(["svg", "math", "object", "template"]);
|
|
37
|
+
|
|
25
38
|
const VOID_TAGS = new Set(["br", "hr", "img", "col"]);
|
|
26
39
|
|
|
27
40
|
const GLOBAL_ATTRS = new Set(["class", "style", "id", "title", "dir", "lang"]);
|
|
@@ -58,10 +71,13 @@ const ALLOWED_CSS = new Set([
|
|
|
58
71
|
|
|
59
72
|
const SAFE_URL = /^(https?:\/\/|mailto:|tel:|\/|#|\.\/|\.\.\/)/i;
|
|
60
73
|
|
|
61
|
-
function safeUrl(raw: string): string | null {
|
|
74
|
+
function safeUrl(raw: string, attribute: string): string | null {
|
|
62
75
|
const value = raw.trim().replace(/[\u0000-\u001f]/g, "");
|
|
63
76
|
if (!value) return null;
|
|
64
|
-
|
|
77
|
+
// An inline image is an image source, never a link target.
|
|
78
|
+
if (/^data:image\/(png|jpe?g|gif|webp|avif);base64,/i.test(value)) {
|
|
79
|
+
return attribute === "src" ? value : null;
|
|
80
|
+
}
|
|
65
81
|
if (!SAFE_URL.test(value)) return null;
|
|
66
82
|
if (/^\s*javascript:/i.test(value)) return null;
|
|
67
83
|
return value;
|
|
@@ -127,21 +143,17 @@ export function decodeAttr(value: string): string {
|
|
|
127
143
|
);
|
|
128
144
|
}
|
|
129
145
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
function sanitizeAttrs(tag: string, raw: string): string {
|
|
133
|
-
const allowed = TAG_ATTRS[tag];
|
|
146
|
+
function sanitizeAttrs(tag: string, attrs: ReadonlyArray<{ name: string; raw: string }>): string {
|
|
147
|
+
const allowed = Object.prototype.hasOwnProperty.call(TAG_ATTRS, tag) ? TAG_ATTRS[tag] : undefined;
|
|
134
148
|
const parts: string[] = [];
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const name = m[1].toLowerCase();
|
|
139
|
-
let value = decodeAttr(m[3] ?? m[4] ?? m[5] ?? "");
|
|
149
|
+
for (const attr of attrs) {
|
|
150
|
+
const name = attr.name;
|
|
151
|
+
let value = decodeAttr(attr.raw);
|
|
140
152
|
if (name.startsWith("on")) continue;
|
|
141
153
|
if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
|
|
142
154
|
if (name === "href" || name === "src" || name === "srcset") {
|
|
143
155
|
if (name === "srcset") continue;
|
|
144
|
-
const url = safeUrl(value);
|
|
156
|
+
const url = safeUrl(value, name);
|
|
145
157
|
if (!url) continue;
|
|
146
158
|
value = url;
|
|
147
159
|
}
|
|
@@ -164,30 +176,56 @@ function sanitizeAttrs(tag: string, raw: string): string {
|
|
|
164
176
|
return parts.length ? " " + parts.join(" ") : "";
|
|
165
177
|
}
|
|
166
178
|
|
|
179
|
+
/** A character reference as written in text: kept, since it can only decode to text. */
|
|
180
|
+
const CHAR_REF = /^&(?:#[0-9]{1,7}|#[xX][0-9a-fA-F]{1,6}|[a-zA-Z][a-zA-Z0-9]{0,31});/;
|
|
181
|
+
|
|
182
|
+
/** Text as written, made safe to emit: no `<` or `>`, and every `&` a reference. */
|
|
183
|
+
function escapeText(raw: string): string {
|
|
184
|
+
let out = "";
|
|
185
|
+
for (let i = 0; i < raw.length; i++) {
|
|
186
|
+
const c = raw[i]!;
|
|
187
|
+
if (c === "<") out += "<";
|
|
188
|
+
else if (c === ">") out += ">";
|
|
189
|
+
else if (c === "&") out += CHAR_REF.test(raw.slice(i, i + 42)) ? "&" : "&";
|
|
190
|
+
else out += c;
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
167
195
|
/** Sanitize an HTML fragment down to the editor's allowlist. */
|
|
168
196
|
export function sanitizeHtml(input: string | undefined | null): string {
|
|
169
197
|
if (!input) return "";
|
|
170
|
-
// Remove comments and any element whose content must go with it.
|
|
171
|
-
let html = input.replace(/<!--[\s\S]*?-->/g, "");
|
|
172
|
-
for (const tag of DROP_WITH_CONTENT) {
|
|
173
|
-
html = html.replace(new RegExp(`<${tag}[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "");
|
|
174
|
-
html = html.replace(new RegExp(`<${tag}\\b[^>]*\\/?>`, "gi"), "");
|
|
175
|
-
}
|
|
176
|
-
|
|
177
198
|
const open: string[] = [];
|
|
178
199
|
let out = "";
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
200
|
+
/** The dropped element being skipped, and how deep inside it we are. */
|
|
201
|
+
let skipping: { tag: string; depth: number } | null = null;
|
|
202
|
+
|
|
203
|
+
for (const token of tokenizeHtml(input)) {
|
|
204
|
+
if (skipping) {
|
|
205
|
+
if (token.kind === "start" && token.name === skipping.tag && NESTING.has(token.name)) {
|
|
206
|
+
if (!token.selfClosing) skipping.depth++;
|
|
207
|
+
} else if (token.kind === "end" && token.name === skipping.tag) {
|
|
208
|
+
if (--skipping.depth === 0) skipping = null;
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (token.kind === "text") {
|
|
214
|
+
out += escapeText(token.raw);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const tag = token.name;
|
|
219
|
+
if (token.kind === "start" && DROP_WITH_CONTENT.has(tag)) {
|
|
220
|
+
// A self-closing <svg/> or <math/> is empty; every other one has content.
|
|
221
|
+
if (!(token.selfClosing && (tag === "svg" || tag === "math"))) {
|
|
222
|
+
skipping = { tag, depth: 1 };
|
|
223
|
+
}
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
188
226
|
if (!ALLOWED_TAGS.has(tag)) continue;
|
|
189
227
|
|
|
190
|
-
if (
|
|
228
|
+
if (token.kind === "end") {
|
|
191
229
|
if (VOID_TAGS.has(tag)) continue;
|
|
192
230
|
const at = open.lastIndexOf(tag);
|
|
193
231
|
if (at < 0) continue;
|
|
@@ -197,22 +235,19 @@ export function sanitizeHtml(input: string | undefined | null): string {
|
|
|
197
235
|
}
|
|
198
236
|
|
|
199
237
|
if (VOID_TAGS.has(tag)) {
|
|
200
|
-
out += `<${tag}${sanitizeAttrs(tag,
|
|
238
|
+
out += `<${tag}${sanitizeAttrs(tag, token.attrs)} />`;
|
|
201
239
|
continue;
|
|
202
240
|
}
|
|
203
|
-
out += `<${tag}${sanitizeAttrs(tag,
|
|
241
|
+
out += `<${tag}${sanitizeAttrs(tag, token.attrs)}>`;
|
|
204
242
|
open.push(tag);
|
|
205
243
|
}
|
|
206
|
-
out += html.slice(last);
|
|
207
244
|
while (open.length) out += `</${open.pop()}>`;
|
|
208
245
|
return out;
|
|
209
246
|
}
|
|
210
247
|
|
|
211
248
|
/** Plain-text preview of rich text (used for thumbnails and summaries). */
|
|
212
249
|
export function htmlToText(input: string | undefined | null): string {
|
|
213
|
-
return sanitizeHtml(input)
|
|
214
|
-
.replace(/<[^>]+>/g, " ")
|
|
215
|
-
.replace(/ /g, " ")
|
|
250
|
+
return decodeAttr(sanitizeHtml(input).replace(/<[^>]+>/g, " "))
|
|
216
251
|
.replace(/\s+/g, " ")
|
|
217
252
|
.trim();
|
|
218
253
|
}
|