From ae7ad1b93e9685f3da359c5fdd7576bbbd7589a3 Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 30 Mar 2026 17:06:32 +0900 Subject: [PATCH 1/5] refactor: move config sanitization into function Moves the code that prompts the user to remove unsafe MermaidConfig settings into it's own function, so that it can be reused. --- src/lib/util/state.ts | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/lib/util/state.ts b/src/lib/util/state.ts index b52cd78a..09c1df3d 100644 --- a/src/lib/util/state.ts +++ b/src/lib/util/state.ts @@ -160,28 +160,34 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => { }; }); +/** + * Asks the user for confirmation if the config contains settings that might + * pose security risks, such as a relaxed `securityLevel`. + * + * @param config - The Mermaid configuration to sanitize. + * @returns The sanitized Mermaid configuration as a JSON string. + */ +export const sanitizeConfig = (config: string | MermaidConfig) => { + const mermaidConfig: MermaidConfig = + typeof config === 'string' ? (JSON.parse(config) as MermaidConfig) : config; + if ( + mermaidConfig.securityLevel && + mermaidConfig.securityLevel !== 'strict' && + confirm( + `Removing "securityLevel":"${mermaidConfig.securityLevel}" from the config for safety.\nClick Cancel if you trust the source of this Diagram.` + ) + ) { + delete mermaidConfig.securityLevel; // Prevent setting overriding securityLevel when loading state to mitigate possible XSS attack + } + return formatJSON(mermaidConfig); +}; + export const loadState = (data: string): void => { let state: State; console.log(`Loading '${data}'`); try { state = deserializeState(data); - if (!state.mermaid) { - state.mermaid = defaultState.mermaid; - } - const mermaidConfig: MermaidConfig = - typeof state.mermaid === 'string' - ? (JSON.parse(state.mermaid) as MermaidConfig) - : state.mermaid; - if ( - mermaidConfig.securityLevel && - mermaidConfig.securityLevel !== 'strict' && - confirm( - `Removing "securityLevel":"${mermaidConfig.securityLevel}" from the config for safety.\nClick Cancel if you trust the source of this Diagram.` - ) - ) { - delete mermaidConfig.securityLevel; // Prevent setting overriding securityLevel when loading state to mitigate possible XSS attack - } - state.mermaid = formatJSON(mermaidConfig); + state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid); } catch (error) { state = get(inputStateStore); if (data) { From 48b9560e8e13db1ba793c6be5da402b825a846c8 Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 30 Mar 2026 18:11:05 +0900 Subject: [PATCH 2/5] fix: sanitize config loaded from gist/`config` URL Currently, the `config` in the codeState in the hash is sanitized for unsafe values, however the `?config` URL parameter or configs loaded from a GitHub Gist are not. Reported-by: Chai Cheng Xun @QiaoNPC --- src/lib/util/fileLoaders/loader.ts | 3 ++- tests/loadSite.spec.ts | 34 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/lib/util/fileLoaders/loader.ts b/src/lib/util/fileLoaders/loader.ts index 70f7664f..ca6cb996 100644 --- a/src/lib/util/fileLoaders/loader.ts +++ b/src/lib/util/fileLoaders/loader.ts @@ -1,5 +1,5 @@ import type { Loader, State } from '$lib/types'; -import { defaultState, updateCodeStore } from '$lib/util/state'; +import { defaultState, sanitizeConfig, updateCodeStore } from '$lib/util/state'; import { fetchText } from '$lib/util/util'; import { loadGistData } from './gist'; @@ -50,6 +50,7 @@ export const loadDataFromUrl = async (): Promise => { } } if (loaded) { + state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid); updateCodeStore({ ...state, updateDiagram: true diff --git a/tests/loadSite.spec.ts b/tests/loadSite.spec.ts index 1e78791f..913ce5ed 100644 --- a/tests/loadSite.spec.ts +++ b/tests/loadSite.spec.ts @@ -1,3 +1,5 @@ +import type { State } from '$/types'; +import assert from 'node:assert'; import { expect, test } from './test'; test.describe('Site Loads', () => { @@ -67,6 +69,38 @@ test.describe('Site Loads', () => { }); }); + test('should prompt user to scrub unsafe config', async ({ editPage, page }) => { + let dialogAccepted = false; + page.on('dialog', async (dialog) => { + expect(dialog.type()).toBe('confirm'); + expect(dialog.message()).toContain('from the config for safety'); + await dialog.accept(); + dialogAccepted = true; + }); + await editPage.start( + `/edit?${new URLSearchParams({ + code: `data:application/vnd.mermaid,${encodeURIComponent('flowchart TD\nHello-->World')}`, + config: `data:application/json,${encodeURIComponent( + JSON.stringify({ + someOtherSetting: 'Test value', + securityLevel: 'loose' + }) + )}` + }).toString()}` + ); + await editPage.checkTextInView('Hello'); + await expect.poll(() => dialogAccepted).toBeTruthy(); + const codeStore = await page.evaluate(() => localStorage.getItem('codeStore')); + assert(codeStore); + const parsedStore = JSON.parse(codeStore) as State; + const parsedConfig = JSON.parse(parsedStore.mermaid) as Record; + expect(parsedConfig).toEqual({ + someOtherSetting: 'Test value' + }); + // should scrub unsafe securityLevel but keep other settings + expect(parsedConfig.securityLevel).toBeUndefined(); + }); + test('should show troubleshooting steps if loading fails', async ({ editPage, page }) => { await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg'); await page.reload({ waitUntil: 'networkidle' }); From 7e9cdfca7302e08ee02f2bd33558d8ecba16d8ce Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 30 Mar 2026 21:42:18 +0900 Subject: [PATCH 3/5] fix: sanitize all `secure` keys in config Check and remove all the secure keys in the site Mermaid Config, not just `securityLevel`. Unfortunately, the logic of Mermaid's `sanitize` function in `config.ts` is a bit convoluted. For instance, the `secure` config restricts keys deeply in the object. See: https://github.com/mermaid-js/mermaid/blob/9745f325cb9e1967640f0e85da193a2f820634f1/packages/mermaid/src/config.ts#L155-L190 --- src/lib/util/mermaid.ts | 5 ++++ src/lib/util/state.ts | 58 ++++++++++++++++++++++++++++++++++++----- tests/loadSite.spec.ts | 4 ++- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/lib/util/mermaid.ts b/src/lib/util/mermaid.ts index d6c45f5b..6ed245f2 100644 --- a/src/lib/util/mermaid.ts +++ b/src/lib/util/mermaid.ts @@ -24,6 +24,11 @@ export const parse = async (code: string) => { return await mermaid.parse(code); }; +/** + * @see https://mermaid.js.org/config/schema-docs/config.html + */ +export const defaultMermaidConfig = mermaid.mermaidAPI.defaultConfig ?? {}; + export const standardizeDiagramType = (diagramType: string) => { switch (diagramType) { case 'class': diff --git a/src/lib/util/state.ts b/src/lib/util/state.ts index 09c1df3d..429cf736 100644 --- a/src/lib/util/state.ts +++ b/src/lib/util/state.ts @@ -1,5 +1,5 @@ import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types'; -import { debounce } from 'lodash-es'; +import { debounce, get as lodashGet } from 'lodash-es'; import type { MermaidConfig } from 'mermaid'; import { derived, get, writable, type Readable } from 'svelte/store'; import { env } from './env'; @@ -8,7 +8,7 @@ import { findMostRelevantLineNumber, replaceLineNumberInErrorMessage } from './errorHandling'; -import { parse } from './mermaid'; +import { defaultMermaidConfig, parse } from './mermaid'; import { localStorage, persist } from './persist'; import { deserializeState, pakoSerde, serializeState } from './serde'; import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util'; @@ -160,6 +160,33 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => { }; }); +/** + * Gets a list of paths that contain unsafe keys which might pose security risks. + * + * @param object - The object to check for unsafe keys. + * @param unsafeKeys - List of unsafe keys. + * @param path - The current path being checked (used for recursion). + * @returns List of unsafe paths. + */ +function getUnsafePaths(object: object, unsafeKeys: string[], path: string[] = []) { + const unsafePaths = new Array(); + for (const key of unsafeKeys) { + // Copied from mermaid's sanitize function in case there's non-enumerable keys + if (Object.hasOwn(object, key)) { + unsafePaths.push([...path, key]); + continue; + } + } + Object.keys(object).forEach((key) => { + const value = object[key] as unknown; + const currentPath = [...path, key]; + if (typeof value === 'object' && value !== null) { + unsafePaths.push(...getUnsafePaths(value as object, unsafeKeys, currentPath)); + } + }); + return unsafePaths; +} + /** * Asks the user for confirmation if the config contains settings that might * pose security risks, such as a relaxed `securityLevel`. @@ -170,14 +197,33 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => { export const sanitizeConfig = (config: string | MermaidConfig) => { const mermaidConfig: MermaidConfig = typeof config === 'string' ? (JSON.parse(config) as MermaidConfig) : config; + + const secureKeys = defaultMermaidConfig.secure ?? []; + const unsafePaths = getUnsafePaths(mermaidConfig, secureKeys).filter((path) => { + return lodashGet(mermaidConfig, path) !== lodashGet(defaultMermaidConfig, path); + }); + if ( - mermaidConfig.securityLevel && - mermaidConfig.securityLevel !== 'strict' && + unsafePaths.length > 0 && confirm( - `Removing "securityLevel":"${mermaidConfig.securityLevel}" from the config for safety.\nClick Cancel if you trust the source of this Diagram.` + `Removing ${unsafePaths + .map((unsafePath) => { + return `${JSON.stringify(unsafePath.join('.'))}: ${JSON.stringify(lodashGet(mermaidConfig, unsafePath))}`; + }) + .join( + ',\n' + )} from the config for safety.\nClick Cancel if you trust the source of this Diagram.` ) ) { - delete mermaidConfig.securityLevel; // Prevent setting overriding securityLevel when loading state to mitigate possible XSS attack + for (const unsafePath of unsafePaths) { + const pathToObject = [...unsafePath]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know this exists since it was found in `getUnsafePaths` + const lastKey = pathToObject.pop()!; + const lastObject = + pathToObject.length === 0 ? mermaidConfig : lodashGet(mermaidConfig, pathToObject); + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Copied from mermaid code + delete lastObject[lastKey]; + } } return formatJSON(mermaidConfig); }; diff --git a/tests/loadSite.spec.ts b/tests/loadSite.spec.ts index 913ce5ed..e0d8b0a7 100644 --- a/tests/loadSite.spec.ts +++ b/tests/loadSite.spec.ts @@ -83,7 +83,8 @@ test.describe('Site Loads', () => { config: `data:application/json,${encodeURIComponent( JSON.stringify({ someOtherSetting: 'Test value', - securityLevel: 'loose' + securityLevel: 'loose', + secure: [] }) )}` }).toString()}` @@ -99,6 +100,7 @@ test.describe('Site Loads', () => { }); // should scrub unsafe securityLevel but keep other settings expect(parsedConfig.securityLevel).toBeUndefined(); + expect(parsedConfig.secure).toBeUndefined(); }); test('should show troubleshooting steps if loading fails', async ({ editPage, page }) => { From 67aacdebe4f0f62605bf4aaabea11fd92b71030f Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 30 Mar 2026 21:54:25 +0900 Subject: [PATCH 4/5] fix: sanitize config for `__` keys According to upstream, this is supposed to be fore prototype pollution prevention. See: https://github.com/mermaid-js/mermaid/blob/9745f325cb9e1967640f0e85da193a2f820634f1/packages/mermaid/src/config.ts#L169-L174 --- src/lib/util/state.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/util/state.ts b/src/lib/util/state.ts index 429cf736..79f8355a 100644 --- a/src/lib/util/state.ts +++ b/src/lib/util/state.ts @@ -180,6 +180,11 @@ function getUnsafePaths(object: object, unsafeKeys: string[], path: string[] = [ Object.keys(object).forEach((key) => { const value = object[key] as unknown; const currentPath = [...path, key]; + // Prototype pollution check. + if (key.startsWith('__')) { + unsafePaths.push(currentPath); + return; + } if (typeof value === 'object' && value !== null) { unsafePaths.push(...getUnsafePaths(value as object, unsafeKeys, currentPath)); } From 46a7a533ab4a1017009c440ec8dcd39a1e4f725c Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 30 Mar 2026 21:58:13 +0900 Subject: [PATCH 5/5] fix: copy mermaid config sanitization check Copy the XSS prevention check from the `sanitize` function in mermaid upstream's `config.ts` file. See: https://github.com/mermaid-js/mermaid/blob/9745f325cb9e1967640f0e85da193a2f820634f1/packages/mermaid/src/config.ts#L178-L183 --- src/lib/util/state.ts | 6 ++++++ tests/loadSite.spec.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/lib/util/state.ts b/src/lib/util/state.ts index 79f8355a..5274e513 100644 --- a/src/lib/util/state.ts +++ b/src/lib/util/state.ts @@ -187,6 +187,12 @@ function getUnsafePaths(object: object, unsafeKeys: string[], path: string[] = [ } if (typeof value === 'object' && value !== null) { unsafePaths.push(...getUnsafePaths(value as object, unsafeKeys, currentPath)); + } else if ( + typeof value === 'string' && + // XSS prevention checks -- See mermaid `sanitize` function for reference. + (value.includes('<') || value.includes('>') || value.includes('url(data:')) + ) { + unsafePaths.push(currentPath); } }); return unsafePaths; diff --git a/tests/loadSite.spec.ts b/tests/loadSite.spec.ts index e0d8b0a7..f94b03f5 100644 --- a/tests/loadSite.spec.ts +++ b/tests/loadSite.spec.ts @@ -84,7 +84,10 @@ test.describe('Site Loads', () => { JSON.stringify({ someOtherSetting: 'Test value', securityLevel: 'loose', - secure: [] + secure: [], + themeVariables: { + nodeBorder: '' + } }) )}` }).toString()}` @@ -96,7 +99,8 @@ test.describe('Site Loads', () => { const parsedStore = JSON.parse(codeStore) as State; const parsedConfig = JSON.parse(parsedStore.mermaid) as Record; expect(parsedConfig).toEqual({ - someOtherSetting: 'Test value' + someOtherSetting: 'Test value', + themeVariables: {} }); // should scrub unsafe securityLevel but keep other settings expect(parsedConfig.securityLevel).toBeUndefined();