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,
});
}