Skip to content
Merged
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
54 changes: 54 additions & 0 deletions libs/ui/src/ai/a2ui/A2UI_PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions libs/ui/src/ai/a2ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
229 changes: 229 additions & 0 deletions libs/ui/src/ai/a2ui/security.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = { 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: '<img src=x onerror=alert(1)>' } });

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();
});
});
Loading