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/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 e2b74b8d..ec4b8f10 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'; @@ -169,28 +169,91 @@ 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]; + // Prototype pollution check. + if (key.startsWith('__')) { + unsafePaths.push(currentPath); + return; + } + 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; +} + +/** + * 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; + + const secureKeys = defaultMermaidConfig.secure ?? []; + const unsafePaths = getUnsafePaths(mermaidConfig, secureKeys).filter((path) => { + return lodashGet(mermaidConfig, path) !== lodashGet(defaultMermaidConfig, path); + }); + + if ( + unsafePaths.length > 0 && + confirm( + `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.` + ) + ) { + 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); +}; + 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) { diff --git a/tests/loadSite.spec.ts b/tests/loadSite.spec.ts index 1e78791f..f94b03f5 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,44 @@ 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', + secure: [], + themeVariables: { + nodeBorder: '' + } + }) + )}` + }).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', + themeVariables: {} + }); + // 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 }) => { await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg'); await page.reload({ waitUntil: 'networkidle' });