From b6ce9501a2045cbbe436ce6b8222303dd32dd080 Mon Sep 17 00:00:00 2001 From: Olek Date: Fri, 10 Jul 2026 15:16:06 +0200 Subject: [PATCH 1/3] test: Add security-focused tests and sanitization logic --- libs/ui/src/ai/a2ui/A2UI_PROTOCOL.md | 54 +++++ libs/ui/src/ai/a2ui/index.ts | 12 + libs/ui/src/ai/a2ui/security.test.ts | 229 ++++++++++++++++++ libs/ui/src/ai/a2ui/security.ts | 223 +++++++++++++++++ libs/ui/src/ai/a2ui/system-prompt.test.ts | 14 ++ libs/ui/src/ai/a2ui/system-prompt.ts | 12 + libs/ui/src/utils/a2ui-security.test.tsx | 157 ++++++++++++ libs/ui/src/utils/a2ui/constants.ts | 1 + libs/ui/src/utils/a2ui/helpers/options.tsx | 3 +- libs/ui/src/utils/a2ui/render.tsx | 29 ++- libs/ui/src/utils/a2ui/renderers/avatar.tsx | 10 +- libs/ui/src/utils/a2ui/renderers/card.tsx | 8 +- libs/ui/src/utils/a2ui/renderers/chat.tsx | 3 +- .../utils/a2ui/renderers/content-carousel.tsx | 3 +- libs/ui/src/utils/a2ui/renderers/header.tsx | 3 +- libs/ui/src/utils/a2ui/renderers/image.tsx | 6 +- libs/ui/src/utils/a2ui/renderers/link.tsx | 8 +- libs/ui/src/utils/a2ui/renderers/sidebar.tsx | 3 +- libs/ui/src/utils/a2ui/renderers/skeleton.tsx | 3 +- libs/ui/src/utils/a2ui/types.ts | 18 +- 20 files changed, 781 insertions(+), 18 deletions(-) create mode 100644 libs/ui/src/ai/a2ui/security.test.ts create mode 100644 libs/ui/src/ai/a2ui/security.ts create mode 100644 libs/ui/src/utils/a2ui-security.test.tsx diff --git a/libs/ui/src/ai/a2ui/A2UI_PROTOCOL.md b/libs/ui/src/ai/a2ui/A2UI_PROTOCOL.md index 871fde3..b3fac62 100644 --- a/libs/ui/src/ai/a2ui/A2UI_PROTOCOL.md +++ b/libs/ui/src/ai/a2ui/A2UI_PROTOCOL.md @@ -347,6 +347,60 @@ A2UI responses are validated with AJV against [`./ui-specification-schema.json`] --- +## Security + +[`./security.ts`](./security.ts) is the canonical, dependency-free hardening module for the A2UI pipeline. It is exported from the `gd-design-library/ai` subpath so it can be imported by both: + +- **Spec ingest** (e.g. Cerebra or any other external consumer that accepts an A2UI spec before it reaches a browser) — call these functions to reject or repair a spec before trusting it. +- **Spec render** (`renderA2UISpec`, `gd-design-library/renderer`) — the renderer calls `checkA2UISpecLimits` itself before walking the tree, and every built-in renderer that reads a navigation/media URL calls `isSafeA2UIUrl` before using it. + +Ingest-side consumers should call the same three functions the renderer uses, so a spec that would be rejected at render time is also rejected (or sanitized) before it is ever accepted: + +```typescript +import { checkA2UISpecLimits, isSafeA2UIUrl, sanitizeA2UIAttributes } from 'gd-design-library/ai'; +``` + +### Resource limits — `checkA2UISpecLimits(spec, limits?)` + +Bounds the component tree before it is rendered, to prevent denial-of-service via deeply nested trees, wide trees, or oversized payloads. + +| Limit | Default | Counts | +| ----------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `maxTreeDepth` | `24` | Deepest nesting level across `children` and every other component-array slot (`footer`, `actionChildren`, `logoChildren`, `menuChildren`, `bannerChildren`, `advChildren`, `headerChildren`, `footerChildren`, `headerContent`, `sidebarContent`, `sidebarMinifiedContent`, `sidebarHeaderContent`, `dragOverContent`, `loadingOverlay`, `dragOverChildren`). Root components are depth `1`. | +| `maxNodeCount` | `1000` | Total number of component nodes across the same set of array slots. | +| `maxPayloadBytes` | `300 * 1024` (300 KB) | `JSON.stringify(spec).length` — UTF-16 code units, used as a conservative proxy for byte length. | + +Data-only arrays — `options`, `columns`, `rows`, `data`, `items`, `images`, `files`, `errors` — are **not** counted toward tree depth or node count (they hold plain data, not component nodes), only toward payload size. This means a large data table (many rows/columns) does not trip the depth/node-count limits, only the payload-size limit if the data itself is excessive. + +Every limit is overridable: `renderA2UISpec(spec, actions, customComponents, { limits: { maxNodeCount: 2000 } })`. When a spec fails this check, `renderA2UISpec` renders a generic `InlineNotification` (never the attacker-influenced spec content) instead of throwing, and calls `securityOptions.onSecurityViolation(violations)` if provided. + +### URL scheme validation — `isSafeA2UIUrl(url, allowedSchemes?)` + +Every built-in renderer that reads a navigation or media URL (`link.href`, `image`/`avatar`/`card-image` `src`, `chat-image-gallery` image `src`, `content-carousel` item `src`, `sidebar`/`header` nav item `href`/`path`, `breadcrumbs`/option `href`) calls `isSafeA2UIUrl` before using the value. An unsafe URL resolves to `undefined`, so the affected component falls back to its normal "missing value" behavior (e.g. `Image`'s `fallbackComponent`, an unlinked breadcrumb label) instead of navigating or loading the unsafe URL. + +Resolution order: + +1. Non-string input is never safe. +2. `javascript:`, `vbscript:`, `data:`, and `file:` (`A2UI_ALWAYS_BLOCKED_URL_SCHEMES`) are always unsafe, regardless of any allowlist. `data:` is blocked here — not just left out of the default allowlist — because it is also an unbounded-size payload vector, not only a script-injection vector. +3. A scheme-less value (relative path, `#fragment`, `?query`, or a protocol-relative `//host/path`) is safe. +4. Otherwise, the URL is safe only if its scheme is in `allowedSchemes` (default: `A2UI_DEFAULT_ALLOWED_URL_SCHEMES` — `http:`, `https:`, `mailto:`, `tel:`). + +At render time, built-in renderers always use the default allowlist as a fixed, defense-in-depth floor — it is not configurable through `renderA2UISpec`. A host or ingest-side consumer that needs a different policy (a stricter or looser scheme allowlist, or a host-level allowlist) should call `isSafeA2UIUrl(url, customSchemes)` directly against the spec's URLs before rendering or accepting it. Host-level allowlisting for navigation links (beyond scheme checking) is not implemented — see Open Questions in the implementation plan for this ticket. + +### Attribute sanitization — `sanitizeA2UIAttributes(attributes?)` + +Strips dangerous keys/values from a spec component's free-form `attributes` object before it is spread onto a DOM-forwarding component (currently `skeleton`, the only renderer that spreads `attributes` directly): + +- `dangerouslySetInnerHTML` — removed (this is the "no untrusted HTML rendering paths" enforcement point). +- `children` — removed (would otherwise silently override the renderer's own children). +- Any key matching `/^on[A-Z]/` (e.g. `onClick`, `onError`) — removed. +- Any value with `typeof value === 'function'` — removed. +- Everything else — including `data-*`, `aria-*`, `className`, `style` — passes through unchanged. + +There is no `dangerouslySetInnerHTML`, `innerHTML`, `eval(`, or `new Function(` anywhere else in the renderer (`libs/ui/src/utils/a2ui/`); component text (`label`/`value`) is always rendered as a React text child, which React escapes by default. + +--- + ## `buildA2UISystemPrompt` Options ```typescript diff --git a/libs/ui/src/ai/a2ui/index.ts b/libs/ui/src/ai/a2ui/index.ts index ae5affa..385ced3 100644 --- a/libs/ui/src/ai/a2ui/index.ts +++ b/libs/ui/src/ai/a2ui/index.ts @@ -58,3 +58,15 @@ export { } from './system-prompt'; export { type A2UIImageSources, normalizeImageSources } from './image-policy'; + +export { + // Security + A2UI_SECURITY_LIMITS, + A2UI_ALWAYS_BLOCKED_URL_SCHEMES, + A2UI_DEFAULT_ALLOWED_URL_SCHEMES, + checkA2UISpecLimits, + isSafeA2UIUrl, + sanitizeA2UIAttributes, + type A2UISecurityLimits, + type A2UISpecLimitCheck, +} from './security'; diff --git a/libs/ui/src/ai/a2ui/security.test.ts b/libs/ui/src/ai/a2ui/security.test.ts new file mode 100644 index 0000000..78bae1b --- /dev/null +++ b/libs/ui/src/ai/a2ui/security.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + A2UI_SECURITY_LIMITS, + A2UI_ALWAYS_BLOCKED_URL_SCHEMES, + A2UI_DEFAULT_ALLOWED_URL_SCHEMES, + checkA2UISpecLimits, + isSafeA2UIUrl, + sanitizeA2UIAttributes, +} from './security'; + +function buildNestedChildrenSpec(depth: number) { + let leaf: Record = { id: `node_${depth}`, type: 'box' }; + + for (let level = depth - 1; level >= 1; level--) { + leaf = { id: `node_${level}`, type: 'box', children: [leaf] }; + } + + return { ui: { components: [leaf] } }; +} + +function buildWideSiblingSpec(count: number) { + const components = Array.from({ length: count }, (_, index) => ({ id: `sibling_${index}`, type: 'box' })); + return { ui: { components } }; +} + +describe('checkA2UISpecLimits', () => { + it('SHOULD pass a spec at or under the default tree-depth limit', () => { + const result = checkA2UISpecLimits(buildNestedChildrenSpec(A2UI_SECURITY_LIMITS.maxTreeDepth)); + + expect(result.valid).toBe(true); + expect(result.stats.maxDepth).toBe(A2UI_SECURITY_LIMITS.maxTreeDepth); + }); + + it('SHOULD fail a spec over the default tree-depth limit', () => { + const result = checkA2UISpecLimits(buildNestedChildrenSpec(A2UI_SECURITY_LIMITS.maxTreeDepth + 1)); + + expect(result.valid).toBe(false); + expect(result.violations.some((v) => v.includes('depth'))).toBe(true); + }); + + it('SHOULD count depth across every component-array slot, not just children', () => { + const spec = { + ui: { + components: [ + { + id: 'sidebar_root', + type: 'sidebar', + headerChildren: [ + { + id: 'sidebar_header_child', + type: 'box', + dragOverContent: [{ id: 'deep_leaf', type: 'box' }], + }, + ], + }, + ], + }, + }; + + const result = checkA2UISpecLimits(spec); + + expect(result.stats.maxDepth).toBe(3); + expect(result.stats.nodeCount).toBe(3); + }); + + it('SHOULD pass a spec at or under the default node-count limit', () => { + const result = checkA2UISpecLimits(buildWideSiblingSpec(A2UI_SECURITY_LIMITS.maxNodeCount)); + + expect(result.valid).toBe(true); + expect(result.stats.nodeCount).toBe(A2UI_SECURITY_LIMITS.maxNodeCount); + }); + + it('SHOULD fail a spec over the default node-count limit', () => { + const result = checkA2UISpecLimits(buildWideSiblingSpec(A2UI_SECURITY_LIMITS.maxNodeCount + 1)); + + expect(result.valid).toBe(false); + expect(result.violations.some((v) => v.includes('nodes'))).toBe(true); + }); + + it('SHOULD fail a spec over the default payload-size limit', () => { + const spec = { + ui: { + components: [{ id: 'huge_value', type: 'typography', value: 'x'.repeat(A2UI_SECURITY_LIMITS.maxPayloadBytes) }], + }, + }; + + const result = checkA2UISpecLimits(spec); + + expect(result.valid).toBe(false); + expect(result.violations.some((v) => v.includes('payload'))).toBe(true); + }); + + it('SHOULD NOT count data-only arrays (rows/columns/options/items/images) toward depth or node count', () => { + const spec = { + ui: { + components: [ + { + id: 'big_table', + type: 'table', + columns: Array.from({ length: 12 }, (_, i) => ({ key: `col_${i}`, label: `Column ${i}` })), + rows: Array.from({ length: 200 }, (_, i) => ({ col_0: `row ${i}` })), + }, + ], + }, + }; + + const result = checkA2UISpecLimits(spec); + + expect(result.valid).toBe(true); + expect(result.stats.nodeCount).toBe(1); + expect(result.stats.maxDepth).toBe(1); + }); + + it('SHOULD respect overridden limits', () => { + const spec = buildWideSiblingSpec(5); + + expect(checkA2UISpecLimits(spec, { maxNodeCount: 4 }).valid).toBe(false); + expect(checkA2UISpecLimits(spec, { maxNodeCount: 5 }).valid).toBe(true); + }); + + it('SHOULD treat a missing/empty spec as valid with zero stats', () => { + expect(checkA2UISpecLimits(undefined)).toMatchObject({ + valid: true, + stats: { nodeCount: 0, maxDepth: 0 }, + }); + expect(checkA2UISpecLimits({ ui: { components: [] } }).valid).toBe(true); + }); +}); + +describe('isSafeA2UIUrl', () => { + it.each(A2UI_ALWAYS_BLOCKED_URL_SCHEMES)('SHOULD always reject the %s scheme regardless of allowlist', (scheme) => { + const url = `${scheme}alert(1)`; + + expect(isSafeA2UIUrl(url)).toBe(false); + expect(isSafeA2UIUrl(url, [...A2UI_DEFAULT_ALLOWED_URL_SCHEMES, scheme])).toBe(false); + }); + + it.each(A2UI_DEFAULT_ALLOWED_URL_SCHEMES)('SHOULD accept the %s scheme by default', (scheme) => { + expect(isSafeA2UIUrl(`${scheme}//example.com/resource`)).toBe(true); + }); + + it('SHOULD accept relative paths, hash fragments, and query strings', () => { + expect(isSafeA2UIUrl('/products/123')).toBe(true); + expect(isSafeA2UIUrl('products/123')).toBe(true); + expect(isSafeA2UIUrl('#section-2')).toBe(true); + expect(isSafeA2UIUrl('?tab=details')).toBe(true); + }); + + it('SHOULD accept protocol-relative URLs', () => { + expect(isSafeA2UIUrl('//cdn.example.com/image.png')).toBe(true); + }); + + it('SHOULD reject a scheme not present in a custom allowlist', () => { + expect(isSafeA2UIUrl('https://example.com', ['mailto:'])).toBe(false); + }); + + it('SHOULD accept a scheme added via a custom allowlist', () => { + expect(isSafeA2UIUrl('ftp://example.com/file', ['ftp:'])).toBe(true); + }); + + it('SHOULD reject non-string input without throwing', () => { + expect(isSafeA2UIUrl(undefined)).toBe(false); + expect(isSafeA2UIUrl(null)).toBe(false); + expect(isSafeA2UIUrl(42)).toBe(false); + expect(isSafeA2UIUrl({})).toBe(false); + }); + + it('SHOULD reject an empty or whitespace-only string', () => { + expect(isSafeA2UIUrl('')).toBe(false); + expect(isSafeA2UIUrl(' ')).toBe(false); + }); + + it('SHOULD be case-insensitive when matching blocked schemes', () => { + expect(isSafeA2UIUrl('JavaScript:alert(1)')).toBe(false); + expect(isSafeA2UIUrl(' JAVASCRIPT:alert(1)')).toBe(false); + }); +}); + +describe('sanitizeA2UIAttributes', () => { + it('SHOULD strip dangerouslySetInnerHTML', () => { + const result = sanitizeA2UIAttributes({ dangerouslySetInnerHTML: { __html: '' } }); + + expect(result).not.toHaveProperty('dangerouslySetInnerHTML'); + }); + + it('SHOULD strip a children override', () => { + const result = sanitizeA2UIAttributes({ children: 'override' }); + + expect(result).not.toHaveProperty('children'); + }); + + it('SHOULD strip event-handler-shaped keys', () => { + const result = sanitizeA2UIAttributes({ onClick: 'not-a-real-handler', onError: 'x', onLoad: 'y' }); + + expect(result).toEqual({}); + }); + + it('SHOULD strip function-typed values regardless of key name', () => { + const result = sanitizeA2UIAttributes({ handler: vi.fn(), label: 'kept' }); + + expect(result).toEqual({ label: 'kept' }); + }); + + it('SHOULD preserve data-*, aria-*, and other plain attribute values', () => { + const result = sanitizeA2UIAttributes({ + 'data-testid': 'widget', + 'aria-label': 'Widget', + className: 'custom-class', + style: { color: 'red' }, + min: 0, + max: 100, + }); + + expect(result).toEqual({ + 'data-testid': 'widget', + 'aria-label': 'Widget', + className: 'custom-class', + style: { color: 'red' }, + min: 0, + max: 100, + }); + }); + + it('SHOULD return undefined for undefined or non-object input', () => { + expect(sanitizeA2UIAttributes(undefined)).toBeUndefined(); + expect(sanitizeA2UIAttributes(null as never)).toBeUndefined(); + expect(sanitizeA2UIAttributes([] as never)).toBeUndefined(); + }); +}); diff --git a/libs/ui/src/ai/a2ui/security.ts b/libs/ui/src/ai/a2ui/security.ts new file mode 100644 index 0000000..4d72829 --- /dev/null +++ b/libs/ui/src/ai/a2ui/security.ts @@ -0,0 +1,223 @@ +/** + * A2UI Security Module + * + * Shared, dependency-free hardening primitives for the A2UI pipeline: resource limits + * (tree depth / node count / payload size), URL scheme validation, and attribute + * sanitization. These functions are pure and side-effect-free so they can run both at + * spec-ingest time (an external consumer such as Cerebra, importing `gd-design-library/ai`) + * and at spec-render time (`renderA2UISpec`, `gd-design-library/renderer`) without pulling + * in React or any other runtime dependency. + * + * A2UI protocol: https://a2ui.org + */ + +import type { A2UISpec } from './spec-schema'; + +export type A2UISecurityLimits = { + /** Maximum nesting depth of the component tree (root components are depth 1). */ + maxTreeDepth: number; + /** Maximum total number of component nodes across the whole tree. */ + maxNodeCount: number; + /** Maximum serialized spec size, measured in UTF-16 code units (a conservative proxy for byte length). */ + maxPayloadBytes: number; +}; + +export const A2UI_SECURITY_LIMITS: A2UISecurityLimits = { + maxTreeDepth: 24, + maxNodeCount: 1000, + maxPayloadBytes: 300 * 1024, +}; + +/** + * Component fields that hold nested `A2UIComponent[]` trees. Kept in sync by hand with + * `A2UIComponent` in `spec-schema.ts` — data-only arrays (`options`, `columns`, `rows`, + * `data`, `items`, `images`, `files`, `errors`) are intentionally excluded: they hold plain + * data, not renderable component nodes, so they count toward payload size only. + */ +const COMPONENT_ARRAY_FIELDS = [ + 'children', + 'footer', + 'actionChildren', + 'logoChildren', + 'menuChildren', + 'bannerChildren', + 'advChildren', + 'headerChildren', + 'footerChildren', + 'headerContent', + 'sidebarContent', + 'sidebarMinifiedContent', + 'sidebarHeaderContent', + 'dragOverContent', + 'loadingOverlay', + 'dragOverChildren', +] as const; + +export type A2UISpecLimitCheck = { + valid: boolean; + violations: string[]; + stats: { + nodeCount: number; + maxDepth: number; + payloadBytes: number; + }; +}; + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function walkComponentTree( + components: unknown, + depth: number, + accumulator: { nodeCount: number; maxDepth: number } +): void { + if (!Array.isArray(components)) { + return; + } + + for (const component of components) { + if (!isRecord(component)) { + continue; + } + + accumulator.nodeCount += 1; + accumulator.maxDepth = Math.max(accumulator.maxDepth, depth); + + for (const field of COMPONENT_ARRAY_FIELDS) { + walkComponentTree(component[field], depth + 1, accumulator); + } + } +} + +/** + * Validates a spec against resource limits (tree depth, node count, payload size) before + * it is rendered or otherwise trusted. Accepts any object shaped like an A2UI spec (or a + * partial `{ ui: { components } }` shape), since ingest-side consumers may validate a + * spec before it is fully normalized. + */ +export function checkA2UISpecLimits( + spec: Pick | A2UISpec | null | undefined, + limits?: Partial +): A2UISpecLimitCheck { + const mergedLimits: A2UISecurityLimits = { ...A2UI_SECURITY_LIMITS, ...limits }; + const violations: string[] = []; + const accumulator = { nodeCount: 0, maxDepth: 0 }; + + walkComponentTree(spec?.ui?.components, 1, accumulator); + + const payloadBytes = spec ? JSON.stringify(spec).length : 0; + + if (accumulator.nodeCount > mergedLimits.maxNodeCount) { + violations.push( + `Component tree has ${accumulator.nodeCount} nodes, exceeding the maximum of ${mergedLimits.maxNodeCount}.` + ); + } + + if (accumulator.maxDepth > mergedLimits.maxTreeDepth) { + violations.push( + `Component tree depth is ${accumulator.maxDepth}, exceeding the maximum of ${mergedLimits.maxTreeDepth}.` + ); + } + + if (payloadBytes > mergedLimits.maxPayloadBytes) { + violations.push(`Spec payload is ${payloadBytes} bytes, exceeding the maximum of ${mergedLimits.maxPayloadBytes}.`); + } + + return { + valid: violations.length === 0, + violations, + stats: { nodeCount: accumulator.nodeCount, maxDepth: accumulator.maxDepth, payloadBytes }, + }; +} + +/** + * URL schemes that are never safe to navigate to or load, regardless of any configured + * allowlist. `data:` is included here (not just left out of the default allowlist) because + * it is also an unbounded-size payload vector, not only a script-injection vector. + */ +export const A2UI_ALWAYS_BLOCKED_URL_SCHEMES = ['javascript:', 'vbscript:', 'data:', 'file:'] as const; + +export const A2UI_DEFAULT_ALLOWED_URL_SCHEMES = ['http:', 'https:', 'mailto:', 'tel:'] as const; + +function extractUrlScheme(url: string): string | null { + const match = /^([a-z][a-z0-9+.-]*):/i.exec(url); + return match ? `${match[1]!.toLowerCase()}:` : null; +} + +/** + * Determines whether a spec-provided URL (`href`, `src`, `path`, etc.) is safe to use. + * + * Resolution order: + * 1. Non-string input is never safe. + * 2. Protocol-relative URLs (`//host/path`) inherit the current page scheme (always + * http/https in a browser), so they are treated as safe. + * 3. A scheme-less value (relative path, `#fragment`, `?query`) is safe. + * 4. Any scheme in `A2UI_ALWAYS_BLOCKED_URL_SCHEMES` is unsafe, regardless of `allowedSchemes`. + * 5. Otherwise, the URL is safe only if its scheme appears in `allowedSchemes`. + */ +export function isSafeA2UIUrl( + url: unknown, + allowedSchemes: readonly string[] = A2UI_DEFAULT_ALLOWED_URL_SCHEMES +): boolean { + if (typeof url !== 'string') { + return false; + } + + const trimmed = url.trim(); + + if (trimmed.length === 0) { + return false; + } + + if (trimmed.startsWith('//')) { + return true; + } + + const scheme = extractUrlScheme(trimmed); + + if (scheme === null) { + return true; + } + + if ((A2UI_ALWAYS_BLOCKED_URL_SCHEMES as readonly string[]).includes(scheme)) { + return false; + } + + return allowedSchemes.includes(scheme); +} + +const EVENT_HANDLER_ATTRIBUTE_PATTERN = /^on[A-Z]/; + +/** + * Strips dangerous keys/values from a spec's free-form `attributes` object before it is + * spread onto a React element: `dangerouslySetInnerHTML` (raw HTML injection), a `children` + * override, event-handler-shaped keys (`onClick`, `onError`, ...), and any function-typed + * value. Everything else — including `data-*`, `aria-*`, `className`, `style` — passes + * through unchanged, so legitimate DOM passthrough attributes keep working. + */ +export function sanitizeA2UIAttributes(attributes?: Record): Record | undefined { + if (!isRecord(attributes)) { + return undefined; + } + + const sanitized: Record = {}; + + for (const [key, value] of Object.entries(attributes)) { + if (key === 'dangerouslySetInnerHTML' || key === 'children') { + continue; + } + + if (EVENT_HANDLER_ATTRIBUTE_PATTERN.test(key)) { + continue; + } + + if (typeof value === 'function') { + continue; + } + + sanitized[key] = value; + } + + return sanitized; +} diff --git a/libs/ui/src/ai/a2ui/system-prompt.test.ts b/libs/ui/src/ai/a2ui/system-prompt.test.ts index 1169873..cdc1261 100644 --- a/libs/ui/src/ai/a2ui/system-prompt.test.ts +++ b/libs/ui/src/ai/a2ui/system-prompt.test.ts @@ -1,7 +1,21 @@ import { describe, expect, it } from 'vitest'; import { buildA2UISystemPrompt } from './system-prompt'; +import { A2UI_SECURITY_LIMITS, A2UI_ALWAYS_BLOCKED_URL_SCHEMES } from './security'; describe('A2UI system prompt', () => { + it('SHOULD include the security rules section with the enforced scheme and limit values', () => { + const prompt = buildA2UISystemPrompt(); + + expect(prompt).toContain('## SECURITY RULES — STRICTLY ENFORCED'); + for (const scheme of A2UI_ALWAYS_BLOCKED_URL_SCHEMES) { + expect(prompt).toContain(scheme); + } + expect(prompt).toContain('dangerouslySetInnerHTML'); + expect(prompt).toContain(`${A2UI_SECURITY_LIMITS.maxTreeDepth} nesting levels`); + expect(prompt).toContain(`${A2UI_SECURITY_LIMITS.maxNodeCount} total component nodes`); + expect(prompt).toContain(`${A2UI_SECURITY_LIMITS.maxPayloadBytes} bytes`); + }); + it('SHOULD include custom components in the prompt and group them by category', () => { const prompt = buildA2UISystemPrompt({ customComponents: [ diff --git a/libs/ui/src/ai/a2ui/system-prompt.ts b/libs/ui/src/ai/a2ui/system-prompt.ts index 86d6192..2f5835e 100644 --- a/libs/ui/src/ai/a2ui/system-prompt.ts +++ b/libs/ui/src/ai/a2ui/system-prompt.ts @@ -22,6 +22,7 @@ import { A2UI_COMPONENT_MAP, A2UI_AVAILABLE_ICONS, A2UI_BUTTON_VARIANTS, A2UI_ICON_CATALOG } from './component-map'; import { type A2UIImageSources, normalizeImageSources } from './image-policy'; +import { A2UI_SECURITY_LIMITS, A2UI_ALWAYS_BLOCKED_URL_SCHEMES } from './security'; import { type A2UIActionDefinition, type A2UICustomComponentMeta } from './spec-schema'; import { FIGMA_COLOR_MAP, @@ -402,6 +403,17 @@ export function buildA2UISystemPrompt(options?: A2UISystemPromptOptions): string 'If you are not highly confident that a direct image URL will render publicly, do NOT guess. Omit the image component entirely.', ]), '', + '## SECURITY RULES — STRICTLY ENFORCED', + '', + 'These rules are enforced by the renderer at runtime. A spec that violates them will not render as intended.', + '', + `1. NEVER use these URL schemes for any href, src, or path value: ${A2UI_ALWAYS_BLOCKED_URL_SCHEMES.join(', ')}`, + ' This applies to link.href, image/avatar/card-image/chat-image-gallery src, sidebar/header nav items, and breadcrumb/option href.', + "2. NEVER include attributes.dangerouslySetInnerHTML, or any function-like value, in a component's attributes object.", + ' All text content is rendered as plain text — do not attempt to inject markup or scripts through label, value, or attributes.', + `3. Keep the component tree within ${A2UI_SECURITY_LIMITS.maxTreeDepth} nesting levels and ${A2UI_SECURITY_LIMITS.maxNodeCount} total component nodes.`, + `4. Keep the overall JSON spec under ${A2UI_SECURITY_LIMITS.maxPayloadBytes} bytes. Do not emit excessively large rows/columns/options arrays to work around this.`, + '', '## ACCESSIBILITY RULES', '', '1. Always set a meaningful alt text on image components (attributes.alt).', diff --git a/libs/ui/src/utils/a2ui-security.test.tsx b/libs/ui/src/utils/a2ui-security.test.tsx new file mode 100644 index 0000000..b6ecbad --- /dev/null +++ b/libs/ui/src/utils/a2ui-security.test.tsx @@ -0,0 +1,157 @@ +import '@testing-library/jest-dom'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@testUtils'; +import { renderA2UISpec } from './a2ui'; +import { A2UI_SECURITY_LIMITS } from '../ai'; +import { SECURITY_FALLBACK_TEST_ID } from './a2ui/constants'; + +declare global { + var __pwned: boolean | undefined; +} + +function buildWideSiblingSpec(count: number) { + return { + ui: { + components: Array.from({ length: count }, (_, index) => ({ id: `sibling_${index}`, type: 'box' as const })), + }, + }; +} + +function buildNestedChildrenSpec(depth: number) { + let leaf: Record = { id: `node_${depth}`, type: 'box' }; + + for (let level = depth - 1; level >= 1; level--) { + leaf = { id: `node_${level}`, type: 'box', children: [leaf] }; + } + + return { ui: { components: [leaf] } }; +} + +describe('renderA2UISpec security', () => { + beforeAll(() => { + if (typeof globalThis.ResizeObserver === 'undefined') { + globalThis.ResizeObserver = class ResizeObserver { + // eslint-disable-next-line @typescript-eslint/no-empty-function + observe() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + unobserve() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + disconnect() {} + } as typeof ResizeObserver; + } + }); + + it('SHOULD render the fallback notice, not the tree, for a spec over the node-count limit', () => { + const onSecurityViolation = vi.fn(); + const spec = buildWideSiblingSpec(A2UI_SECURITY_LIMITS.maxNodeCount + 1); + + render(<>{renderA2UISpec(spec, undefined, undefined, { onSecurityViolation })}); + + expect(screen.getByTestId(SECURITY_FALLBACK_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId('a2ui-layout-root')).toBeNull(); + expect(onSecurityViolation).toHaveBeenCalledTimes(1); + expect(onSecurityViolation.mock.calls[0]![0]).toEqual(expect.arrayContaining([expect.stringContaining('nodes')])); + }); + + it('SHOULD render the fallback notice for a spec over the tree-depth limit', () => { + const spec = buildNestedChildrenSpec(A2UI_SECURITY_LIMITS.maxTreeDepth + 1); + + render(<>{renderA2UISpec(spec)}); + + expect(screen.getByTestId(SECURITY_FALLBACK_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId('a2ui-layout-root')).toBeNull(); + }); + + it('SHOULD render normally, and not call onSecurityViolation, for a spec within limits', () => { + const onSecurityViolation = vi.fn(); + const spec = buildWideSiblingSpec(5); + + render(<>{renderA2UISpec(spec, undefined, undefined, { onSecurityViolation })}); + + expect(screen.getByTestId('a2ui-layout-root')).toBeTruthy(); + expect(screen.queryByTestId(SECURITY_FALLBACK_TEST_ID)).toBeNull(); + expect(onSecurityViolation).not.toHaveBeenCalled(); + }); + + it('SHOULD still render a large but realistic table (many rows/columns) within limits', () => { + const spec = { + ui: { + components: [ + { + id: 'big_table', + type: 'table' as const, + columns: Array.from({ length: 12 }, (_, i) => ({ key: `col_${i}`, label: `Column ${i}` })), + rows: Array.from({ length: 200 }, (_, i) => ({ col_0: `row ${i}` })), + }, + ], + }, + }; + + render(<>{renderA2UISpec(spec)}); + + expect(screen.getByTestId('a2ui-layout-root')).toBeTruthy(); + expect(screen.queryByTestId(SECURITY_FALLBACK_TEST_ID)).toBeNull(); + }); + + it('SHOULD render a link with a javascript: href without a functioning navigational href', () => { + const unsafeHref = 'javascript:alert(1)'; + const spec = { + ui: { + components: [{ id: 'evil_link', type: 'link' as const, href: unsafeHref, label: 'Click me' }], + }, + }; + + render(<>{renderA2UISpec(spec)}); + + const link = screen.getByText('Click me').closest('a'); + expect(link?.getAttribute('href')).not.toBe(unsafeHref); + + expect(link?.getAttribute('href') ?? '').not.toContain('javascript:'); + }); + + it('SHOULD never inject raw HTML or execute markup from attributes.dangerouslySetInnerHTML on skeleton', () => { + globalThis.__pwned = false; + + const spec = { + ui: { + components: [ + { + id: 'evil_skeleton', + type: 'skeleton' as const, + attributes: { + dangerouslySetInnerHTML: { __html: '' }, + }, + }, + ], + }, + }; + + render(<>{renderA2UISpec(spec)}); + + expect(document.querySelector('img[onerror]')).toBeNull(); + expect(document.body.innerHTML).not.toContain('onerror'); + expect(globalThis.__pwned).toBe(false); + }); + + it('SHOULD render label/value text containing markup as inert escaped text', () => { + const spec = { + ui: { + components: [ + { + id: 'evil_text', + type: 'typography' as const, + value: '', + }, + ], + }, + }; + + globalThis.__pwned = false; + + render(<>{renderA2UISpec(spec)}); + + expect(screen.getByText('')).toBeTruthy(); + expect(document.querySelectorAll('script')).toHaveLength(0); + expect(globalThis.__pwned).toBe(false); + }); +}); diff --git a/libs/ui/src/utils/a2ui/constants.ts b/libs/ui/src/utils/a2ui/constants.ts index f101eb8..d9faba0 100644 --- a/libs/ui/src/utils/a2ui/constants.ts +++ b/libs/ui/src/utils/a2ui/constants.ts @@ -1,4 +1,5 @@ export const ROOT_TEST_ID = 'a2ui-layout-root'; +export const SECURITY_FALLBACK_TEST_ID = 'a2ui-security-fallback'; export const DROPDOWN_SELECT_CONTEXT = { onSelect: () => { /* empty */ diff --git a/libs/ui/src/utils/a2ui/helpers/options.tsx b/libs/ui/src/utils/a2ui/helpers/options.tsx index a01c15d..4253cf3 100644 --- a/libs/ui/src/utils/a2ui/helpers/options.tsx +++ b/libs/ui/src/utils/a2ui/helpers/options.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { FlexContainer, Icon, Typography } from '@components'; import type { A2UIComponent } from '../../../ai'; +import { isSafeA2UIUrl } from '../../../ai'; import { isRecord } from './misc'; import { getAttributeBoolean } from './attributes'; @@ -28,7 +29,7 @@ export function getOptionLikeItems(component: A2UIComponent) { label: typeof labelSource === 'string' ? labelSource : String(labelSource ?? `Option ${index + 1}`), disabled: typeof item['disabled'] === 'boolean' ? item['disabled'] : undefined, icon: typeof item['icon'] === 'string' ? item['icon'] : undefined, - href: typeof item['href'] === 'string' ? item['href'] : undefined, + href: typeof item['href'] === 'string' && isSafeA2UIUrl(item['href']) ? item['href'] : undefined, noticeCounter: typeof item['noticeCounter'] === 'string' || typeof item['noticeCounter'] === 'number' ? item['noticeCounter'] diff --git a/libs/ui/src/utils/a2ui/render.tsx b/libs/ui/src/utils/a2ui/render.tsx index 63d10f4..d5ce2e1 100644 --- a/libs/ui/src/utils/a2ui/render.tsx +++ b/libs/ui/src/utils/a2ui/render.tsx @@ -1,9 +1,15 @@ import { cloneElement, Fragment, isValidElement, type ReactElement, type ReactNode } from 'react'; -import { Box, Typography } from '@components'; +import { Box, InlineNotification, Typography } from '@components'; import type { A2UIActionDefinition, A2UIComponent } from '../../ai'; -import { A2UI_COMPONENT_MAP } from '../../ai'; -import type { DispatchAction, RenderableA2UISpec, Renderer, A2UICustomComponentDefinition } from './types'; -import { ROOT_TEST_ID } from './constants'; +import { A2UI_COMPONENT_MAP, checkA2UISpecLimits } from '../../ai'; +import type { + DispatchAction, + RenderableA2UISpec, + Renderer, + A2UICustomComponentDefinition, + A2UISecurityOptions, +} from './types'; +import { ROOT_TEST_ID, SECURITY_FALLBACK_TEST_ID } from './constants'; import { getMergedComponentStyles, getComponentText } from './helpers'; import { renderers } from './renderers'; @@ -154,12 +160,25 @@ function createCustomRendererMap( export function renderA2UISpec( spec?: RenderableA2UISpec | null, actions?: A2UIActionDefinition[], - customComponents?: A2UICustomComponentDefinition[] + customComponents?: A2UICustomComponentDefinition[], + securityOptions?: A2UISecurityOptions ): ReactNode { if (!spec?.ui?.components?.length) { return null; } + const limitCheck = checkA2UISpecLimits(spec, securityOptions?.limits); + + if (!limitCheck.valid) { + securityOptions?.onSecurityViolation?.(limitCheck.violations); + + return ( + + This content could not be displayed because it exceeded a configured size or complexity limit. + + ); + } + const dispatchAction = actions?.length ? createDispatchAction(spec, actions) : undefined; const customRenderers = customComponents?.length ? createCustomRendererMap(customComponents) : undefined; diff --git a/libs/ui/src/utils/a2ui/renderers/avatar.tsx b/libs/ui/src/utils/a2ui/renderers/avatar.tsx index 4a69a5a..fcdc08f 100644 --- a/libs/ui/src/utils/a2ui/renderers/avatar.tsx +++ b/libs/ui/src/utils/a2ui/renderers/avatar.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { Avatar, AvatarUser, Icon } from '@components'; import type { A2UIComponent } from '../../../ai'; +import { isSafeA2UIUrl } from '../../../ai'; import { getMergedComponentStyles, getComponentText, @@ -9,6 +10,11 @@ import { getComponentArrayField, } from '../helpers'; +function getSafeAvatarSrc(component: A2UIComponent) { + const src = (component.src as string | undefined) || getAttributeString(component, 'src'); + return isSafeA2UIUrl(src) ? src : undefined; +} + function getAvatarIconSize(sizeVariant?: string) { switch (sizeVariant) { case 'xs': @@ -91,7 +97,7 @@ export const avatarRenderers = { ReactNode[]) => ( ( & { @@ -19,7 +20,7 @@ function normalizeLegacyContentCarouselItem( return item as A2UIComponent; } - if (typeof item.src === 'string') { + if (typeof item.src === 'string' && isSafeA2UIUrl(item.src)) { return { id: item.id || `${parentId}_item_${index + 1}`, type: 'image', diff --git a/libs/ui/src/utils/a2ui/renderers/header.tsx b/libs/ui/src/utils/a2ui/renderers/header.tsx index e786a64..a3881b2 100644 --- a/libs/ui/src/utils/a2ui/renderers/header.tsx +++ b/libs/ui/src/utils/a2ui/renderers/header.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { Header } from '@components'; import type { A2UIComponent } from '../../../ai'; +import { isSafeA2UIUrl } from '../../../ai'; import { getComponentStyles, getObjectArrayField, @@ -26,7 +27,7 @@ function getHeaderMobileMenuList(component: A2UIComponent) { result.push({ id: typeof item['id'] === 'string' ? item['id'] : `${component.id}_mobile_item_${index + 1}`, title, - path: typeof item['path'] === 'string' ? item['path'] : undefined, + path: typeof item['path'] === 'string' && isSafeA2UIUrl(item['path']) ? item['path'] : undefined, icon: typeof item['icon'] === 'string' ? item['icon'] : undefined, }); diff --git a/libs/ui/src/utils/a2ui/renderers/image.tsx b/libs/ui/src/utils/a2ui/renderers/image.tsx index 7419934..e868a7d 100644 --- a/libs/ui/src/utils/a2ui/renderers/image.tsx +++ b/libs/ui/src/utils/a2ui/renderers/image.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from 'react'; import { Image, ImagePreview, InlineNotification } from '@components'; import type { A2UIComponent } from '../../../ai'; +import { isSafeA2UIUrl } from '../../../ai'; import { getComponentStyles, getAttributeString, @@ -10,7 +11,8 @@ import { } from '../helpers'; export function getImageSrc(component: A2UIComponent) { - return component.src || getAttributeString(component, 'src'); + const src = component.src || getAttributeString(component, 'src'); + return isSafeA2UIUrl(src) ? src : undefined; } export function getImageAlt(component: A2UIComponent) { @@ -56,7 +58,7 @@ type ImagePreviewItemSpec = { }; function normalizeImagePreviewItem(rawItem: Record): ImagePreviewItemSpec | null { - if (typeof rawItem['src'] !== 'string') { + if (typeof rawItem['src'] !== 'string' || !isSafeA2UIUrl(rawItem['src'])) { return null; } diff --git a/libs/ui/src/utils/a2ui/renderers/link.tsx b/libs/ui/src/utils/a2ui/renderers/link.tsx index 56ea183..f8e7af5 100644 --- a/libs/ui/src/utils/a2ui/renderers/link.tsx +++ b/libs/ui/src/utils/a2ui/renderers/link.tsx @@ -1,8 +1,14 @@ import type { ReactNode } from 'react'; import { Link } from '@components'; import type { A2UIComponent } from '../../../ai'; +import { isSafeA2UIUrl } from '../../../ai'; import { getMergedComponentStyles, getComponentText, getAttributeString } from '../helpers'; +function getSafeLinkHref(component: A2UIComponent) { + const href = (component.href as string | undefined) || getAttributeString(component, 'href'); + return isSafeA2UIUrl(href) ? href : undefined; +} + export const linkRenderers = { link: ( component: A2UIComponent, @@ -11,7 +17,7 @@ export const linkRenderers = { ) => ( {renderChildren(component.children)} diff --git a/libs/ui/src/utils/a2ui/types.ts b/libs/ui/src/utils/a2ui/types.ts index 96cfab9..3048f8e 100644 --- a/libs/ui/src/utils/a2ui/types.ts +++ b/libs/ui/src/utils/a2ui/types.ts @@ -1,5 +1,5 @@ import type { ReactNode } from 'react'; -import type { A2UIComponent, A2UISpec, A2UICustomComponentMeta } from '../../ai'; +import type { A2UIComponent, A2UISpec, A2UICustomComponentMeta, A2UISecurityLimits } from '../../ai'; import type { INLINE_STYLE_PROP_KEYS } from './constants'; export type RenderableA2UISpec = Pick; @@ -23,3 +23,19 @@ export type A2UICustomComponentDefinition = A2UICustomComponentMeta & { /** Renders the component when its `type` appears in a spec produced by the LLM */ renderer: Renderer; }; + +/** + * Optional security configuration for `renderA2UISpec`. Limits are merged over + * `A2UI_SECURITY_LIMITS` defaults. + * + * URL scheme checking at render time always uses the fixed default allowlist + * (`A2UI_DEFAULT_ALLOWED_URL_SCHEMES`) as a defense-in-depth floor — it is not configurable + * here. A host that needs a different policy should call `isSafeA2UIUrl(url, customSchemes)` + * itself against the spec before rendering (the same function an ingest-side consumer such + * as Cerebra would call before accepting the spec). + */ +export type A2UISecurityOptions = { + limits?: Partial; + /** Called with the list of violation messages when a spec fails the resource-limit check. */ + onSecurityViolation?: (violations: string[]) => void; +}; From eeabfb32b5fdb6d692d0b1cf4e0fe1317383a8cb Mon Sep 17 00:00:00 2001 From: Oleksii Povolotskyi Date: Tue, 21 Jul 2026 09:02:19 +0200 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/ui/src/ai/a2ui/security.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/libs/ui/src/ai/a2ui/security.ts b/libs/ui/src/ai/a2ui/security.ts index 4d72829..85393bd 100644 --- a/libs/ui/src/ai/a2ui/security.ts +++ b/libs/ui/src/ai/a2ui/security.ts @@ -100,7 +100,12 @@ export function checkA2UISpecLimits( spec: Pick | A2UISpec | null | undefined, limits?: Partial ): A2UISpecLimitCheck { - const mergedLimits: A2UISecurityLimits = { ...A2UI_SECURITY_LIMITS, ...limits }; + const mergedLimits: A2UISecurityLimits = { + ...A2UI_SECURITY_LIMITS, + ...(typeof limits?.maxTreeDepth === 'number' ? { maxTreeDepth: limits.maxTreeDepth } : {}), + ...(typeof limits?.maxNodeCount === 'number' ? { maxNodeCount: limits.maxNodeCount } : {}), + ...(typeof limits?.maxPayloadBytes === 'number' ? { maxPayloadBytes: limits.maxPayloadBytes } : {}), + }; const violations: string[] = []; const accumulator = { nodeCount: 0, maxDepth: 0 }; From 4d688055a54b22c113b5c32fbf623b1d92799d25 Mon Sep 17 00:00:00 2001 From: Oleksii Povolotskyi Date: Tue, 21 Jul 2026 09:02:28 +0200 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- libs/ui/src/ai/a2ui/security.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/ui/src/ai/a2ui/security.ts b/libs/ui/src/ai/a2ui/security.ts index 85393bd..3e301ff 100644 --- a/libs/ui/src/ai/a2ui/security.ts +++ b/libs/ui/src/ai/a2ui/security.ts @@ -192,7 +192,7 @@ export function isSafeA2UIUrl( return allowedSchemes.includes(scheme); } -const EVENT_HANDLER_ATTRIBUTE_PATTERN = /^on[A-Z]/; +const EVENT_HANDLER_ATTRIBUTE_PATTERN = /^on[a-z]/i; /** * Strips dangerous keys/values from a spec's free-form `attributes` object before it is