@ipxjs/refract 0.10.2 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ipxjs/refract",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "description": "A minimal React-like virtual DOM library with an optional React compat layer",
5
5
  "type": "module",
6
6
  "main": "src/refract/index.ts",
@@ -1,6 +1,9 @@
1
1
  import type { Fiber } from "./types.js";
2
2
 
3
3
  const SVG_NS = "http://www.w3.org/2000/svg";
4
+ const XLINK_NS = "http://www.w3.org/1999/xlink";
5
+ const XML_NS = "http://www.w3.org/XML/1998/namespace";
6
+ const XMLNS_NS = "http://www.w3.org/2000/xmlns/";
4
7
  const SVG_TAGS = new Set([
5
8
  "svg", "circle", "ellipse", "line", "path", "polygon", "polyline",
6
9
  "rect", "g", "defs", "use", "text", "tspan", "image", "clipPath",
@@ -103,7 +106,12 @@ export function applyProps(
103
106
  if (key.startsWith("on")) {
104
107
  el.removeEventListener(key.slice(2).toLowerCase(), getEventListener(oldProps[key]));
105
108
  } else {
106
- el.removeAttribute(normalizeAttributeName(key, isSvgElement));
109
+ const attr = normalizeAttributeName(key, isSvgElement);
110
+ if (attr.namespaceURI) {
111
+ el.removeAttributeNS(attr.namespaceURI, attr.localName);
112
+ } else {
113
+ el.removeAttribute(attr.name);
114
+ }
107
115
  }
108
116
  }
109
117
  }
@@ -167,17 +175,34 @@ export function applyProps(
167
175
  el.addEventListener(event, getEventListener(newProps[key]));
168
176
  } else {
169
177
  const value = newProps[key];
170
- const attrName = normalizeAttributeName(key, isSvgElement);
171
- if (unsafeUrlPropChecker(key, value)) {
172
- el.removeAttribute(attrName);
178
+ const attr = normalizeAttributeName(key, isSvgElement);
179
+ const securityKey = isSvgElement ? attr.name : key;
180
+ if (unsafeUrlPropChecker(securityKey, value)) {
181
+ if (attr.namespaceURI) {
182
+ el.removeAttributeNS(attr.namespaceURI, attr.localName);
183
+ } else {
184
+ el.removeAttribute(attr.name);
185
+ }
173
186
  continue;
174
187
  }
175
188
  if (value == null || value === false) {
176
- el.removeAttribute(attrName);
189
+ if (attr.namespaceURI) {
190
+ el.removeAttributeNS(attr.namespaceURI, attr.localName);
191
+ } else {
192
+ el.removeAttribute(attr.name);
193
+ }
177
194
  } else if (value === true) {
178
- el.setAttribute(attrName, "true");
195
+ if (attr.namespaceURI) {
196
+ el.setAttributeNS(attr.namespaceURI, attr.name, "true");
197
+ } else {
198
+ el.setAttribute(attr.name, "true");
199
+ }
179
200
  } else {
180
- el.setAttribute(attrName, String(value));
201
+ if (attr.namespaceURI) {
202
+ el.setAttributeNS(attr.namespaceURI, attr.name, String(value));
203
+ } else {
204
+ el.setAttribute(attr.name, String(value));
205
+ }
181
206
  }
182
207
  }
183
208
  break;
@@ -185,19 +210,114 @@ export function applyProps(
185
210
  }
186
211
  }
187
212
 
188
- function normalizeAttributeName(key: string, isSvgElement: boolean): string {
189
- if (!isSvgElement) return key;
190
- if (key === "xlinkHref") return "xlink:href";
191
- if (key === "xmlnsXlink") return "xmlns:xlink";
192
- if (key === "xmlSpace") return "xml:space";
193
- if (key === "xmlLang") return "xml:lang";
194
- if (key === "xmlBase") return "xml:base";
195
- if (SVG_ATTR_CASE_PRESERVED.has(key)) return key;
196
- if (key.startsWith("aria-") || key.startsWith("data-")) return key;
197
- return key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
213
+ type NormalizedAttribute = {
214
+ name: string;
215
+ localName: string;
216
+ namespaceURI: string | null;
217
+ };
218
+
219
+ function normalizeAttributeName(key: string, isSvgElement: boolean): NormalizedAttribute {
220
+ if (!isSvgElement) {
221
+ return { name: key, localName: key, namespaceURI: null };
222
+ }
223
+
224
+ if (key.startsWith("xlink") && key.length > 5) {
225
+ const local = key.slice(5);
226
+ const localName = local.charAt(0).toLowerCase() + local.slice(1);
227
+ return { name: `xlink:${localName.toLowerCase()}`, localName: localName.toLowerCase(), namespaceURI: XLINK_NS };
228
+ }
229
+
230
+ if (key === "xmlnsXlink") {
231
+ return { name: "xmlns:xlink", localName: "xlink", namespaceURI: XMLNS_NS };
232
+ }
233
+
234
+ if (key.startsWith("xml") && key.length > 3) {
235
+ const local = key.slice(3);
236
+ const localName = local.charAt(0).toLowerCase() + local.slice(1);
237
+ return { name: `xml:${localName}`, localName, namespaceURI: XML_NS };
238
+ }
239
+
240
+ if (SVG_ATTR_CASE_PRESERVED.has(key) || key.startsWith("aria-") || key.startsWith("data-")) {
241
+ return { name: key, localName: key, namespaceURI: null };
242
+ }
243
+
244
+ if (SVG_ATTR_KEBAB_CASE.has(key)) {
245
+ const name = key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
246
+ return { name, localName: name, namespaceURI: null };
247
+ }
248
+
249
+ return { name: key, localName: key, namespaceURI: null };
198
250
  }
199
251
 
200
252
  const SVG_ATTR_CASE_PRESERVED = new Set([
201
253
  "viewBox",
202
254
  "preserveAspectRatio",
255
+ "gradientUnits",
256
+ "gradientTransform",
257
+ "patternUnits",
258
+ "patternContentUnits",
259
+ "patternTransform",
260
+ "maskUnits",
261
+ "maskContentUnits",
262
+ "filterUnits",
263
+ "primitiveUnits",
264
+ "pointsAtX",
265
+ "pointsAtY",
266
+ "pointsAtZ",
267
+ "markerUnits",
268
+ "markerWidth",
269
+ "markerHeight",
270
+ "refX",
271
+ "refY",
272
+ "stdDeviation",
273
+ ]);
274
+
275
+ const SVG_ATTR_KEBAB_CASE = new Set([
276
+ "clipPath",
277
+ "clipRule",
278
+ "fillOpacity",
279
+ "fillRule",
280
+ "floodColor",
281
+ "floodOpacity",
282
+ "strokeDasharray",
283
+ "strokeDashoffset",
284
+ "strokeLinecap",
285
+ "strokeLinejoin",
286
+ "strokeMiterlimit",
287
+ "strokeOpacity",
288
+ "strokeWidth",
289
+ "stopColor",
290
+ "stopOpacity",
291
+ "fontFamily",
292
+ "fontSize",
293
+ "fontSizeAdjust",
294
+ "fontStretch",
295
+ "fontStyle",
296
+ "fontVariant",
297
+ "fontWeight",
298
+ "glyphOrientationHorizontal",
299
+ "glyphOrientationVertical",
300
+ "letterSpacing",
301
+ "wordSpacing",
302
+ "textAnchor",
303
+ "textDecoration",
304
+ "textRendering",
305
+ "dominantBaseline",
306
+ "alignmentBaseline",
307
+ "baselineShift",
308
+ "colorInterpolation",
309
+ "colorInterpolationFilters",
310
+ "colorProfile",
311
+ "colorRendering",
312
+ "imageRendering",
313
+ "shapeRendering",
314
+ "pointerEvents",
315
+ "lightingColor",
316
+ "unicodeBidi",
317
+ "renderingIntent",
318
+ "vectorEffect",
319
+ "writingMode",
320
+ "markerStart",
321
+ "markerMid",
322
+ "markerEnd",
203
323
  ]);
@@ -80,6 +80,20 @@ describe("render", () => {
80
80
  expect(link.getAttribute("href")).toBe("https://example.com");
81
81
  });
82
82
 
83
+ it("blocks javascript: URLs on SVG xlinkHref", () => {
84
+ render(
85
+ createElement(
86
+ "svg",
87
+ null,
88
+ createElement("use", { xlinkHref: "javascript:alert('xss')" }),
89
+ ),
90
+ container,
91
+ );
92
+
93
+ const use = container.querySelector("use")!;
94
+ expect(use.getAttribute("xlink:href")).toBeNull();
95
+ });
96
+
83
97
  it("normalizes camelCase SVG attributes used by chart paths", () => {
84
98
  const vnode = createElement(
85
99
  "svg",
@@ -120,4 +134,22 @@ describe("render", () => {
120
134
  const use = container.querySelector("use")!;
121
135
  expect(use.getAttribute("xlink:href")).toBe("#slice");
122
136
  });
137
+
138
+ it("preserves camelCase SVG attributes that are not hyphenated in SVG", () => {
139
+ const vnode = createElement(
140
+ "svg",
141
+ null,
142
+ createElement(
143
+ "linearGradient",
144
+ { id: "g", gradientUnits: "userSpaceOnUse", gradientTransform: "rotate(25)" },
145
+ ),
146
+ );
147
+ render(vnode, container);
148
+
149
+ const gradient = container.querySelector("linearGradient")!;
150
+ expect(gradient.getAttribute("gradientUnits")).toBe("userSpaceOnUse");
151
+ expect(gradient.getAttribute("gradientTransform")).toBe("rotate(25)");
152
+ expect(gradient.getAttribute("gradient-units")).toBeNull();
153
+ expect(gradient.getAttribute("gradient-transform")).toBeNull();
154
+ });
123
155
  });