@bygghammar/cms-platform 0.92.1 → 0.92.2

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 CHANGED
@@ -8,6 +8,19 @@ 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.92.2
12
+
13
+ - Fix rich-text attribute escaping. `sanitizeHtml` read attribute values as
14
+ raw text and escaped them again, so every save and every render added an
15
+ `amp;` to each `&` in a link: `?calendardates=…&trp=…` reached the browser
16
+ with a query key named `amp;trp`. Attribute values are now decoded once,
17
+ checked, and escaped exactly once, so sanitizing is idempotent through save
18
+ and render. The URL allowlist, style checks and target check judge the
19
+ decoded value, the one the browser will use. Text content is unchanged.
20
+ - Links stored before this release keep their extra levels until an editor
21
+ saves the correct URL; they no longer grow on each save.
22
+ - Two scheduling test suites no longer depend on the date they run on.
23
+
11
24
  ## 0.92.1
12
25
 
13
26
  - Fix the page-builder save and publish RPC call by removing the unsupported
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.92.1",
3
+ "version": "0.92.2",
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": [
@@ -85,6 +85,48 @@ function escapeAttr(v: string): string {
85
85
  return v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
86
86
  }
87
87
 
88
+ /** The named references the editor and browsers write into attribute values. */
89
+ const NAMED_REFERENCES: Record<string, string> = {
90
+ amp: "&",
91
+ AMP: "&",
92
+ lt: "<",
93
+ LT: "<",
94
+ gt: ">",
95
+ GT: ">",
96
+ quot: '"',
97
+ QUOT: '"',
98
+ apos: "'",
99
+ nbsp: "\u00a0",
100
+ };
101
+
102
+ /**
103
+ * An attribute value as a browser reads it: its character references decoded.
104
+ *
105
+ * Stored markup is HTML, so `?a=1&amp;b=2` means `?a=1&b=2`. Everything below
106
+ * judges the decoded value, and `escapeAttr` encodes it exactly once on the
107
+ * way out. Reading the raw text instead let `escapeAttr` encode the editor's
108
+ * own `&amp;` a second time, so every save and every render added a level and
109
+ * a link's `&b=` arrived in the browser as a key named `amp;b`.
110
+ *
111
+ * Numeric references, and the named ones above, are decoded. Any other name
112
+ * is left as written, so it is escaped as text and reaches the browser as that
113
+ * literal text. Either way the browser ends up with exactly the string that
114
+ * was checked: `&#106;avascript:` is judged as `javascript:`, and an
115
+ * undecoded `javascript&colon;` never becomes one.
116
+ */
117
+ function decodeAttr(value: string): string {
118
+ return value.replace(
119
+ /&(?:#([0-9]{1,7})|#[xX]([0-9a-fA-F]{1,6})|([a-zA-Z][a-zA-Z0-9]{0,31}));/g,
120
+ (reference, decimal?: string, hex?: string, name?: string) => {
121
+ if (name !== undefined) return NAMED_REFERENCES[name] ?? reference;
122
+ const code = decimal !== undefined ? Number.parseInt(decimal, 10) : Number.parseInt(hex ?? "", 16);
123
+ // What a browser does with these: the replacement character.
124
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return "\ufffd";
125
+ return String.fromCodePoint(code);
126
+ },
127
+ );
128
+ }
129
+
88
130
  const ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
89
131
 
90
132
  function sanitizeAttrs(tag: string, raw: string): string {
@@ -94,7 +136,7 @@ function sanitizeAttrs(tag: string, raw: string): string {
94
136
  ATTR_RE.lastIndex = 0;
95
137
  while ((m = ATTR_RE.exec(raw))) {
96
138
  const name = m[1].toLowerCase();
97
- let value = m[3] ?? m[4] ?? m[5] ?? "";
139
+ let value = decodeAttr(m[3] ?? m[4] ?? m[5] ?? "");
98
140
  if (name.startsWith("on")) continue;
99
141
  if (!GLOBAL_ATTRS.has(name) && !allowed?.has(name)) continue;
100
142
  if (name === "href" || name === "src" || name === "srcset") {