From a4f36e2c59ac7026dc268840f1f269b232180c8f Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 7 Sep 2026 14:21:44 +0200 Subject: [PATCH 1/5] fix(react-charts): prevent Sankey prototype pollution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json | 6 +++++ ...-74a9389f-5fc5-45cf-b170-ae0c14afed64.json | 6 +++++ .../SankeyChart/SankeyChart.base.tsx | 20 +++++++++------- .../SankeyChart/SankeyChart.test.tsx | 18 +++++++++++++++ .../SankeyChart/SankeyChart.test.tsx | 23 +++++++++++++++++++ .../components/SankeyChart/SankeyChart.tsx | 20 +++++++++------- 6 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 change/@fluentui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json create mode 100644 change/@fluentui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json diff --git a/change/@fluentui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json b/change/@fluentui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json new file mode 100644 index 00000000000000..960393be73b786 --- /dev/null +++ b/change/@fluentui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "Fix prototype pollution in SankeyChart node ID maps.", + "packageName": "@fluentui/react-charting", + "email": "paulmardling@microsoft.com" +} diff --git a/change/@fluentui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json b/change/@fluentui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json new file mode 100644 index 00000000000000..3c5ebd09bfaf3a --- /dev/null +++ b/change/@fluentui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "Fix prototype pollution in SankeyChart node ID maps.", + "packageName": "@fluentui/react-charts", + "email": "paulmardling@microsoft.com" +} diff --git a/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx b/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx index 1d0a350519f9f7..de5848b8ede610 100644 --- a/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx +++ b/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.base.tsx @@ -39,7 +39,7 @@ type NodeValues = ItemValues; type LinkItemValues = { [key: NodeId]: ItemValues }; type LinkValues = LinkItemValues; -type NodesInColumns = { [key: number]: SNode[] }; +type NodesInColumns = ItemValues; type NormalizedData = ISankeyChartData & { width: number; height: number; @@ -56,6 +56,10 @@ type NormalizeDiagramFunction = ( type NodeColors = { fillColor: string; borderColor: string }; type SankeyLayoutGenerator = SankeyLayout, {}, {}>; +function createNodeIdMap(): ItemValues { + return Object.setPrototypeOf({}, null); +} + export interface ISankeyChartState extends IBasestate, IChartHoverCardProps { containerWidth: number; containerHeight: number; @@ -194,7 +198,7 @@ function getSelectedLinksforStreamHover(singleLink: SLink): { */ // This is exported for unit tests. export function groupNodesByColumn(graph: ISankeyChartData): NodesInColumns { - const nodesInColumn: NodesInColumns = {}; + const nodesInColumn = createNodeIdMap(); graph.nodes.forEach((node: SNode) => { const columnId = node.layer!; if (nodesInColumn[columnId]) { @@ -331,7 +335,7 @@ function duplicateData(data: ISankeyChartData): ISankeyChartData { } function valuesOfNodes(nodes: SNode[]): NodeValues { - const result: NodeValues = {}; + const result = createNodeIdMap(); nodes.forEach((node: SNode) => { result[node.nodeId as NodeId] = node.value!; }); @@ -339,12 +343,12 @@ function valuesOfNodes(nodes: SNode[]): NodeValues { } function valuesOfLinks(links: SLink[]): LinkValues { - const result: LinkValues = {}; + const result = createNodeIdMap>(); links.forEach((link: SLink) => { const sourceId = idFromNumberOrSNode(link.source); let sourceToTarget = result[sourceId]; if (!sourceToTarget) { - sourceToTarget = {}; + sourceToTarget = createNodeIdMap(); result[sourceId] = sourceToTarget; } sourceToTarget[idFromNumberOrSNode(link.target)] = link.value; @@ -495,12 +499,12 @@ function computeLinkAttributes( linkFrom: (node: SNode) => string, linkAriaLabel: (link: SLink) => string, ): LinkItemValues { - const result: LinkItemValues = {}; + const result = createNodeIdMap>(); links.forEach((link: SLink) => { const sourceId = idFromNumberOrSNode(link.source); let sourceToTarget = result[sourceId]; if (!sourceToTarget) { - sourceToTarget = {}; + sourceToTarget = createNodeIdMap(); result[sourceId] = sourceToTarget; } sourceToTarget[idFromNumberOrSNode(link.target)] = { @@ -928,7 +932,7 @@ export class SankeyChartBase extends React.Component string, ): ItemValues { - const result: ItemValues = {}; + const result = createNodeIdMap(); const weightSpan = select('.nodeName').append('text').attr('class', 'tempText').append('tspan').text(null); const nameSpan = select('.nodeName') .append('text') diff --git a/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.test.tsx b/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.test.tsx index 3e4baa0e246b35..ce379eb02859bd 100644 --- a/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.test.tsx +++ b/packages/charts/react-charting/src/components/SankeyChart/SankeyChart.test.tsx @@ -91,6 +91,17 @@ const dataWithoutColors: () => IChartProps = () => ({ }, }); +const dataWithPrototypeNodeId: () => IChartProps = () => ({ + chartTitle: 'Sankey Chart', + SankeyChartData: { + nodes: [ + { nodeId: '__proto__', name: 'Source' }, + { nodeId: 'target', name: 'Target' }, + ], + links: [{ source: 0, target: 1, value: 10 }], + }, +}); + describe('Sankey Chart snapShot testing', () => { beforeEach(sharedBeforeEach); afterEach(sharedAfterEach); @@ -143,6 +154,13 @@ describe('Sankey Chart snapShot testing', () => { expect(container).toMatchSnapshot(); }); + it('renders Sankey correctly with a prototype property node ID', () => { + const { getByText } = render(); + + expect(getByText('Source')).toBeDefined(); + expect(getByText('Target')).toBeDefined(); + }); + describe.skip('number formatting', () => { it('renders Sankey correctly by formatting large numbers', () => { // ARRANGE diff --git a/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.test.tsx b/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.test.tsx index 429819483414b5..daa5e0ad1b1af8 100644 --- a/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.test.tsx +++ b/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.test.tsx @@ -99,6 +99,19 @@ function chartPointsWithStringNodeId(): ChartProps { }; } +function chartPointsWithPrototypeNodeId(): ChartProps { + return { + chartTitle: 'Sankey Chart', + SankeyChartData: { + nodes: [ + { nodeId: '__proto__', name: 'Source' }, + { nodeId: 'target', name: 'Target' }, + ], + links: [{ source: 0, target: 1, value: 10 }], + }, + }; +} + const emptyChartPoints: ChartProps = { chartData: [], }; @@ -134,6 +147,16 @@ describe('Sankey bar chart rendering', () => { expect(container).toMatchSnapshot(); }, ); + + testWithoutWait( + 'Should render the Sankey chart with a prototype property node ID', + SankeyChart, + { data: chartPointsWithPrototypeNodeId() }, + () => { + expect(screen.getByText('Source')).toBeDefined(); + expect(screen.getByText('Target')).toBeDefined(); + }, + ); }); describe('Sankey chart - Theme', () => { diff --git a/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.tsx b/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.tsx index fa5dbba5ef5f58..69feafc5e42b74 100644 --- a/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.tsx +++ b/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.tsx @@ -28,7 +28,7 @@ type NodeValues = ItemValues; type LinkItemValues = { [key: NodeId]: ItemValues }; type LinkValues = LinkItemValues; -type NodesInColumns = { [key: number]: SNode[] }; +type NodesInColumns = ItemValues; type NormalizedData = SankeyChartData & { width: number; height: number; @@ -37,6 +37,10 @@ type NormalizedData = SankeyChartData & { type NodeColors = { fillColor: string; borderColor: string }; type SankeyLayoutGenerator = SankeyLayout, {}, {}>; +function createNodeIdMap(): ItemValues { + return Object.setPrototypeOf({}, null); +} + const NON_SELECTED_NODE_AND_STREAM_COLOR: string = '#757575'; const DEFAULT_NODE_COLORS: NodeColors[] = [ { fillColor: '#00758F', borderColor: '#002E39' }, @@ -162,7 +166,7 @@ function getSelectedLinksforStreamHover(singleLink: SLink): { */ // This is exported for unit tests. export function groupNodesByColumn(graph: SankeyChartData): NodesInColumns { - const nodesInColumn: NodesInColumns = {}; + const nodesInColumn = createNodeIdMap(); graph.nodes.forEach((node: SNode) => { const columnId = node.layer!; if (nodesInColumn[columnId]) { @@ -299,7 +303,7 @@ function duplicateData(data: SankeyChartData): SankeyChartData { } function valuesOfNodes(nodes: SNode[]): NodeValues { - const result: NodeValues = {}; + const result = createNodeIdMap(); nodes.forEach((node: SNode) => { result[node.nodeId as NodeId] = node.value!; }); @@ -307,12 +311,12 @@ function valuesOfNodes(nodes: SNode[]): NodeValues { } function valuesOfLinks(links: SLink[]): LinkValues { - const result: LinkValues = {}; + const result = createNodeIdMap>(); links.forEach((link: SLink) => { const sourceId = idFromNumberOrSNode(link.source); let sourceToTarget = result[sourceId]; if (!sourceToTarget) { - sourceToTarget = {}; + sourceToTarget = createNodeIdMap(); result[sourceId] = sourceToTarget; } sourceToTarget[idFromNumberOrSNode(link.target)] = link.value; @@ -464,12 +468,12 @@ function computeLinkAttributes( linkAriaLabel: (link: SLink) => string, linkId: string, ): LinkItemValues { - const result: LinkItemValues = {}; + const result = createNodeIdMap>(); links.forEach((link: SLink, index: number) => { const sourceId = idFromNumberOrSNode(link.source); let sourceToTarget = result[sourceId]; if (!sourceToTarget) { - sourceToTarget = {}; + sourceToTarget = createNodeIdMap(); result[sourceId] = sourceToTarget; } sourceToTarget[idFromNumberOrSNode(link.target)] = { @@ -618,7 +622,7 @@ export const SankeyChart: React.FunctionComponent = React.forw const _computeNodeAttributes = React.useCallback( (nodes: SNode[], nodeAriaLabel: (node: SNode, weight: number) => string): ItemValues => { - const result: ItemValues = {}; + const result = createNodeIdMap(); const weightSpan = select('.nodeName').append('text').attr('class', 'tempText').append('tspan').text(null); const nameSpan = select('.nodeName') .append('text') From 7b97b52c8dae7ffe81007a374aba483f0ed5efa8 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 7 Sep 2026 14:50:24 +0200 Subject: [PATCH 2/5] fix(ci): skip Yarn cache when lockfile is unchanged Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/check-packages.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-packages.yml b/.github/workflows/check-packages.yml index 2d488b5c44ed02..cd14fc7767b224 100644 --- a/.github/workflows/check-packages.yml +++ b/.github/workflows/check-packages.yml @@ -11,16 +11,22 @@ jobs: with: fetch-depth: 0 + - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 + id: changed-files-specific + with: + files: | + yarn.lock + - uses: actions/setup-node@v6 + if: steps.changed-files-specific.outputs.any_changed == 'true' with: node-version: '22' cache: 'yarn' - - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 - id: changed-files-specific + - uses: actions/setup-node@v6 + if: steps.changed-files-specific.outputs.any_changed != 'true' with: - files: | - yarn.lock + node-version: '22' - run: yarn install --immutable if: steps.changed-files-specific.outputs.any_changed == 'true' From d42e11dd0ed45acf35038d96db9499436699e7e5 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 7 Sep 2026 16:20:12 +0200 Subject: [PATCH 3/5] fix(react-charts): preserve Sankey snapshot IDs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../SankeyChart/SankeyChart.test.tsx | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.test.tsx b/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.test.tsx index daa5e0ad1b1af8..baf9a6c5538845 100644 --- a/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.test.tsx +++ b/packages/charts/react-charts/library/src/components/SankeyChart/SankeyChart.test.tsx @@ -147,16 +147,6 @@ describe('Sankey bar chart rendering', () => { expect(container).toMatchSnapshot(); }, ); - - testWithoutWait( - 'Should render the Sankey chart with a prototype property node ID', - SankeyChart, - { data: chartPointsWithPrototypeNodeId() }, - () => { - expect(screen.getByText('Source')).toBeDefined(); - expect(screen.getByText('Target')).toBeDefined(); - }, - ); }); describe('Sankey chart - Theme', () => { @@ -626,3 +616,17 @@ describe('SankeyChart - Min Height of Node Test', () => { expect(component).toMatchSnapshot(); }); }); + +describe('Sankey chart prototype property node IDs', () => { + beforeEach(sharedBeforeEach); + + testWithoutWait( + 'Should render the Sankey chart with a prototype property node ID', + SankeyChart, + { data: chartPointsWithPrototypeNodeId() }, + () => { + expect(screen.getByText('Source')).toBeDefined(); + expect(screen.getByText('Target')).toBeDefined(); + }, + ); +}); From e1263bbeac5003175fc8da73d9b3615cc203f2c9 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 7 Sep 2026 16:21:31 +0200 Subject: [PATCH 4/5] fix: prefix charting change descriptions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...tui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json | 2 +- ...entui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/change/@fluentui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json b/change/@fluentui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json index 960393be73b786..43fb63d835840b 100644 --- a/change/@fluentui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json +++ b/change/@fluentui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "Fix prototype pollution in SankeyChart node ID maps.", + "comment": "fix: prevent prototype pollution in SankeyChart node ID maps.", "packageName": "@fluentui/react-charting", "email": "paulmardling@microsoft.com" } diff --git a/change/@fluentui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json b/change/@fluentui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json index 3c5ebd09bfaf3a..e99d10d41c6d37 100644 --- a/change/@fluentui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json +++ b/change/@fluentui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json @@ -1,6 +1,6 @@ { "type": "patch", - "comment": "Fix prototype pollution in SankeyChart node ID maps.", + "comment": "fix: prevent prototype pollution in SankeyChart node ID maps.", "packageName": "@fluentui/react-charts", "email": "paulmardling@microsoft.com" } From c28cd2b320ed6ad8c0f5d75fb6c9a6df156ef545 Mon Sep 17 00:00:00 2001 From: PaulGMardling Date: Mon, 7 Sep 2026 16:22:09 +0200 Subject: [PATCH 5/5] revert: keep package check workflow unchanged Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/check-packages.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/check-packages.yml b/.github/workflows/check-packages.yml index cd14fc7767b224..2d488b5c44ed02 100644 --- a/.github/workflows/check-packages.yml +++ b/.github/workflows/check-packages.yml @@ -11,22 +11,16 @@ jobs: with: fetch-depth: 0 - - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 - id: changed-files-specific - with: - files: | - yarn.lock - - uses: actions/setup-node@v6 - if: steps.changed-files-specific.outputs.any_changed == 'true' with: node-version: '22' cache: 'yarn' - - uses: actions/setup-node@v6 - if: steps.changed-files-specific.outputs.any_changed != 'true' + - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c # v46.0.5 + id: changed-files-specific with: - node-version: '22' + files: | + yarn.lock - run: yarn install --immutable if: steps.changed-files-specific.outputs.any_changed == 'true'