@scratch/scratch-svg-renderer 13.6.0 → 13.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scratch/scratch-svg-renderer",
3
- "version": "13.6.0",
3
+ "version": "13.6.2",
4
4
  "description": "SVG renderer for Scratch",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/scratchfoundation/scratch-svg-renderer#readme",
@@ -52,7 +52,7 @@
52
52
  "dependencies": {
53
53
  "base64-js": "1.5.1",
54
54
  "base64-loader": "1.0.0",
55
- "css-tree": "1.1.3",
55
+ "css-tree": "3.2.1",
56
56
  "fastestsmallesttextencoderdecoder": "1.0.22",
57
57
  "isomorphic-dompurify": "2.36.0",
58
58
  "transformation-matrix": "1.15.3",
@@ -64,7 +64,7 @@
64
64
  "babel-loader": "9.2.1",
65
65
  "copy-webpack-plugin": "6.4.1",
66
66
  "eslint": "9.39.4",
67
- "eslint-config-scratch": "14.1.3",
67
+ "eslint-config-scratch": "14.1.4",
68
68
  "globals": "16.5.0",
69
69
  "jsdom": "13.2.0",
70
70
  "mkdirp": "2.1.6",
@@ -4,49 +4,100 @@
4
4
  */
5
5
  const fixupSvgString = require('./fixup-svg-string');
6
6
  const {generate, parse, walk} = require('css-tree');
7
+ const {ident} = require('css-tree/utils');
7
8
  const DOMPurify = require('isomorphic-dompurify');
8
9
 
9
10
  const sanitizeSvg = {};
10
11
 
11
- const isInternalRef = ref => ref.startsWith('#') || ref.startsWith('data:');
12
+ const isInternalRef = ref => ref.startsWith('#') || ref.toLowerCase().startsWith('data:');
13
+
14
+ /**
15
+ * Check if raw CSS text contains an external url() reference via regex.
16
+ * Used for Raw nodes (e.g. custom property values) that css-tree doesn't fully parse.
17
+ * @param {string} text - raw CSS text to check
18
+ * @returns {boolean} true if an external url() reference was found
19
+ */
20
+ const rawTextHasExternalUrls = text => {
21
+ const normalized = text.toLowerCase().replace(/\s/g, '');
22
+ const urlPattern = /url\((.+?)\)/g;
23
+ let match;
24
+ while ((match = urlPattern.exec(normalized)) !== null) {
25
+ const ref = match[1].replace(/['"]/g, '');
26
+ if (!isInternalRef(ref)) return true;
27
+ }
28
+ return false;
29
+ };
30
+
31
+ /**
32
+ * Walk a css-tree AST and return true if any Url node references an external resource.
33
+ * Also checks Raw nodes, which css-tree produces for custom property values and other
34
+ * unparsed content that could still contain url() references.
35
+ * @param {import('css-tree').CssNode} ast - The CSS tree or subtree to walk
36
+ * @returns {boolean} True if an external url() reference was found
37
+ */
38
+ const astHasExternalUrls = ast => {
39
+ let found = false;
40
+ walk(ast, node => {
41
+ if (node.type === 'Url') {
42
+ const urlValue = node.value.trim().replace(/['"]/g, '');
43
+ if (!isInternalRef(urlValue)) {
44
+ found = true;
45
+ }
46
+ }
47
+ if (node.type === 'Raw' && rawTextHasExternalUrls(node.value)) {
48
+ found = true;
49
+ }
50
+ });
51
+ return found;
52
+ };
53
+
54
+ /**
55
+ * Canonicalize a CSS string and check it for external url() references.
56
+ * Canonicalization: decode CSS escapes, then parse through css-tree so that all syntax
57
+ * variations (quoting, whitespace, comments, escapes) are normalized into AST nodes.
58
+ * @param {string} cssText - raw CSS text
59
+ * @param {string} parseContext - css-tree parse context: 'value' for a single CSS value
60
+ * (presentation attributes like fill, stroke), or 'declarationList' for style attributes.
61
+ * @returns {boolean} true if an external url() reference was found
62
+ */
63
+ const cssHasExternalUrls = (cssText, parseContext) => {
64
+ const decoded = ident.decode(cssText);
65
+ try {
66
+ return astHasExternalUrls(parse(decoded, {context: parseContext}));
67
+ } catch {
68
+ // If css-tree can't parse it, conservatively check the decoded text.
69
+ // This handles edge cases where creative syntax breaks the parser but
70
+ // a browser might still interpret a url() call.
71
+ return rawTextHasExternalUrls(decoded);
72
+ }
73
+ };
74
+
75
+ // Attributes that directly reference a URI (not via CSS url())
76
+ const URI_ATTRIBUTES = new Set(['href', 'xlink:href']);
12
77
 
13
78
  DOMPurify.addHook(
14
79
  'beforeSanitizeAttributes',
15
80
  currentNode => {
81
+ if (!currentNode || !currentNode.attributes) return currentNode;
16
82
 
17
- if (currentNode && currentNode.href && currentNode.href.baseVal) {
18
- const href = currentNode.href.baseVal.replace(/\s/g, '');
19
- // "data:" and "#" are valid hrefs
20
- if (!isInternalRef(href)) {
21
- // TODO: Those can be in different namespaces than `xlink:`
22
- if (currentNode.attributes.getNamedItem('xlink:href')) {
23
- currentNode.attributes.removeNamedItem('xlink:href');
24
- delete currentNode['xlink:href'];
83
+ for (let i = currentNode.attributes.length - 1; i >= 0; i--) {
84
+ const attr = currentNode.attributes[i];
85
+ if (!attr.value) continue;
86
+
87
+ if (URI_ATTRIBUTES.has(attr.name)) {
88
+ // Direct URI: strip whitespace and check
89
+ if (!isInternalRef(attr.value.replace(/\s/g, ''))) {
90
+ currentNode.removeAttribute(attr.name);
25
91
  }
26
- if (currentNode.attributes.getNamedItem('href')) {
27
- currentNode.attributes.removeNamedItem('href');
28
- delete currentNode.href;
92
+ } else {
93
+ // CSS value that might contain url()
94
+ const context = attr.name === 'style' ? 'declarationList' : 'value';
95
+ if (cssHasExternalUrls(attr.value, context)) {
96
+ currentNode.removeAttribute(attr.name);
29
97
  }
30
98
  }
31
99
  }
32
100
 
33
- // Remove url(...) usages with external references
34
- if (currentNode && currentNode.attributes) {
35
- for (let i = currentNode.attributes.length - 1; i >= 0; i--) {
36
- const attr = currentNode.attributes[i];
37
- const rawValue = attr.value || '';
38
- const value = rawValue.toLowerCase().replace(/\s/g, '');
39
-
40
- const urlMatch = value.match(/url\((.+?)\)/);
41
- if (urlMatch) {
42
- const ref = urlMatch[1].replace(/['"]/g, '');
43
- if (!isInternalRef(ref)) {
44
- currentNode.removeAttribute(attr.name);
45
- }
46
- }
47
- }
48
- }
49
-
50
101
  return currentNode;
51
102
  }
52
103
  );
@@ -55,38 +106,34 @@ DOMPurify.addHook(
55
106
  'uponSanitizeElement',
56
107
  (node, data) => {
57
108
  if (data.tagName === 'style') {
58
- const ast = parse(node.textContent);
59
- let isModified = false;
60
-
61
- walk(ast, (astNode, item, list) => {
62
- // @import rules
63
- if (astNode.type === 'Atrule' && astNode.name.toLowerCase() === 'import') {
64
- list.remove(item);
65
- isModified = true;
66
- }
67
-
68
- // Elements using url(...) for external resources
69
- if (astNode.type === 'Declaration' && astNode.value) {
70
- let shouldRemove = false;
71
- walk(astNode.value, valueNode => {
72
- if (valueNode.type === 'Url') {
73
- const urlValue = (valueNode.value.value || '').trim().replace(/['"]/g, '');
74
-
75
- if (!isInternalRef(urlValue)) {
76
- shouldRemove = true;
77
- }
78
- }
79
- });
80
-
81
- if (shouldRemove) {
109
+ try {
110
+ // Canonicalize: decode CSS escapes then parse, so css-tree sees
111
+ // normalized tokens (e.g. \75\72\6c becomes url).
112
+ const decodedCss = ident.decode(node.textContent);
113
+ const ast = parse(decodedCss);
114
+ let isModified = decodedCss !== node.textContent;
115
+
116
+ walk(ast, (astNode, item, list) => {
117
+ // @import rules
118
+ if (astNode.type === 'Atrule' && astNode.name.toLowerCase() === 'import') {
82
119
  list.remove(item);
83
120
  isModified = true;
84
121
  }
85
- }
86
- });
87
122
 
88
- if (isModified) {
89
- node.textContent = generate(ast);
123
+ // Declarations using url(...) for external resources
124
+ if (astNode.type === 'Declaration' && astNode.value && astHasExternalUrls(astNode.value)) {
125
+ list.remove(item);
126
+ isModified = true;
127
+ }
128
+ });
129
+
130
+ if (isModified) {
131
+ node.textContent = generate(ast);
132
+ }
133
+ } catch {
134
+ // If CSS parsing fails, remove the style content entirely
135
+ // rather than risk passing through unsanitized CSS.
136
+ node.textContent = '';
90
137
  }
91
138
  }
92
139
  }
@@ -98,7 +145,7 @@ let _TextDecoder;
98
145
  let _TextEncoder;
99
146
  if (typeof TextDecoder === 'undefined' || typeof TextEncoder === 'undefined') {
100
147
  // Wait to require the text encoding polyfill until we know it's needed.
101
-
148
+
102
149
  const encoding = require('fastestsmallesttextencoderdecoder');
103
150
  _TextDecoder = encoding.TextDecoder;
104
151
  _TextEncoder = encoding.TextEncoder;