Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/palette-css-safe-color.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 9 additions & 3 deletions packages/mermaid/src/diagrams/block/styles.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 += `
Expand Down
12 changes: 9 additions & 3 deletions packages/mermaid/src/diagrams/class/styles.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand Down
36 changes: 36 additions & 0 deletions packages/mermaid/src/diagrams/common/colorThemeGate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
MAX_COLOR_SLOTS,
colorSlotCount,
paletteSlotCount,
safeColor,
safeLook,
stampColorSlot,
} from './colorThemeGate.js';
Expand Down Expand Up @@ -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`
Expand Down
7 changes: 7 additions & 0 deletions packages/mermaid/src/diagrams/common/colorThemeGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
5 changes: 3 additions & 2 deletions packages/mermaid/src/diagrams/er/styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
hasPalette,
isColorTheme,
paletteSlotCount,
safeColor,
safeLook,
} from '../common/colorThemeGate.js';

Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 10 additions & 4 deletions packages/mermaid/src/diagrams/flowchart/styles.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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])};
}
`
: ''
Expand Down
11 changes: 6 additions & 5 deletions packages/mermaid/src/diagrams/git/styles.js
Original file line number Diff line number Diff line change
@@ -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']);
Expand Down Expand Up @@ -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}; }
`;
}
}
Expand Down
12 changes: 9 additions & 3 deletions packages/mermaid/src/diagrams/requirement/styles.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions packages/mermaid/src/diagrams/timeline/styles.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 9 additions & 3 deletions packages/mermaid/src/diagrams/usecase/styles.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading