From 72fb4d93582bb3cac222c4a21819822a47b04ca3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 13:34:21 +0100 Subject: [PATCH] fix(frontend): keep node-reported text out of i18next's nesting parser The SPA initialised i18next with nesting on, though no locale uses $t(...), so its nesting regex, exponential on an unclosed "$t(", ran over every missing key and interpolated value, including the status and name a node reports for its sub-nodes. One crafted status froze the Nodes page. Nesting is switched off for every i18next instance the project creates, an unknown status renders as Unknown instead of being used as a key, and a sub-node is never named as the parent it is reached through. --- frontend/.storybook/preview.tsx | 3 +- frontend/src/i18n/react.ts | 13 +++- frontend/src/pages/nodes/NodeList.tsx | 15 +++- .../test/node-list-untrusted-i18n.test.tsx | 77 +++++++++++++++++++ frontend/src/test/setup.components.ts | 3 +- 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 frontend/src/test/node-list-untrusted-i18n.test.tsx diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 93b3846a6..3ee3f563a 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -5,6 +5,7 @@ import i18next from 'i18next'; import { initReactI18next } from 'react-i18next'; import { buildAntdThemeConfig } from '@/hooks/useTheme'; +import { INTERPOLATION } from '@/i18n/react'; import enUS from '../../internal/web/translation/en-US.json'; if (!i18next.isInitialized) { @@ -12,7 +13,7 @@ if (!i18next.isInitialized) { lng: 'en-US', fallbackLng: 'en-US', resources: { 'en-US': { translation: enUS } }, - interpolation: { escapeValue: false, prefix: '{', suffix: '}' }, + interpolation: INTERPOLATION, returnNull: false, }); } diff --git a/frontend/src/i18n/react.ts b/frontend/src/i18n/react.ts index 4a6f7a7e7..7ca9d78d0 100644 --- a/frontend/src/i18n/react.ts +++ b/frontend/src/i18n/react.ts @@ -1,4 +1,5 @@ import i18next from 'i18next'; +import type { InterpolationOptions } from 'i18next'; import { initReactI18next } from 'react-i18next'; import { LanguageManager } from '@/utils'; @@ -8,6 +9,16 @@ import enUS from '../../../internal/web/translation/en-US.json'; const FALLBACK = 'en-US'; +// No locale uses $t(...) nesting, and its regex backtracks exponentially on an unclosed +// "$t(" in untrusted text (node-reported names, remarks); no off switch, so never match. +export const INTERPOLATION: InterpolationOptions = { + escapeValue: false, + prefix: '{', + suffix: '}', + nestingPrefix: '', + nestingPrefixEscaped: '(?!)', +}; + const lazyModules = import.meta.glob([ '../../../internal/web/translation/*.json', '!../../../internal/web/translation/en-US.json', @@ -30,7 +41,7 @@ export async function readyI18n(scope: LanguageScope = 'panel') { lng: active, fallbackLng: FALLBACK, resources: { [FALLBACK]: { translation: enUS } }, - interpolation: { escapeValue: false, prefix: '{', suffix: '}' }, + interpolation: INTERPOLATION, returnNull: false, }); if (active !== FALLBACK) { diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx index b8cf089a3..467dc704c 100644 --- a/frontend/src/pages/nodes/NodeList.tsx +++ b/frontend/src/pages/nodes/NodeList.tsx @@ -127,7 +127,14 @@ function StatusLabel({ status, xrayState }: HealthProps) { ); } - return {t(`pages.nodes.statusValues.${status || 'unknown'}`)}; + // A sub-node's status is free text from its parent node: show it as unknown, not as a key. + return ( + + {t(`pages.nodes.statusValues.${status || 'unknown'}`, { + defaultValue: t('pages.nodes.statusValues.unknown'), + })} + + ); } function formatPct(p?: number, available = true): string { @@ -188,11 +195,11 @@ export default function NodeList({ const [statsNode, setStatsNode] = useState(null); const [expandedIds, setExpandedIds] = useState>(new Set()); - // Map a node GUID to its display name so a transitive sub-node can show which - // parent it is reached through (#4983). + // Map a direct node's GUID to its display name so a transitive sub-node can show + // which parent it is reached through (#4983); the master walks one level only. const nameByGuid = useMemo(() => { const m = new Map(); - for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid); + for (const n of nodes) if (n.guid && !n.transitive) m.set(n.guid, n.name || n.guid); return m; }, [nodes]); diff --git a/frontend/src/test/node-list-untrusted-i18n.test.tsx b/frontend/src/test/node-list-untrusted-i18n.test.tsx new file mode 100644 index 000000000..ff98835e1 --- /dev/null +++ b/frontend/src/test/node-list-untrusted-i18n.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; +import i18next from 'i18next'; + +import NodeList from '@/pages/nodes/NodeList'; +import type { NodeRecord } from '@/schemas/node'; + +import { renderWithProviders } from './test-utils'; + +const noop = () => {}; + +// A closed reference: an unclosed "$t(" would hang a regressed run instead of failing it. +const reported = '$t(pages.nodes.subNode)'; + +function renderSubNode(fields: Partial) { + const nodes: NodeRecord[] = [ + { id: 1, name: 'parent', guid: 'p1', enable: true, status: 'online' }, + // parentGuid is its own GUID: the sub-node tries to put its name into the parent tooltip. + { id: 0, name: 'child', guid: 'c1', parentGuid: 'c1', transitive: true, ...fields }, + ]; + renderWithProviders( + , + ); +} + +describe('NodeList sub-node rows', () => { + afterEach(() => vi.restoreAllMocks()); + + it('shows a node-reported status it does not know as Unknown, not as an i18next key', () => { + renderSubNode({ status: reported }); + + const row = screen.getByText('child').closest('tr') as HTMLElement; + expect(row.textContent).toContain('Unknown'); + expect(row.textContent).not.toContain('statusValues'); + }); + + it('never names a sub-node as the parent it is reached through', () => { + const translate = vi.spyOn(i18next, 't'); + renderSubNode({ name: reported, status: 'online' }); + + expect(translate).toHaveBeenCalledWith( + 'pages.nodes.subNodeTip', + expect.objectContaining({ parent: '-' }), + ); + expect(JSON.stringify(translate.mock.calls)).not.toContain(reported); + }); +}); + +describe('i18next nesting', () => { + afterEach(() => vi.restoreAllMocks()); + + // Closed references only: an unclosed "$t(" would hang a regressed run instead of failing it. + it('is off, so "$t(...)" in a missing key or an interpolated value stays plain text', async () => { + const utils = await import('@/utils'); + vi.spyOn(utils.LanguageManager, 'getLanguage').mockReturnValue('en-US'); + const { readyI18n } = await import('@/i18n/react'); + const i18n = await readyI18n(); + + expect(i18n.t('$t(pages.nodes.subNode)')).toBe('$t(pages.nodes.subNode)'); + expect(i18n.t('pages.nodes.subNodeTip', { parent: '$t(pages.nodes.subNode)' })).toBe( + "Read-only: a downstream node reached through $t(pages.nodes.subNode). Manage it from $t(pages.nodes.subNode)'s own panel.", + ); + }); +}); diff --git a/frontend/src/test/setup.components.ts b/frontend/src/test/setup.components.ts index 931901984..acb7cdf15 100644 --- a/frontend/src/test/setup.components.ts +++ b/frontend/src/test/setup.components.ts @@ -3,6 +3,7 @@ import { cleanup } from '@testing-library/react'; import i18next from 'i18next'; import { initReactI18next } from 'react-i18next'; +import { INTERPOLATION } from '@/i18n/react'; import enUS from '../../../internal/web/translation/en-US.json'; // RTL sets this from a global beforeAll, which never runs with `globals: false`. @@ -75,7 +76,7 @@ if (!i18next.isInitialized) { lng: 'en-US', fallbackLng: 'en-US', resources: { 'en-US': { translation: enUS } }, - interpolation: { escapeValue: false, prefix: '{', suffix: '}' }, + interpolation: INTERPOLATION, returnNull: false, }); }