diff --git a/.changeset/palette-css-safe-color.md b/.changeset/palette-css-safe-color.md new file mode 100644 index 00000000000..32946bfb92b --- /dev/null +++ b/.changeset/palette-css-safe-color.md @@ -0,0 +1,5 @@ +--- +'mermaid': patch +--- + +fix: escape theme palette colours before they're interpolated into generated CSS, mirroring the existing `look` handling. A `themeVariables` palette array (`borderColorArray`/`bkgColorArray`) is currently stripped by config sanitization before it can reach a diagram's stylesheet, but the interpolation sites themselves had no defense of their own if that ever changed. diff --git a/packages/mermaid/src/diagrams/block/styles.ts b/packages/mermaid/src/diagrams/block/styles.ts index 6107f7a38ae..2e4e6718cb1 100644 --- a/packages/mermaid/src/diagrams/block/styles.ts +++ b/packages/mermaid/src/diagrams/block/styles.ts @@ -1,6 +1,12 @@ import * as khroma from 'khroma'; import { getIconStyles } from '../globalStyles.js'; -import { colorSlotCount, hasPalette, isColorTheme, safeLook } from '../common/colorThemeGate.js'; +import { + colorSlotCount, + hasPalette, + isColorTheme, + safeColor, + safeLook, +} from '../common/colorThemeGate.js'; /** Returns the styles given options */ export interface BlockChartStyleOptions { @@ -47,8 +53,8 @@ const genColor = (options: BlockChartStyleOptions) => { let sections = ''; for (let i = 0; i < colorSlotCount(options.THEME_COLOR_LIMIT, borderColorArray); i++) { - const borderColor = borderColorArray![i % borderColorArray!.length]; - const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; + const borderColor = safeColor(borderColorArray![i % borderColorArray!.length]); + const fill = hasBkgColors ? `fill: ${safeColor(bkgColorArray[i % bkgColorArray.length])};` : ''; const slot = `[data-look="${look}"][data-color-id="color-${i}"]`; sections += ` diff --git a/packages/mermaid/src/diagrams/class/styles.js b/packages/mermaid/src/diagrams/class/styles.js index 4d55b43f986..d3339f8ee00 100644 --- a/packages/mermaid/src/diagrams/class/styles.js +++ b/packages/mermaid/src/diagrams/class/styles.js @@ -1,5 +1,11 @@ import { getIconStyles } from '../globalStyles.js'; -import { colorSlotCount, hasPalette, isColorTheme, safeLook } from '../common/colorThemeGate.js'; +import { + colorSlotCount, + hasPalette, + isColorTheme, + safeColor, + safeLook, +} from '../common/colorThemeGate.js'; /** * Cycling per-class colour, mirroring `er/styles.ts`. A class box is the structural twin @@ -21,12 +27,12 @@ const genColor = (options) => { let sections = ''; for (let i = 0; i < colorSlotCount(options.THEME_COLOR_LIMIT, borderColorArray); i++) { - const borderColor = borderColorArray[i % borderColorArray.length]; + const borderColor = safeColor(borderColorArray[i % borderColorArray.length]); sections += ` [data-look="${look}"][data-color-id="color-${i}"].node .outer-path path { stroke: ${borderColor}; - ${hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''} + ${hasBkgColors ? `fill: ${safeColor(bkgColorArray[i % bkgColorArray.length])};` : ''} } [data-look="${look}"][data-color-id="color-${i}"].node .divider path { diff --git a/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts b/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts index 59a9de7cfd3..14a24701dc7 100644 --- a/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts +++ b/packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts @@ -24,6 +24,7 @@ import { MAX_COLOR_SLOTS, colorSlotCount, paletteSlotCount, + safeColor, safeLook, stampColorSlot, } from './colorThemeGate.js'; @@ -159,6 +160,41 @@ describe('safeLook', () => { }); }); +describe('safeColor', () => { + it.each([ + '#fff', + '#ffff', + '#a1b2c3', + '#a1b2c3d4', + 'red', + 'currentColor', + 'rgb(1, 2, 3)', + 'hsla(1, 2%, 3%, 0.5)', + ])('passes %s through', (color) => { + expect(safeColor(color)).toBe(color); + }); + + it.each(['red; } .evil { background: url(x)', 'red"]{a{b', '', '#12345', '#1234567'])( + 'falls back to currentColor for %j', + (color) => { + expect(safeColor(color)).toBe('currentColor'); + } + ); + + it('falls back to a given fallback', () => { + expect(safeColor('bad;value', 'black')).toBe('black'); + }); + + it('preserves incidental surrounding whitespace on an otherwise valid value', () => { + // Validates trimmed, but returns the original value untrimmed. + expect(safeColor('#A3E635 ')).toBe('#A3E635 '); + }); + + it('falls back to currentColor for a non-string value', () => { + expect(safeColor(['red'])).toBe('currentColor'); + }); +}); + /** * Unlike `look`, palette entries (`borderColorArray`/`bkgColorArray`) are interpolated * straight into generated CSS by `er/styles.ts`, `requirement/styles.js`, `block/styles.ts` diff --git a/packages/mermaid/src/diagrams/common/colorThemeGate.ts b/packages/mermaid/src/diagrams/common/colorThemeGate.ts index 66d356e532b..b2cd68173ce 100644 --- a/packages/mermaid/src/diagrams/common/colorThemeGate.ts +++ b/packages/mermaid/src/diagrams/common/colorThemeGate.ts @@ -60,6 +60,13 @@ export const safeLook = (look: unknown): string => { return SAFE_LOOK.test(s) ? s : 'classic'; }; +/** Same reasoning as `SAFE_LOOK`, but for palette colour values: hex, keyword, or rgb/hsl(a). */ +const SAFE_COLOR = + /^(#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})|[\w-]+|(rgb|rgba|hsl|hsla)\([\d\s%,./]+\))$/i; + +export const safeColor = (color: unknown, fallback = 'currentColor'): string => + typeof color === 'string' && SAFE_COLOR.test(color.trim()) ? color : fallback; + /** * Number of palette slots a stylesheet should emit. * diff --git a/packages/mermaid/src/diagrams/er/styles.ts b/packages/mermaid/src/diagrams/er/styles.ts index 1cd056afea1..63dc10902b8 100644 --- a/packages/mermaid/src/diagrams/er/styles.ts +++ b/packages/mermaid/src/diagrams/er/styles.ts @@ -5,6 +5,7 @@ import { hasPalette, isColorTheme, paletteSlotCount, + safeColor, safeLook, } from '../common/colorThemeGate.js'; @@ -43,8 +44,8 @@ const genColor: DiagramStylesProvider = (options) => { // `borderColorArray[i]` needs no wrap now the bound is its own length. The background // palette is a separate array that may be shorter, so that one still wraps -- guarded // by `hasBkgColors`, since `i % 0` is NaN and `[][NaN]` is `undefined`. - const borderColor = borderColorArray[i]; - const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; + const borderColor = safeColor(borderColorArray[i]); + const fill = hasBkgColors ? `fill: ${safeColor(bkgColorArray[i % bkgColorArray.length])};` : ''; sections += ` [data-look="${look}"][data-color-id="color-${i}"].node path { diff --git a/packages/mermaid/src/diagrams/flowchart/styles.ts b/packages/mermaid/src/diagrams/flowchart/styles.ts index e0286b7bbf8..7d55cc2e5a0 100644 --- a/packages/mermaid/src/diagrams/flowchart/styles.ts +++ b/packages/mermaid/src/diagrams/flowchart/styles.ts @@ -1,7 +1,13 @@ // import khroma from 'khroma'; import * as khroma from 'khroma'; import { getIconStyles } from '../globalStyles.js'; -import { colorSlotCount, hasPalette, isColorTheme, safeLook } from '../common/colorThemeGate.js'; +import { + colorSlotCount, + hasPalette, + isColorTheme, + safeColor, + safeLook, +} from '../common/colorThemeGate.js'; /** Returns the styles given options */ export interface FlowChartStyleOptions { @@ -56,8 +62,8 @@ const genColor = (options: FlowChartStyleOptions) => { let sections = ''; for (let i = 0; i < colorSlotCount(options.THEME_COLOR_LIMIT, borderColorArray); i++) { - const borderColor = borderColorArray![i % borderColorArray!.length]; - const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; + const borderColor = safeColor(borderColorArray![i % borderColorArray!.length]); + const fill = hasBkgColors ? `fill: ${safeColor(bkgColorArray[i % bkgColorArray.length])};` : ''; const slot = `[data-look="${look}"][data-color-id="color-${i}"]`; /* A collapsed subgraph is drawn by `collapsedGroup.ts` through `getNodeClasses`, which * returns `rough-node` instead of `node` for the handDrawn look -- so a `.node`-only @@ -101,7 +107,7 @@ ${ ? ` /* A roughjs fill is drawn as lines, so the lane fill is a stroke here. */ ${laneRule(' path:first-of-type')} { - stroke: ${bkgColorArray[i % bkgColorArray.length]}; + stroke: ${safeColor(bkgColorArray[i % bkgColorArray.length])}; } ` : '' diff --git a/packages/mermaid/src/diagrams/git/styles.js b/packages/mermaid/src/diagrams/git/styles.js index fd5a20b4bb7..55ab5a34de6 100644 --- a/packages/mermaid/src/diagrams/git/styles.js +++ b/packages/mermaid/src/diagrams/git/styles.js @@ -1,4 +1,5 @@ import * as configApi from '../../config.js'; +import { safeColor } from '../common/colorThemeGate.js'; const GIT_NAMED_COLOR_COUNT = 8; const REDUX_GEOMETRY_THEMES = new Set(['redux', 'redux-dark', 'redux-color', 'redux-dark-color']); @@ -86,13 +87,13 @@ const genColor = (options) => { .commit-bullets { fill: ${options.nodeBorder}; } `; } else { - const colorIndex = i % borderColorArray.length; + const paletteColor = safeColor(borderColorArray[i % borderColorArray.length]); sections += ` .branch-label${i} { fill: ${options.nodeBorder}; ${useReduxGeometry ? `font-weight:${options.noteFontWeight}` : ''} } - .commit${i} { stroke: ${borderColorArray[colorIndex]}; fill: ${borderColorArray[colorIndex]}; } - .commit-highlight${i} { stroke: ${borderColorArray[colorIndex]}; fill: ${borderColorArray[colorIndex]}; } - .label${i} { fill: ${DARK_THEMES.has(theme) ? options.mainBkg : borderColorArray[colorIndex]}; stroke: ${borderColorArray[colorIndex]}; stroke-width: ${options.strokeWidth}; } - .arrow${i} { stroke: ${borderColorArray[colorIndex]}; } + .commit${i} { stroke: ${paletteColor}; fill: ${paletteColor}; } + .commit-highlight${i} { stroke: ${paletteColor}; fill: ${paletteColor}; } + .label${i} { fill: ${DARK_THEMES.has(theme) ? options.mainBkg : paletteColor}; stroke: ${paletteColor}; stroke-width: ${options.strokeWidth}; } + .arrow${i} { stroke: ${paletteColor}; } `; } } diff --git a/packages/mermaid/src/diagrams/requirement/styles.js b/packages/mermaid/src/diagrams/requirement/styles.js index 2a7cc501f7e..a13e4a205ff 100644 --- a/packages/mermaid/src/diagrams/requirement/styles.js +++ b/packages/mermaid/src/diagrams/requirement/styles.js @@ -1,5 +1,11 @@ import * as configApi from '../../config.js'; -import { hasPalette, isColorTheme, paletteSlotCount, safeLook } from '../common/colorThemeGate.js'; +import { + hasPalette, + isColorTheme, + paletteSlotCount, + safeColor, + safeLook, +} from '../common/colorThemeGate.js'; const genColor = () => { const config = configApi.getConfig(); @@ -29,8 +35,8 @@ const genColor = () => { // The background palette is a separate array that may be shorter than the border one, // so it still wraps; `borderColorArray[i]` does not need to, now the bound is its own // length. - const borderColor = borderColorArray[i]; - const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; + const borderColor = safeColor(borderColorArray[i]); + const fill = hasBkgColors ? `fill: ${safeColor(bkgColorArray[i % bkgColorArray.length])};` : ''; sections += ` [data-look="${look}"][data-color-id="color-${i}"].node path { diff --git a/packages/mermaid/src/diagrams/timeline/styles.js b/packages/mermaid/src/diagrams/timeline/styles.js index 8c5795ab32d..12853023652 100644 --- a/packages/mermaid/src/diagrams/timeline/styles.js +++ b/packages/mermaid/src/diagrams/timeline/styles.js @@ -1,6 +1,10 @@ import { darken, lighten, isDark } from 'khroma'; import { getConfig } from './../../config.js'; -import { colorSlotCount, isColorTheme as isPaletteTheme } from '../common/colorThemeGate.js'; +import { + colorSlotCount, + isColorTheme as isPaletteTheme, + safeColor, +} from '../common/colorThemeGate.js'; const genReduxSections = (options) => { const { theme } = getConfig(); @@ -29,7 +33,7 @@ const genReduxSections = (options) => { // however many sections exist -- indexing raw would leave the overflow sections // undefined. const slot = isColorTheme - ? options.borderColorArray[i % options.borderColorArray.length] + ? safeColor(options.borderColorArray[i % options.borderColorArray.length]) : undefined; const color = slot ?? options.mainBkg; const stroke = slot ?? options.nodeBorder; diff --git a/packages/mermaid/src/diagrams/usecase/styles.ts b/packages/mermaid/src/diagrams/usecase/styles.ts index f279f811036..3e48f9949c8 100644 --- a/packages/mermaid/src/diagrams/usecase/styles.ts +++ b/packages/mermaid/src/diagrams/usecase/styles.ts @@ -1,6 +1,12 @@ import * as configApi from '../../config.js'; import type { DiagramStylesProvider } from '../../diagram-api/types.js'; -import { hasPalette, isColorTheme, paletteSlotCount, safeLook } from '../common/colorThemeGate.js'; +import { + hasPalette, + isColorTheme, + paletteSlotCount, + safeColor, + safeLook, +} from '../common/colorThemeGate.js'; interface UsecaseStyleOptions { actorBkg?: string; @@ -96,11 +102,11 @@ const genColor: DiagramStylesProvider = (options) => { // `colorIndex % borderColorArray.length`, so deriving the bound from the same length is // what keeps the emitted rules and the stamped ids from disagreeing. for (let i = 0; i < paletteSlotCount(borderColorArray); i++) { - const borderColor = borderColorArray[i]; + const borderColor = safeColor(borderColorArray[i]); // The background palette is a separate array that may be shorter, so it still wraps -- // guarded by `hasBkgColors`, since `i % 0` is NaN and `[][NaN]` is `undefined`. // `redux-dark-color` is the live no-background case: it colours outlines only. - const fill = hasBkgColors ? `fill: ${bkgColorArray[i % bkgColorArray.length]};` : ''; + const fill = hasBkgColors ? `fill: ${safeColor(bkgColorArray[i % bkgColorArray.length])};` : ''; const slot = `[data-look="${look}"][data-color-id="color-${i}"]`; /* System boundaries, in both schemes. A boundary is a container, and numbering the