diff --git a/.changeset/inline-bold-localization-markup.md b/.changeset/inline-bold-localization-markup.md new file mode 100644 index 00000000000..72a197e739d --- /dev/null +++ b/.changeset/inline-bold-localization-markup.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': minor +--- + +Add support for inline `` markup in localization values, rendered as `` elements. Translators can now write `'Agree to Terms'` in a single key instead of splitting into prefix/bold/suffix fragments. Token values are substituted only into parsed text leaves, so user-controlled data can never become markup. Also hardens `applyTokensToString` to use `Object.prototype.hasOwnProperty.call` when filtering token names, preventing prototype-chain names like `{{hasOwnProperty}}` from crashing rendering. diff --git a/packages/ui/src/localization/__tests__/applyMarkupToNodes.test.tsx b/packages/ui/src/localization/__tests__/applyMarkupToNodes.test.tsx new file mode 100644 index 00000000000..04b1c13238b --- /dev/null +++ b/packages/ui/src/localization/__tests__/applyMarkupToNodes.test.tsx @@ -0,0 +1,408 @@ +import type { ReactNode } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; + +import type { Tokens } from '../applyTokensToString'; +import { applyMarkupAndTokens, stripMarkup } from '../applyMarkupToNodes'; + +const html = (node: ReactNode) => renderToStaticMarkup(node as any); + +const tokens = { + applicationName: 'Acme', + 'user.firstName': 'Nikos', +} as unknown as Tokens; + +const withToken = (key: string, value: string): Tokens => ({ ...tokens, [key]: value }) as unknown as Tokens; + +// The parser's safety property: when rendered to HTML, the output contains +// no real elements other than . Anything else — script/img/iframe/svg/ +// a/style/onerror=/javascript: — can only appear as text content, which means +// it was HTML-entity-escaped by React and is inert. +const ALLOWED_TAGS = new Set(['strong']); +const TAG_NAME_RE = /<\/?([a-zA-Z][a-zA-Z0-9-]*)/g; + +const expectSafeHtml = (out: string) => { + const matches = out.matchAll(TAG_NAME_RE); + for (const m of matches) { + expect(ALLOWED_TAGS, `unexpected tag <${m[1]}> in output: ${out}`).toContain(m[1].toLowerCase()); + } +}; + +describe('applyMarkupAndTokens', () => { + describe('plain strings (no markup)', () => { + it('returns empty string for undefined', () => { + expect(applyMarkupAndTokens(undefined, tokens)).toBe(''); + }); + + it('returns empty string for empty input', () => { + expect(applyMarkupAndTokens('', tokens)).toBe(''); + }); + + it('passes plain text through token substitution', () => { + expect(applyMarkupAndTokens('Welcome to {{applicationName}}', tokens)).toBe('Welcome to Acme'); + }); + + it('preserves stray angle brackets that are not allowlisted tags', () => { + expect(html(applyMarkupAndTokens('1 < 2 and 3 > 2', tokens))).toBe('1 < 2 and 3 > 2'); + }); + + it('preserves unknown tag names as literal text', () => { + expect(html(applyMarkupAndTokens('Hello ', tokens))).toBe( + 'Hello <script>x</script>', + ); + }); + }); + + describe('bold tag rendering', () => { + it('wraps inner text in ', () => { + expect(html(applyMarkupAndTokens('Press OK', tokens))).toBe('Press OK'); + }); + + it('supports multiple bold spans in one string', () => { + expect(html(applyMarkupAndTokens('Open Settings then Profile', tokens))).toBe( + 'Open Settings then Profile', + ); + }); + + it('substitutes tokens inside bold content', () => { + expect(html(applyMarkupAndTokens('Hello {{user.firstName}}', tokens))).toBe( + 'Hello Nikos', + ); + }); + + it('substitutes tokens in text outside bold', () => { + expect(html(applyMarkupAndTokens('Welcome to {{applicationName}}, click OK', tokens))).toBe( + 'Welcome to Acme, click OK', + ); + }); + }); + + describe('security — injection resistance', () => { + it('escapes script-like token values (no XSS via token)', () => { + const evilTokens = { ...tokens, 'user.firstName': '' } as Tokens; + expect(html(applyMarkupAndTokens('Hi {{user.firstName}}', evilTokens))).toBe( + 'Hi <script>alert(1)</script>', + ); + }); + + it('does not re-parse tag-like token values into elements', () => { + const evilTokens = { ...tokens, 'user.firstName': 'pwned' } as Tokens; + expect(html(applyMarkupAndTokens('Hi {{user.firstName}}', evilTokens))).toBe('Hi <bold>pwned</bold>'); + }); + + it('does not re-parse tag-like token values when token is inside bold', () => { + const evilTokens = { ...tokens, 'user.firstName': 'pwned' } as Tokens; + expect(html(applyMarkupAndTokens('Hello {{user.firstName}}', evilTokens))).toBe( + 'Hello <bold>pwned</bold>', + ); + }); + + it('rejects tags with attributes (does not match)', () => { + expect(html(applyMarkupAndTokens('Click OK', tokens))).toBe( + 'Click <bold onclick="alert(1)">OK</bold>', + ); + }); + + it('rejects tags with whitespace', () => { + expect(html(applyMarkupAndTokens('Click OK', tokens))).toBe('Click <bold >OK</bold>'); + }); + + it('escapes javascript: in token values inside bold', () => { + const evilTokens = { ...tokens, 'user.firstName': 'javascript:alert(1)' } as Tokens; + expect(html(applyMarkupAndTokens('{{user.firstName}}', evilTokens))).toBe( + 'javascript:alert(1)', + ); + }); + }); + + describe('malformed input — safe fallback', () => { + it('falls back to plain text for unclosed bold tag', () => { + expect(html(applyMarkupAndTokens('Press OK', tokens))).toBe('Press <bold>OK'); + }); + + it('falls back to plain text for stray closing tag', () => { + expect(html(applyMarkupAndTokens('Press OK', tokens))).toBe('Press OK</bold>'); + }); + + it('falls back to plain text for mismatched nesting', () => { + // Same tag self-nested without proper closing — treated as malformed. + expect(html(applyMarkupAndTokens('ab', tokens))).toBe( + '<bold>a<bold>b</bold>', + ); + }); + + it('still applies tokens in the fallback path', () => { + expect(html(applyMarkupAndTokens('Press {{applicationName}}', tokens))).toBe('Press <bold>Acme'); + }); + }); +}); + +describe('stripMarkup', () => { + it('removes opening and closing bold tags, preserves text', () => { + expect(stripMarkup('Press OK')).toBe('Press OK'); + }); + + it('leaves strings without markup unchanged', () => { + expect(stripMarkup('No markup here')).toBe('No markup here'); + }); + + it('does not strip non-allowlisted tags', () => { + expect(stripMarkup('')).toBe(''); + }); +}); + +describe('security — adversarial inputs', () => { + describe('tag-name evasion', () => { + it('rejects fullwidth Unicode angle brackets', () => { + const out = html(applyMarkupAndTokens('<bold>OK</bold>', tokens)); + expectSafeHtml(out); + expect(out).not.toContain(''); + }); + + it('rejects zero-width space inside opening tag', () => { + const out = html(applyMarkupAndTokens('<​bold>OK', tokens)); + expectSafeHtml(out); + expect(out).not.toContain(''); + }); + + it.each(['OK', 'OK', 'OK'])('rejects case variant %s', input => { + const out = html(applyMarkupAndTokens(input, tokens)); + expectSafeHtml(out); + expect(out).not.toContain(''); + }); + + it('rejects doubled angle brackets around tag', () => { + const out = html(applyMarkupAndTokens('<>OK<>', tokens)); + expectSafeHtml(out); + }); + + it('rejects self-closing form', () => { + const out = html(applyMarkupAndTokens('Hi ', tokens)); + expectSafeHtml(out); + expect(out).not.toContain(' { + it.each([ + 'x', + 'x', + 'x', + 'x', + 'x', + 'x', + ])('rejects attributes/whitespace: %s', input => { + const out = html(applyMarkupAndTokens(input, tokens)); + expectSafeHtml(out); + expect(out).not.toContain(' { + expectSafeHtml(html(applyMarkupAndTokens('Hi" dangerouslySetInnerHTML={{__html: x}}', tokens))); + }); + + it('escapes JSX-prop-shaped token value', () => { + const evilTokens = withToken('user.firstName', '" dangerouslySetInnerHTML={{__html:""}}'); + expectSafeHtml(html(applyMarkupAndTokens('Hi {{user.firstName}}', evilTokens))); + expectSafeHtml(html(applyMarkupAndTokens('Hi {{user.firstName}}', evilTokens))); + }); + }); + + describe('disallowed tags must never appear', () => { + it.each([ + '', + '', + '', + 'x', + '', + '', + '', + '', + '', + '', + '', + '', + ])('strips/escapes %s', input => { + expectSafeHtml(html(applyMarkupAndTokens(input, tokens))); + }); + }); + + describe('token-value payloads', () => { + const payloads = [ + '', + 'pwned', + 'javascript:alert(1)', + '" autofocus onfocus="alert(1)', + '', + '', + 'x', + '_++user.firstName++_', + '$&', + '$1', + '$$', + '', + ]; + + it.each(payloads)('token outside bold: %s', payload => { + const evilTokens = withToken('user.firstName', payload); + expectSafeHtml(html(applyMarkupAndTokens('Hi {{user.firstName}}', evilTokens))); + }); + + it.each(payloads)('token inside bold: %s', payload => { + const evilTokens = withToken('user.firstName', payload); + expectSafeHtml(html(applyMarkupAndTokens('Hi {{user.firstName}}', evilTokens))); + }); + + it.each(payloads)('token as entire template: %s', payload => { + const evilTokens = withToken('user.firstName', payload); + expectSafeHtml(html(applyMarkupAndTokens('{{user.firstName}}', evilTokens))); + }); + }); + + describe('DoS shapes complete in bounded time', () => { + it('1000 unmatched opening bold tags falls back without hanging', () => { + const input = ''.repeat(1000) + 'x'; + const start = Date.now(); + const out = html(applyMarkupAndTokens(input, tokens)); + expect(Date.now() - start).toBeLessThan(500); + expectSafeHtml(out); + }); + + it('1000 alternating bold pairs render as 1000 strong elements', () => { + const input = 'x'.repeat(1000); + const start = Date.now(); + const out = html(applyMarkupAndTokens(input, tokens)); + expect(Date.now() - start).toBeLessThan(500); + expectSafeHtml(out); + const strongCount = (out.match(//g) || []).length; + expect(strongCount).toBe(1000); + }); + + it('100KB of stray < characters completes safely', () => { + const input = '<'.repeat(100_000); + const start = Date.now(); + const out = html(applyMarkupAndTokens(input, tokens)); + expect(Date.now() - start).toBeLessThan(500); + expectSafeHtml(out); + expect(out).not.toContain(' { + it('same input renders byte-for-byte identical HTML', () => { + const template = 'Welcome to {{applicationName}}, {{user.firstName}}'; + const a = html(applyMarkupAndTokens(template, tokens)); + const b = html(applyMarkupAndTokens(template, tokens)); + expect(a).toBe(b); + }); + + it('keys remain stable across renders so React hydration is consistent', () => { + const template = 'a and b and c'; + const a = html(applyMarkupAndTokens(template, tokens)); + const b = html(applyMarkupAndTokens(template, tokens)); + expect(a).toBe(b); + }); + + it('structural identity holds whether tokens are populated or empty', () => { + const template = 'Hi {{user.firstName}}'; + const populated = html(applyMarkupAndTokens(template, tokens)); + const empty = html(applyMarkupAndTokens(template, {} as Tokens)); + const strongCount = (s: string) => (s.match(/ { + it('regex lastIndex resets after a malformed-input early return', () => { + // Malformed input takes the early-return fallback path. + html(applyMarkupAndTokens('Press OK', tokens)); + // Subsequent valid input must still parse correctly. + expect(html(applyMarkupAndTokens('Hi x', tokens))).toBe('Hi x'); + }); + + it('sequential calls with mixed valid/invalid inputs all behave independently', () => { + const inputs = [ + 'Hi a', + 'Press OK', + 'plain text', + 'bc', + 'Hi {{user.firstName}}', + '{{applicationName}}', + ]; + for (const input of inputs) { + expectSafeHtml(html(applyMarkupAndTokens(input, tokens))); + } + // After mixed inputs, the regex state must still allow correct matching. + expect(html(applyMarkupAndTokens('final', tokens))).toBe('final'); + }); + }); + + describe('absence assertion baseline', () => { + const inputs = [ + '', + 'plain', + 'x', + 'Hi {{user.firstName}}', + 'Hi {{user.firstName}}', + '', + 'y', + 'Press OK', + '', + 'ab', + ]; + it.each(inputs)('no disallowed tags in output for: %s', input => { + expectSafeHtml(html(applyMarkupAndTokens(input, tokens))); + }); + }); + + describe('defense in depth — invariant pinning', () => { + it('deeply nested bolds produce only elements', () => { + const out = html(applyMarkupAndTokens('x', tokens)); + expectSafeHtml(out); + expect(out).toBe('x'); + }); + + it('HTML-entity-encoded tags do not become elements', () => { + const out = html(applyMarkupAndTokens('<bold>OK</bold>', tokens)); + expectSafeHtml(out); + expect(out).not.toContain(' { + const evil = withToken('tagName', 'script'); + const out = html(applyMarkupAndTokens('<{{tagName}}>x', evil)); + expectSafeHtml(out); + expect(out).not.toContain(' { + const out = html(applyMarkupAndTokens(`Hi {{${name}}}`, tokens)); + expectSafeHtml(out); + }, + ); + + it.each(['OK', 'OK', 'OK'])( + 'tag-name prefix %s does not match the bold allowlist', + input => { + const out = html(applyMarkupAndTokens(input, tokens)); + expectSafeHtml(out); + expect(out).not.toContain('', () => { + const out = html(applyMarkupAndTokens('', tokens)); + expectSafeHtml(out); + expect(out).toBe(''); + }); + + it('bold wrapping a token that resolves to empty still renders safely', () => { + const evil = withToken('user.firstName', ''); + const out = html(applyMarkupAndTokens('{{user.firstName}}', evil)); + expectSafeHtml(out); + expect(out).toBe(''); + }); + }); +}); diff --git a/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx b/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx index 5a897b76ff8..3c09d169248 100644 --- a/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx +++ b/packages/ui/src/localization/__tests__/makeLocalizable.test.tsx @@ -1,7 +1,9 @@ +import React from 'react'; import { describe, expect, it } from 'vitest'; import { bindCreateFixtures } from '@/test/create-fixtures'; import { render, renderHook, screen } from '@/test/utils'; +import { OptionsProvider } from '@/ui/contexts'; import { Badge, Button, @@ -138,3 +140,76 @@ describe('Test localizable components', () => { ).toBe('form_identifier_exists__email_address'); }); }); + +describe('Inline markup in localization values', () => { + it('renders as a element inside a localizable component', async () => { + const { wrapper: Wrapper } = await createFixtures(); + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + + OK to go back' } }}> + {children} + + + ); + + const { container } = render(, { wrapper }); + + const strong = container.querySelector('strong'); + expect(strong).not.toBeNull(); + expect(strong?.textContent).toBe('OK'); + expect(container.textContent).toBe('Press OK to go back'); + }); + + it('substitutes tokens inside ', async () => { + const { wrapper: Wrapper } = await createFixtures(); + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + + {{applicationName}}' } }}> + {children} + + + ); + + const { container } = render(, { wrapper }); + + const strong = container.querySelector('strong'); + expect(strong).not.toBeNull(); + expect(container.textContent).toContain('Welcome to'); + // applicationName comes from the fixture environment; we only assert the tag wraps non-empty text. + expect(strong?.textContent?.length).toBeGreaterThan(0); + }); + + it('does not render when token value contains tag-like text', async () => { + const { wrapper: Wrapper } = await createFixtures(); + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + + + {children} + + + ); + + // The fixture's applicationName is plain text; the safety property we care about is + // that token values are NEVER re-parsed as markup. We assert no appears. + const { container } = render(, { wrapper }); + expect(container.querySelector('strong')).toBeNull(); + }); + + it('t() returns a plain string with markup tags stripped', async () => { + const { wrapper: Wrapper } = await createFixtures(); + const wrapper = ({ children }: { children?: React.ReactNode }) => ( + + OK' } }}>{children} + + ); + + const { result } = renderHook(() => useLocalizations(), { wrapper }); + expect(result.current.t(localizationKeys('backButton'))).toBe('Press OK'); + }); +}); diff --git a/packages/ui/src/localization/applyMarkupToNodes.tsx b/packages/ui/src/localization/applyMarkupToNodes.tsx new file mode 100644 index 00000000000..b17ad89179d --- /dev/null +++ b/packages/ui/src/localization/applyMarkupToNodes.tsx @@ -0,0 +1,60 @@ +import type { ReactNode } from 'react'; +import { createElement, Fragment } from 'react'; + +import { applyTokensToString, type Tokens } from './applyTokensToString'; + +const TAGS = { + bold: 'strong', +} as const; + +type TagName = keyof typeof TAGS; + +const TAG_RE = /<(\/?)(bold)>/g; + +export const stripMarkup = (s: string): string => s.replace(/<\/?(bold)>/g, ''); + +export const applyMarkupAndTokens = (template: string | undefined, tokens: Tokens): ReactNode => { + if (!template) return ''; + const substitute = (s: string) => (s.includes('{{') ? applyTokensToString(s, tokens) : s); + if (!template.includes('<')) { + return substitute(template); + } + + type Frame = { tag: TagName | 'root'; children: ReactNode[] }; + const stack: Frame[] = [{ tag: 'root', children: [] }]; + let cursor = 0; + let match: RegExpExecArray | null; + + TAG_RE.lastIndex = 0; + while ((match = TAG_RE.exec(template)) !== null) { + const [full, slash, tag] = match; + const text = template.slice(cursor, match.index); + if (text) stack[stack.length - 1].children.push(substitute(text)); + cursor = match.index + full.length; + + if (!slash) { + stack.push({ tag: tag as TagName, children: [] }); + continue; + } + + const top = stack.pop(); + if (!top || top.tag !== tag) { + return substitute(template); + } + stack[stack.length - 1].children.push( + createElement(TAGS[top.tag as TagName], { key: match.index }, ...top.children), + ); + } + + if (stack.length !== 1) { + return substitute(template); + } + + const tail = template.slice(cursor); + if (tail) stack[0].children.push(substitute(tail)); + + const out = stack[0].children; + if (out.length === 0) return ''; + if (out.length === 1) return out[0]; + return createElement(Fragment, null, ...out); +}; diff --git a/packages/ui/src/localization/applyTokensToString.ts b/packages/ui/src/localization/applyTokensToString.ts index 031a79d3865..9097b7efe8c 100644 --- a/packages/ui/src/localization/applyTokensToString.ts +++ b/packages/ui/src/localization/applyTokensToString.ts @@ -53,7 +53,7 @@ const parseTokensFromLocalizedString = ( const matches = (s.match(/{{.+?}}/g) || []).map(m => m.replace(/[{}]/g, '')); const parsedMatches = matches.map(m => m.split('|').map(m => m.trim())); const expressions = parsedMatches - .filter(match => match[0] in tokens) + .filter(match => Object.prototype.hasOwnProperty.call(tokens, match[0])) .map(([token, ...modifiers]) => ({ token, modifiers: modifiers.map(m => getModifierWithParams(m)).filter(m => assertKnownModifier(m.modifierName)), diff --git a/packages/ui/src/localization/makeLocalizable.tsx b/packages/ui/src/localization/makeLocalizable.tsx index f259e490e8f..8716279492c 100644 --- a/packages/ui/src/localization/makeLocalizable.tsx +++ b/packages/ui/src/localization/makeLocalizable.tsx @@ -4,6 +4,7 @@ import React from 'react'; import { useOptions } from '../contexts'; import { readObjectPath } from '../utils/readObjectPath'; +import { applyMarkupAndTokens, stripMarkup } from './applyMarkupToNodes'; import type { GlobalTokens } from './applyTokensToString'; import { applyTokensToString, useGlobalTokens } from './applyTokensToString'; import { defaultResource } from './defaultEnglishResource'; @@ -49,7 +50,7 @@ export const makeLocalizable = (Component: React.FunctionComponent

): Loca ref={ref} data-localization-key={localizationKeyAttribute(localizationKey)} > - {localizedStringFromKey(localizationKey, parsedResource, globalTokens) || restProps.children} + {localizedNodeFromKey(localizationKey, parsedResource, globalTokens) || restProps.children} ); }); @@ -68,7 +69,7 @@ export const useLocalizations = () => { if (!localizationKey || typeof localizationKey === 'string') { return localizationKey || ''; } - return localizedStringFromKey(localizationKey, parsedResource, globalTokens); + return stripMarkup(localizedStringFromKey(localizationKey, parsedResource, globalTokens)); }; /** @@ -110,14 +111,25 @@ const localizationKeyAttribute = (localizationKey: LocalizationKey) => { return localizationKey.key; }; +const resolveKey = (localizationKey: LocalizationKey, resource: LocalizationResource, globalTokens: GlobalTokens) => ({ + base: readObjectPath(resource, localizationKey.key) as string, + tokens: { ...globalTokens, ...localizationKey.params }, +}); + const localizedStringFromKey = ( localizationKey: LocalizationKey, resource: LocalizationResource, globalTokens: GlobalTokens, ): string => { - const key = localizationKey.key; - const base = readObjectPath(resource, key) as string; - const params = localizationKey.params; - const tokens = { ...globalTokens, ...params }; + const { base, tokens } = resolveKey(localizationKey, resource, globalTokens); return applyTokensToString(base || '', tokens); }; + +const localizedNodeFromKey = ( + localizationKey: LocalizationKey, + resource: LocalizationResource, + globalTokens: GlobalTokens, +) => { + const { base, tokens } = resolveKey(localizationKey, resource, globalTokens); + return applyMarkupAndTokens(base, tokens); +};