From 7e9cdfca7302e08ee02f2bd33558d8ecba16d8ce Mon Sep 17 00:00:00 2001 From: Alois Klink Date: Mon, 30 Mar 2026 21:42:18 +0900 Subject: [PATCH] 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 }) => {