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 0000000000000..43fb63d835840 --- /dev/null +++ b/change/@fluentui-react-charting-7ab29f8a-39ad-4df2-a902-360f3961bbf2.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "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 new file mode 100644 index 0000000000000..e99d10d41c6d3 --- /dev/null +++ b/change/@fluentui-react-charts-74a9389f-5fc5-45cf-b170-ae0c14afed64.json @@ -0,0 +1,6 @@ +{ + "type": "patch", + "comment": "fix: prevent 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 1d0a350519f9f..de5848b8ede61 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 3e4baa0e246b3..ce379eb02859b 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 429819483414b..baf9a6c553884 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: [], }; @@ -603,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(); + }, + ); +}); 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 fa5dbba5ef5f5..69feafc5e42b7 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')