astro-mermaid 1.4.0 → 2.0.1

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.
@@ -5,11 +5,19 @@ export interface IconPack {
5
5
  * Name of the icon pack
6
6
  */
7
7
  name: string;
8
-
8
+
9
+ /**
10
+ * URL to the icon pack JSON file (preferred, safe serialization).
11
+ * @example 'https://unpkg.com/@iconify-json/logos@1/icons.json'
12
+ */
13
+ url?: string;
14
+
9
15
  /**
10
- * Function that returns a promise resolving to the icon pack data
16
+ * Legacy: loader function whose source is inspected for a fetch() URL.
17
+ * Prefer using the `url` property instead for safer serialization.
18
+ * @deprecated Use `url` instead.
11
19
  */
12
- loader: () => Promise<any>;
20
+ loader?: () => Promise<any>;
13
21
  }
14
22
 
15
23
  export interface AstroMermaidOptions {
@@ -15,6 +15,36 @@ function escapeHtml(text) {
15
15
  return text.replace(/[&<>"']/g, char => htmlEntities[char]);
16
16
  }
17
17
 
18
+ /**
19
+ * Sanitize a JSON string for safe embedding inside a <script> tag.
20
+ * Prevents premature script termination via </script> or <!-- sequences.
21
+ */
22
+ function sanitizeJsonForScript(jsonStr) {
23
+ return jsonStr
24
+ .replace(/<\//g, '<\\/')
25
+ .replace(/<!--/g, '<\\!--');
26
+ }
27
+
28
+ /**
29
+ * Validate that mermaidConfig is a plain object (not an array, null, etc.)
30
+ * and does not contain __proto__ or constructor keys to prevent prototype pollution.
31
+ */
32
+ function validateConfig(obj, path = 'mermaidConfig') {
33
+ if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
34
+ return;
35
+ }
36
+ const dangerous = ['__proto__', 'constructor', 'prototype'];
37
+ // Use getOwnPropertyNames to catch __proto__ which Object.keys skips
38
+ for (const key of Object.getOwnPropertyNames(obj)) {
39
+ if (dangerous.includes(key)) {
40
+ throw new Error(`astro-mermaid: "${key}" is not allowed in ${path}`);
41
+ }
42
+ if (typeof obj[key] === 'object' && obj[key] !== null) {
43
+ validateConfig(obj[key], `${path}.${key}`);
44
+ }
45
+ }
46
+ }
47
+
18
48
  /**
19
49
  * Remark plugin to transform mermaid code blocks at the markdown level
20
50
  */
@@ -51,6 +81,27 @@ function remarkMermaidPlugin(options = {}) {
51
81
  };
52
82
  }
53
83
 
84
+ /**
85
+ * Escape a string for safe use inside an HTML attribute value.
86
+ */
87
+ function escapeAttribute(value) {
88
+ return String(value).replace(/[&<>"']/g, char => ({
89
+ '&': '&amp;',
90
+ '<': '&lt;',
91
+ '>': '&gt;',
92
+ '"': '&quot;',
93
+ "'": '&#39;'
94
+ })[char]);
95
+ }
96
+
97
+ /**
98
+ * Allowlist of safe HTML tag names that may appear inside mermaid HAST content.
99
+ */
100
+ const ALLOWED_TAG_NAMES = new Set([
101
+ 'b', 'i', 'u', 'em', 'strong', 'br', 'hr', 'sub', 'sup', 'span', 'div',
102
+ 'code', 'pre', 'img', 'a', 'p', 'ul', 'ol', 'li'
103
+ ]);
104
+
54
105
  /**
55
106
  * Helper function to serialize HAST nodes back to HTML text
56
107
  * This preserves HTML tags within the mermaid content
@@ -62,19 +113,26 @@ function serializeHastChildren(children) {
62
113
  if (child.type === 'text') {
63
114
  result += child.value;
64
115
  } else if (child.type === 'element') {
65
- // Reconstruct the HTML tag
116
+ // Reconstruct the HTML tag — only allow safe tag names
66
117
  const tagName = child.tagName;
118
+ if (!ALLOWED_TAG_NAMES.has(tagName)) {
119
+ // Skip disallowed tags, but still serialize their text children
120
+ if (child.children && child.children.length > 0) {
121
+ result += serializeHastChildren(child.children);
122
+ }
123
+ continue;
124
+ }
67
125
  const selfClosing = ['br', 'hr', 'img', 'input', 'meta', 'link'].includes(tagName);
68
126
 
69
127
  result += `<${tagName}`;
70
128
 
71
- // Add attributes if any
129
+ // Add attributes if any — escape all attribute values
72
130
  if (child.properties) {
73
131
  for (const [key, value] of Object.entries(child.properties)) {
74
132
  if (key !== 'className') {
75
- result += ` ${key}="${value}"`;
133
+ result += ` ${key}="${escapeAttribute(value)}"`;
76
134
  } else if (Array.isArray(value)) {
77
- result += ` class="${value.join(' ')}"`;
135
+ result += ` class="${escapeAttribute(value.join(' '))}"`;
78
136
  }
79
137
  }
80
138
  }
@@ -176,6 +234,9 @@ export default function astroMermaid(options = {}) {
176
234
  enableLog = true
177
235
  } = options;
178
236
 
237
+ // Validate mermaidConfig to prevent prototype pollution
238
+ validateConfig(mermaidConfig);
239
+
179
240
  return {
180
241
  name: 'astro-mermaid',
181
242
  hooks: {
@@ -213,11 +274,37 @@ export default function astroMermaid(options = {}) {
213
274
  }
214
275
  });
215
276
 
216
- // Serialize icon packs for client-side use
217
- const iconPacksConfig = iconPacks.map(pack => ({
218
- name: pack.name,
219
- loader: pack.loader.toString()
220
- }));
277
+ // Validate and serialize icon packs for client-side use.
278
+ // Only the pack name and a JSON URL string are forwarded to the
279
+ // client — we never serialize arbitrary function bodies.
280
+ const iconPacksConfig = iconPacks.map(pack => {
281
+ if (typeof pack.name !== 'string' || !pack.name) {
282
+ throw new Error('astro-mermaid: each iconPack must have a non-empty "name" string');
283
+ }
284
+ if (typeof pack.url === 'string') {
285
+ // Preferred: explicit URL
286
+ return { name: pack.name, url: pack.url };
287
+ }
288
+ if (typeof pack.loader === 'function') {
289
+ // Legacy: extract URL from loader().toString() if it contains a
290
+ // fetch('...') call, otherwise warn and skip.
291
+ const src = pack.loader.toString();
292
+ const urlMatch = src.match(/fetch\s*\(\s*['"]([^'"]+)['"]\s*\)/);
293
+ if (urlMatch) {
294
+ return { name: pack.name, url: urlMatch[1] };
295
+ }
296
+ logger.warn(
297
+ `astro-mermaid: iconPack "${pack.name}" uses a loader function ` +
298
+ `that could not be safely serialized. Please provide a "url" ` +
299
+ `property instead. This pack will be skipped.`
300
+ );
301
+ return null;
302
+ }
303
+ throw new Error(
304
+ `astro-mermaid: iconPack "${pack.name}" must have a "url" string ` +
305
+ `or a "loader" function`
306
+ );
307
+ }).filter(Boolean);
221
308
 
222
309
  // Inject client-side mermaid script with conditional loading
223
310
  const mermaidScriptContent = `
@@ -240,13 +327,13 @@ async function loadMermaid() {
240
327
  log('Loading mermaid.js...');
241
328
 
242
329
  mermaidPromise = import('mermaid').then(async ({ default: mermaid }) => {
243
- // Register icon packs if provided
244
- const iconPacks = ${JSON.stringify(iconPacksConfig)};
330
+ // Register icon packs if provided — uses safe fetch(url) instead of eval
331
+ const iconPacks = ${sanitizeJsonForScript(JSON.stringify(iconPacksConfig))};
245
332
  if (iconPacks && iconPacks.length > 0) {
246
333
  log('Registering', iconPacks.length, 'icon packs');
247
334
  const packs = iconPacks.map(pack => ({
248
335
  name: pack.name,
249
- loader: new Function('return ' + pack.loader)()
336
+ loader: () => fetch(pack.url).then(res => res.json())
250
337
  }));
251
338
  await mermaid.registerIconPacks(packs);
252
339
  }
@@ -272,11 +359,11 @@ if (elkModule?.default) {
272
359
  }
273
360
 
274
361
  // Mermaid configuration
275
- const defaultConfig = ${JSON.stringify({
362
+ const defaultConfig = ${sanitizeJsonForScript(JSON.stringify({
276
363
  startOnLoad: false,
277
364
  theme: theme,
278
365
  ...mermaidConfig
279
- })};
366
+ }))};
280
367
 
281
368
  // Theme mapping for auto-theme switching
282
369
  const themeMap = {
@@ -350,10 +437,17 @@ async function initMermaid() {
350
437
  log('Successfully rendered diagram:', id);
351
438
  } catch (error) {
352
439
  logError('Mermaid rendering error for diagram:', id, error);
353
- diagram.innerHTML = \`<div style="color: red; padding: 1rem; border: 1px solid red; border-radius: 0.5rem;">
354
- <strong>Error rendering diagram:</strong><br/>
355
- \${error.message || 'Unknown error'}
356
- </div>\`;
440
+ // Build error UI safely use textContent to prevent XSS via error messages
441
+ const errorDiv = document.createElement('div');
442
+ errorDiv.style.cssText = 'color: red; padding: 1rem; border: 1px solid red; border-radius: 0.5rem;';
443
+ const strong = document.createElement('strong');
444
+ strong.textContent = 'Error rendering diagram:';
445
+ const msg = document.createElement('span');
446
+ msg.textContent = ' ' + (error.message || 'Unknown error');
447
+ errorDiv.appendChild(strong);
448
+ errorDiv.appendChild(msg);
449
+ diagram.textContent = '';
450
+ diagram.appendChild(errorDiv);
357
451
  diagram.setAttribute('data-processed', 'true');
358
452
  }
359
453
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-mermaid",
3
- "version": "1.4.0",
3
+ "version": "2.0.1",
4
4
  "description": "An Astro integration for rendering Mermaid diagrams with automatic theme switching and client-side rendering",
5
5
  "type": "module",
6
6
  "main": "./astro-mermaid-integration.js",
@@ -46,18 +46,20 @@
46
46
  "scripts": {
47
47
  "test": "vitest",
48
48
  "test:ui": "vitest --ui",
49
- "test:coverage": "vitest --coverage"
49
+ "test:coverage": "vitest --coverage",
50
+ "semantic-release": "semantic-release"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@types/hast": "^3.0.4",
53
54
  "@vitest/ui": "^3.2.4",
54
- "astro": "^5.0.0",
55
+ "astro": "^6.0.0",
55
56
  "hast-util-from-html": "^2.0.3",
56
57
  "mermaid": "^11.0.0",
57
58
  "rehype-parse": "^9.0.1",
58
59
  "rehype-stringify": "^10.0.1",
59
60
  "remark-mermaid": "^0.2.0",
60
61
  "remark-parse": "^11.0.0",
62
+ "semantic-release": "^25.0.3",
61
63
  "typescript": "^5.0.0",
62
64
  "unified": "^11.0.5",
63
65
  "vitest": "^3.2.4"