Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions renderers/lit/src/0.8/ui/text-field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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'}
/>
</section>`;
Expand Down
11 changes: 10 additions & 1 deletion renderers/react/src/v0_8/components/interactive/TextField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions renderers/web_core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions renderers/web_core/src/v0_8/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
/**
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
}),
};

Expand Down
135 changes: 135 additions & 0 deletions renderers/web_core/src/v0_9/basic_catalog/functions/safe_regex.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
59 changes: 59 additions & 0 deletions renderers/web_core/src/v0_9/basic_catalog/functions/safe_regex.ts
Original file line number Diff line number Diff line change
@@ -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 `<input pattern>`) 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;
}
}
1 change: 1 addition & 0 deletions renderers/web_core/src/v0_9/basic_catalog/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading