diff --git a/renderers/lit/src/0.8/ui/text-field.ts b/renderers/lit/src/0.8/ui/text-field.ts index 3483b450e1..8c68d54e31 100644 --- a/renderers/lit/src/0.8/ui/text-field.ts +++ b/renderers/lit/src/0.8/ui/text-field.ts @@ -20,7 +20,7 @@ import {Root} from './root.js'; import {A2uiMessageProcessor} from '@a2ui/web_core/data/model-processor'; import * as Primitives from '@a2ui/web_core/types/primitives'; import * as Types from '@a2ui/web_core/types/types'; -import {Events} from '@a2ui/web_core'; +import {Events, isSafeRegex} from '@a2ui/web_core'; import {classMap} from 'lit/directives/class-map.js'; import {styleMap} from 'lit/directives/style-map.js'; import {extractStringValue} from './utils/utils.js'; @@ -126,7 +126,9 @@ export class TextField extends Root { id="data" .value=${value} .placeholder=${'Please enter a value'} - pattern=${this.validationRegexp || nothing} + pattern=${this.validationRegexp && isSafeRegex(this.validationRegexp) + ? this.validationRegexp + : nothing} type=${this.textFieldType === 'number' ? 'number' : 'text'} /> `; diff --git a/renderers/react/src/v0_8/components/interactive/TextField.tsx b/renderers/react/src/v0_8/components/interactive/TextField.tsx index bec70d066d..3c3285b438 100644 --- a/renderers/react/src/v0_8/components/interactive/TextField.tsx +++ b/renderers/react/src/v0_8/components/interactive/TextField.tsx @@ -15,6 +15,7 @@ */ import {useState, useCallback, useEffect, useId, memo} from 'react'; +import {isSafeRegex} from '@a2ui/web_core'; import type * as Types from '@a2ui/web_core/types/types'; import type {A2UIComponentProps} from '../../types'; import {useA2UIComponent} from '../../hooks/useA2UIComponent'; @@ -63,7 +64,15 @@ export const TextField = memo(function TextField({ // Validate if pattern provided if (validationRegexp) { - setIsValid(new RegExp(validationRegexp).test(newValue)); + try { + if (isSafeRegex(validationRegexp)) { + setIsValid(new RegExp(validationRegexp).test(newValue)); + } else { + setIsValid(false); + } + } catch { + setIsValid(false); + } } // Two-way binding: update data model diff --git a/renderers/web_core/package.json b/renderers/web_core/package.json index 476958730f..37d1938f3f 100644 --- a/renderers/web_core/package.json +++ b/renderers/web_core/package.json @@ -170,6 +170,7 @@ "@preact/signals-core": "^1.14.2", "date-fns": "^4.4.0", "lit": "^3.3.3", + "redos-detector": "^6.1.4", "validate-color": "^2.2.4", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.2" diff --git a/renderers/web_core/src/v0_8/index.ts b/renderers/web_core/src/v0_8/index.ts index 33ad20e069..dd6fe108c7 100644 --- a/renderers/web_core/src/v0_8/index.ts +++ b/renderers/web_core/src/v0_8/index.ts @@ -22,6 +22,7 @@ export * from './types/colors.js'; export * from './types/primitives.js'; export * from './types/types.js'; export * from './errors.js'; +export {isSafeRegex, type SafeRegexOptions} from '../v0_9/basic_catalog/functions/safe_regex.js'; export {A2uiMessageSchema} from './schema/server-to-client.js'; export type {A2uiMessage} from './schema/server-to-client.js'; diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts index 0fe83cf3cf..a5feda9d7e 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.test.ts @@ -208,6 +208,57 @@ describe('BASIC_FUNCTIONS', () => { A2uiExpressionError, ); }); + + it('regex blocks catastrophic backtracking (ReDoS) patterns with A2uiExpressionError', () => { + const redosPatterns = [ + '(a+)+b', + '(a*)*b', + '([a-zA-Z]+)*$', + '(a|aa)+$', + '(a|a+)+$', + '(x+x+)+y', + '(\\d+)+', + '((a+)+)+', + '(a{1,}){2,}', + '(?:[0-9]+)+', + ]; + + for (const pattern of redosPatterns) { + assert.throws( + () => invoke('regex', {value: 'aaaaaaaaaaaaaaaaaaaa!', pattern}, context), + A2uiExpressionError, + `Expected pattern ${pattern} to be rejected as unsafe ReDoS`, + ); + } + }); + + it('regex allows valid complex and standard safe patterns', () => { + const safePatterns = [ + { + pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z]{2,}$', + val: 'test@example.com', + expected: true, + }, + {pattern: '^\\d{5}(-\\d{4})?$', val: '12345-6789', expected: true}, + {pattern: '^\\d{4}-\\d{2}-\\d{2}$', val: '2026-08-21', expected: true}, + {pattern: '^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$', val: '#1a2b3c', expected: true}, + { + pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + val: '123e4567-e89b-12d3-a456-426614174000', + expected: true, + }, + {pattern: '^[a-z]+(,[a-z]+)*$', val: 'apple,banana,orange', expected: true}, + {pattern: '^(\\d{1,3}\\.){3}\\d{1,3}$', val: '192.168.1.1', expected: true}, + ]; + + for (const {pattern, val, expected} of safePatterns) { + assert.strictEqual( + invoke('regex', {value: val, pattern}, context), + expected, + `Expected pattern ${pattern} to evaluate safely`, + ); + } + }); }); describe('Formatting', () => { diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts index 0a4bc0b988..f0d08c39d6 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts @@ -46,6 +46,9 @@ import { OpenUrlApi, } from './basic_functions_api.js'; import {A2uiExpressionError} from '../../errors.js'; +import {isSafeRegex} from './safe_regex.js'; + +export {isSafeRegex, type SafeRegexOptions} from './safe_regex.js'; // Arithmetic /** @@ -183,9 +186,15 @@ export const RequiredImplementation = createFunctionImplementation(RequiredApi, /** * Implementation of the regex validation function. * Checks if the value matches the regular expression pattern. - * Throws A2uiExpressionError if the pattern is invalid. + * Throws A2uiExpressionError if the pattern is invalid or unsafe (catastrophic backtracking risk). */ export const RegexImplementation = createFunctionImplementation(RegexApi, args => { + if (!isSafeRegex(args.pattern)) { + throw new A2uiExpressionError( + `Unsafe regex pattern (catastrophic backtracking risk): ${args.pattern}`, + 'regex', + ); + } try { return new RegExp(args.pattern).test(args.value); } catch (e) { diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts index 2907c2d3b6..302b15a745 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions_api.ts @@ -265,8 +265,8 @@ export const RegexApi = { name: 'regex' as const, returnType: 'boolean' as const, schema: z.object({ - 'value': z.preprocess(v => (v === undefined ? undefined : String(v)), z.string()), - 'pattern': z.preprocess(v => (v === undefined ? undefined : String(v)), z.string()), + 'value': z.preprocess(v => (v === undefined ? undefined : String(v)), z.string().max(4096)), + 'pattern': z.preprocess(v => (v === undefined ? undefined : String(v)), z.string().max(256)), }), }; diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/safe_regex.test.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/safe_regex.test.ts new file mode 100644 index 0000000000..c42ce55f53 --- /dev/null +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/safe_regex.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {describe, it} from 'node:test'; +import * as assert from 'node:assert'; +import {isSafeRegex} from './safe_regex.js'; + +describe('isSafeRegex (CWE-1333 ReDoS Safety)', () => { + describe('Catastrophic Backtracking Patterns (Unsafe)', () => { + it('blocks nested plus quantifiers', () => { + assert.strictEqual(isSafeRegex('(a+)+b'), false); + assert.strictEqual(isSafeRegex('(a+)+'), false); + assert.strictEqual(isSafeRegex('((a+)+)+'), false); + assert.strictEqual(isSafeRegex('(\\d+)+'), false); + assert.strictEqual(isSafeRegex('([a-z]+)+'), false); + }); + + it('blocks nested star quantifiers', () => { + assert.strictEqual(isSafeRegex('(a*)*b'), false); + assert.strictEqual(isSafeRegex('(a*)*'), false); + assert.strictEqual(isSafeRegex('([a-zA-Z]+)*$'), false); + assert.strictEqual(isSafeRegex('(?:[0-9]+)+'), false); + }); + + it('blocks bounded nested quantifiers that cause exponential growth', () => { + assert.strictEqual(isSafeRegex('(a{1,}){2,}'), false); + assert.strictEqual(isSafeRegex('(a+){2,}'), false); + assert.strictEqual(isSafeRegex('(a{2,})+'), false); + }); + + it('blocks overlapping alternations in quantified groups', () => { + assert.strictEqual(isSafeRegex('(a|aa)+$'), false); + assert.strictEqual(isSafeRegex('(a|a+)+$'), false); + assert.strictEqual(isSafeRegex('(\\d|\\w)+'), false); + assert.strictEqual(isSafeRegex('(a|b|ab)+$'), false); + }); + + it('blocks adjacent overlapping quantifiers inside quantified groups', () => { + assert.strictEqual(isSafeRegex('(x+x+)+y'), false); + assert.strictEqual(isSafeRegex('(\\d+\\d+)+'), false); + }); + + it('blocks wildcard dot overlap patterns', () => { + assert.strictEqual(isSafeRegex('(a|.)+'), false); + assert.strictEqual(isSafeRegex('(a|.)+$'), false); + assert.strictEqual(isSafeRegex('(.|a)+'), false); + assert.strictEqual(isSafeRegex('(.*)+'), false); + assert.strictEqual(isSafeRegex('(.+)+'), false); + }); + + it('blocks repeated non-disjoint prefixes and suffixes', () => { + assert.strictEqual(isSafeRegex('(aa+)*'), false); + assert.strictEqual(isSafeRegex('(a+a)*'), false); + assert.strictEqual(isSafeRegex('(1\\d+)*'), false); + }); + + it('blocks invalid regex syntax safely without crashing', () => { + assert.strictEqual(isSafeRegex('['), false); + assert.strictEqual(isSafeRegex('(?'), false); + assert.strictEqual(isSafeRegex('*abc'), false); + assert.strictEqual(isSafeRegex('a{2,1}'), false); // numbers out of order + assert.strictEqual(isSafeRegex('a{invalid}'), true); // safely parsed as literal without crash + }); + }); + + describe('Legitimate Form Validation Patterns (Safe)', () => { + it('allows simple and alphanumeric patterns', () => { + assert.strictEqual(isSafeRegex('^[a-z]+$'), true); + assert.strictEqual(isSafeRegex('^[0-9]+$'), true); + assert.strictEqual(isSafeRegex('^[a-zA-Z0-9_]+$'), true); + assert.strictEqual(isSafeRegex('^[a-zA-Z0-9_-]{3,16}$'), true); + assert.strictEqual(isSafeRegex('^[a-fA-F0-9]{32}$'), true); + }); + + it('allows standard email and URL patterns', () => { + assert.strictEqual(isSafeRegex('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z]{2,}$'), true); + assert.strictEqual(isSafeRegex('^https?://[^\\s/$.?#].[^\\s]*$'), true); + }); + + it('allows phone and postal code patterns', () => { + assert.strictEqual(isSafeRegex('^\\+?[1-9]\\d{1,14}$'), true); + assert.strictEqual(isSafeRegex('^\\d{5}$'), true); + assert.strictEqual(isSafeRegex('^\\d{5}(-\\d{4})?$'), true); + }); + + it('allows date and time patterns', () => { + assert.strictEqual(isSafeRegex('^\\d{4}-\\d{2}-\\d{2}$'), true); + assert.strictEqual(isSafeRegex('^\\d{2}:\\d{2}(:\\d{2})?$'), true); + }); + + it('allows hex color codes', () => { + assert.strictEqual(isSafeRegex('^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$'), true); + }); + + it('allows UUID pattern', () => { + assert.strictEqual( + isSafeRegex( + '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$', + ), + true, + ); + }); + + it('allows disjoint delimited lists', () => { + assert.strictEqual(isSafeRegex('^[a-z]+(,[a-z]+)*$'), true); + assert.strictEqual(isSafeRegex('^(\\d{1,3}\\.){3}\\d{1,3}$'), true); + assert.strictEqual(isSafeRegex('^\\d+(\\.\\d+)?$'), true); + assert.strictEqual(isSafeRegex('^\\$\\d+(\\.\\d{2})?$'), true); + }); + + it('handles empty or nullish safely', () => { + assert.strictEqual(isSafeRegex(''), true); + assert.strictEqual(isSafeRegex(null as unknown as string), true); + assert.strictEqual(isSafeRegex(undefined as unknown as string), true); + }); + + it('rejects patterns exceeding maxPatternLength', () => { + assert.strictEqual(isSafeRegex('a'.repeat(257)), false); + assert.strictEqual(isSafeRegex('a'.repeat(20), {maxPatternLength: 10}), false); + }); + }); +}); diff --git a/renderers/web_core/src/v0_9/basic_catalog/functions/safe_regex.ts b/renderers/web_core/src/v0_9/basic_catalog/functions/safe_regex.ts new file mode 100644 index 0000000000..466b4fef0a --- /dev/null +++ b/renderers/web_core/src/v0_9/basic_catalog/functions/safe_regex.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2024 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {isSafePattern} from 'redos-detector'; + +/** + * Options for regular expression safety validation. + */ +export interface SafeRegexOptions { + /** Maximum allowable length of the regex pattern string (default: 256). */ + maxPatternLength?: number; +} + +/** + * Validates whether a regular expression pattern is safe from catastrophic + * backtracking (ReDoS - CWE-1333) using `redos-detector`. + * + * Both the raw pattern and its anchored form (`^(?:pattern)$`, which is also + * used by HTML ``) are checked so that unanchored trailing + * nested quantifiers such as `(a+)+` are detected alongside `(a+)+b`. + * + * @param pattern The regular expression pattern string to validate. + * @param options Optional configuration for pattern length bounds. + * @returns `true` if the pattern is safe to execute; `false` if unsafe or invalid. + */ +export function isSafeRegex(pattern: string, options: SafeRegexOptions = {}): boolean { + if (pattern === null || pattern === undefined || typeof pattern !== 'string') { + return true; + } + if (pattern.length === 0) { + return true; + } + + const maxPatternLength = options.maxPatternLength ?? 256; + if (pattern.length > maxPatternLength) { + return false; + } + + try { + // Verify that the pattern is syntactically valid in the host JS RegExp engine. + new RegExp(pattern); + return isSafePattern(pattern).safe && isSafePattern(`^(?:${pattern})$`).safe; + } catch { + return false; + } +} diff --git a/renderers/web_core/src/v0_9/basic_catalog/index.ts b/renderers/web_core/src/v0_9/basic_catalog/index.ts index 55c058cf04..9605d2a584 100644 --- a/renderers/web_core/src/v0_9/basic_catalog/index.ts +++ b/renderers/web_core/src/v0_9/basic_catalog/index.ts @@ -17,6 +17,7 @@ export * from './expressions/expression_parser.js'; export * from './functions/basic_functions.js'; export * from './functions/basic_functions_api.js'; +export * from './functions/safe_regex.js'; export * from './components/basic_components.js'; export * from './theme.js'; export {injectBasicCatalogStyles, computeColorVariant} from './styles/default.js'; diff --git a/yarn.lock b/yarn.lock index 8ad5a484a5..1314a91be0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -490,6 +490,7 @@ __metadata: js-yaml: "npm:^4.1.0" jsdom: "npm:^29.1.1" lit: "npm:^3.3.3" + redos-detector: "npm:^6.1.4" rxjs: "npm:^7.8.2" typescript: "npm:5.9.3" validate-color: "npm:^2.2.4" @@ -16025,7 +16026,7 @@ __metadata: languageName: node linkType: hard -"jsesc@npm:^3.0.2": +"jsesc@npm:^3.0.2, jsesc@npm:~3.1.0": version: 3.1.0 resolution: "jsesc@npm:3.1.0" bin: @@ -20408,6 +20409,17 @@ __metadata: languageName: node linkType: hard +"redos-detector@npm:^6.1.4": + version: 6.1.4 + resolution: "redos-detector@npm:6.1.4" + dependencies: + regjsparser: "npm:0.13.0" + bin: + redos-detector: bin.js + checksum: 10c0/5119a875985da41a83883f533cd5117048ba2f418a0f4f00aa8825489ee3bdc391c47ead0d1936e64edc584c47a4997fe85d671037a539ee926f883a67a44c60 + languageName: node + linkType: hard + "reflect-metadata@npm:^0.2.0, reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" @@ -20481,6 +20493,17 @@ __metadata: languageName: node linkType: hard +"regjsparser@npm:0.13.0": + version: 0.13.0 + resolution: "regjsparser@npm:0.13.0" + dependencies: + jsesc: "npm:~3.1.0" + bin: + regjsparser: bin/parser + checksum: 10c0/4702f85cda09f67747c1b2fb673a0f0e5d1ba39d55f177632265a0be471ba59e3f320623f411649141f752b126b8126eac3ff4c62d317921e430b0472bfc6071 + languageName: node + linkType: hard + "rehype-harden@npm:^1.1.6": version: 1.1.8 resolution: "rehype-harden@npm:1.1.8"