Skip to content

Commit 5daf50f

Browse files
fix(a2a-core, iframe-app): sanitize chat HTML against XSS, add build version metadata & enable coverage (#8860)
* fix(a2a-core): sanitize HTML output to prevent DOM XSS in Message component Add DOMPurify sanitization to all dangerouslySetInnerHTML paths in Message.tsx where marked.parse() and Prism.highlight() output was rendered without sanitization, allowing XSS via crafted markdown. - Add sanitizeHtml() utility with strict ALLOWED_TAGS/ATTR whitelist - Sanitize all 6 dangerouslySetInnerHTML paths in Message.tsx - Harden marked link renderer to block javascript: URLs - Add 13 XSS prevention unit tests MSRC Case 108268 / IcM 31000000555406 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(iframe-app): inject build version metadata for traceability Add git tag, SHA, branch, and build timestamp to the compiled output: - <meta name="build-version"> tag in the HTML head - __BUILD_*__ globals available in JS at runtime - Console log at app startup with version info Enables tracing any deployed iframe back to its source tag/commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(security): HTML-escape attribute values to prevent XSS - Escape git metadata (tag, sha, branch) in vite build meta tag injection - Escape href and title attributes in marked link renderer Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(a2a-core): add tests for HTML escaping and sanitization - Add Message.escapeAttr tests for link attribute escaping - Add sanitize.test.ts tests for style, form, event handlers, etc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update imports * fix(a2a-core): enable coverage collection in vitest config Align with other libs by using istanbul provider with enabled: true, so coverage data is collected during test:lib CI runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(a2a-core): fix TypeScript errors in Message test files - Replace invalid 'delivered' status with 'sent' (MessageStatus type) - Add missing 'status' property to AuthRequiredPart mock Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(iframe-app): remove console.info build version log from main.tsx Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 8c9ec1e commit 5daf50f

File tree

12 files changed

+625
-464
lines changed

12 files changed

+625
-464
lines changed

apps/iframe-app/src/global.d.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
declare const __BUILD_VERSION__: string;
2+
declare const __BUILD_TAG__: string;
3+
declare const __BUILD_SHA__: string;
4+
declare const __BUILD_BRANCH__: string;
5+
declare const __BUILD_TIME__: string;
6+
17
declare global {
28
interface Window {
39
LOGGED_IN_USER_NAME?: string;

apps/iframe-app/vite.config.ts

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,45 @@ import type { Plugin } from 'vite';
44
import { defineConfig } from 'vite';
55
import react from '@vitejs/plugin-react';
66
import mkcert from 'vite-plugin-mkcert';
7+
import { execSync } from 'child_process';
8+
9+
// Resolve git version info at build time
10+
function getGitVersion(): { tag: string; sha: string; branch: string; buildTime: string } {
11+
const run = (cmd: string) => {
12+
try {
13+
return execSync(cmd, { encoding: 'utf-8' }).trim();
14+
} catch {
15+
return 'unknown';
16+
}
17+
};
18+
return {
19+
tag: run('git describe --tags --abbrev=0 2>/dev/null || echo untagged'),
20+
sha: run('git rev-parse --short HEAD'),
21+
branch: run('git rev-parse --abbrev-ref HEAD'),
22+
buildTime: new Date().toISOString(),
23+
};
24+
}
25+
26+
// HTML-escape a string for safe insertion into HTML attributes
27+
function escapeHtmlAttr(value: string): string {
28+
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
29+
}
30+
31+
// Custom plugin to inject build version as a <meta> tag in HTML
32+
function injectBuildVersion(version: ReturnType<typeof getGitVersion>): Plugin {
33+
return {
34+
name: 'inject-build-version',
35+
transformIndexHtml(html) {
36+
const tag = escapeHtmlAttr(version.tag);
37+
const sha = escapeHtmlAttr(version.sha);
38+
const branch = escapeHtmlAttr(version.branch);
39+
const buildTime = escapeHtmlAttr(version.buildTime);
40+
const versionString = `${tag}+${sha}`;
41+
const meta = `<meta name="build-version" content="${versionString}" data-tag="${tag}" data-sha="${sha}" data-branch="${branch}" data-build-time="${buildTime}" />`;
42+
return html.replace('</head>', ` ${meta}\n</head>`);
43+
},
44+
};
45+
}
746

847
// Custom plugin to rename index.html to iframe.html after build
948
function renameIndexHtml(): Plugin {
@@ -22,27 +61,38 @@ function renameIndexHtml(): Plugin {
2261
};
2362
}
2463

25-
export default defineConfig({
26-
plugins: [
27-
react(),
28-
renameIndexHtml(),
29-
// Only use mkcert (HTTPS) locally, not in CI or E2E
30-
...(process.env.CI || process.env.E2E ? [] : [mkcert()]),
31-
],
32-
base: './', // Use relative paths instead of absolute
33-
build: {
34-
outDir: 'dist',
35-
rollupOptions: {
36-
output: {
37-
manualChunks: undefined,
38-
assetFileNames: '[name]-[hash].[ext]',
39-
chunkFileNames: '[name]-[hash].js',
40-
entryFileNames: '[name]-[hash].js',
64+
export default defineConfig(() => {
65+
const version = getGitVersion();
66+
return {
67+
plugins: [
68+
react(),
69+
injectBuildVersion(version),
70+
renameIndexHtml(),
71+
// Only use mkcert (HTTPS) locally, not in CI or E2E
72+
...(process.env.CI || process.env.E2E ? [] : [mkcert()]),
73+
],
74+
define: {
75+
__BUILD_VERSION__: JSON.stringify(`${version.tag}+${version.sha}`),
76+
__BUILD_TAG__: JSON.stringify(version.tag),
77+
__BUILD_SHA__: JSON.stringify(version.sha),
78+
__BUILD_BRANCH__: JSON.stringify(version.branch),
79+
__BUILD_TIME__: JSON.stringify(version.buildTime),
80+
},
81+
base: './', // Use relative paths instead of absolute
82+
build: {
83+
outDir: 'dist',
84+
rollupOptions: {
85+
output: {
86+
manualChunks: undefined,
87+
assetFileNames: '[name]-[hash].[ext]',
88+
chunkFileNames: '[name]-[hash].js',
89+
entryFileNames: '[name]-[hash].js',
90+
},
4191
},
4292
},
43-
},
44-
server: {
45-
port: 3001,
46-
host: true,
47-
},
93+
server: {
94+
port: 3001,
95+
host: true,
96+
},
97+
};
4898
});

libs/a2a-core/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@
4343
"@testing-library/user-event": "^14.6.1",
4444
"@types/events": "^3.0.3",
4545
"@types/node": "^24.0.12",
46+
"@types/dompurify": "3.0.5",
4647
"@types/prismjs": "^1.26.5",
4748
"@types/react": "^19.0.0",
4849
"@types/react-dom": "^19.2.2",
49-
"@vitest/coverage-v8": "^3.2.4",
50+
"@vitest/coverage-istanbul": "^3.2.4",
5051
"@vitest/ui": "^3.2.4",
5152
"jsdom": "^26.1.0",
5253
"postcss": "^8.5.6",
@@ -69,6 +70,7 @@
6970
"dependencies": {
7071
"@fluentui/react-components": "9.70.0",
7172
"@fluentui/react-icons": "^2.0.306",
73+
"dompurify": "3.2.4",
7274
"eventemitter3": "^5.0.1",
7375
"idb": "^8.0.3",
7476
"immer": "^10.1.1",

libs/a2a-core/src/react/components/Message/Message.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import Prism from 'prismjs';
2727
import { AuthenticationMessage } from './AuthenticationMessage';
2828
import { CodeBlockHeader } from './CodeBlockHeader';
2929
import { getUserFriendlyErrorMessage } from '../../utils/errorUtils';
30+
import { sanitizeHtml } from '../../../utils/sanitize';
3031
import 'prismjs/themes/prism.css';
3132
// Import all Prism language components
3233
import 'prismjs/components/prism-clike';
@@ -75,12 +76,18 @@ marked.use(
7576
})
7677
);
7778

79+
// HTML-escape a string for safe insertion into HTML attributes
80+
function escapeAttr(value: string): string {
81+
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
82+
}
83+
7884
// Configure marked to open links in new tabs
7985
marked.use({
8086
renderer: {
8187
link(href, title, text) {
82-
const titleAttr = title ? ` title="${title}"` : '';
83-
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
88+
const safeHref = href && /^\s*javascript:/i.test(href) ? '#' : escapeAttr(href);
89+
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
90+
return `<a href="${safeHref}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
8491
},
8592
},
8693
});
@@ -495,7 +502,7 @@ function MessageComponent({
495502
<pre>
496503
<code
497504
className={`language-${language}`}
498-
dangerouslySetInnerHTML={{ __html: highlighted }}
505+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(highlighted) }}
499506
/>
500507
</pre>
501508
</div>
@@ -529,7 +536,7 @@ function MessageComponent({
529536
// Add content before the code block
530537
if (match.index > lastIndex) {
531538
const textContent = message.content.slice(lastIndex, match.index);
532-
const html = marked.parse(textContent, { gfm: true, breaks: true }) as string;
539+
const html = sanitizeHtml(marked.parse(textContent, { gfm: true, breaks: true }) as string);
533540
elements.push(
534541
<div key={`text-${lastIndex}`} dangerouslySetInnerHTML={{ __html: html }} />
535542
);
@@ -555,7 +562,7 @@ function MessageComponent({
555562
<pre>
556563
<code
557564
className={language ? `language-${language}` : ''}
558-
dangerouslySetInnerHTML={{ __html: highlighted }}
565+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(highlighted) }}
559566
/>
560567
</pre>
561568
</div>
@@ -568,13 +575,13 @@ function MessageComponent({
568575
// Add any remaining content after the last code block
569576
if (lastIndex < message.content.length) {
570577
const remainingContent = message.content.slice(lastIndex);
571-
const html = marked.parse(remainingContent, { gfm: true, breaks: true }) as string;
578+
const html = sanitizeHtml(marked.parse(remainingContent, { gfm: true, breaks: true }) as string);
572579
elements.push(<div key={`text-${lastIndex}`} dangerouslySetInnerHTML={{ __html: html }} />);
573580
}
574581

575582
// If no code blocks were found, just return the parsed markdown
576583
if (elements.length === 0) {
577-
const html = marked.parse(message.content, { gfm: true, breaks: true }) as string;
584+
const html = sanitizeHtml(marked.parse(message.content, { gfm: true, breaks: true }) as string);
578585
return <div dangerouslySetInnerHTML={{ __html: html }} />;
579586
}
580587

@@ -720,7 +727,7 @@ function MessageComponent({
720727
<pre>
721728
<code
722729
className={`language-${language}`}
723-
dangerouslySetInnerHTML={{ __html: highlighted }}
730+
dangerouslySetInnerHTML={{ __html: sanitizeHtml(highlighted) }}
724731
/>
725732
</pre>
726733
</div>

libs/a2a-core/src/react/components/Message/__tests__/AuthenticationMessage.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ describe('AuthenticationMessage', () => {
1919
{
2020
serviceName: 'External Service',
2121
consentLink: 'https://example.com/auth',
22+
status: 'pending',
2223
description: 'This action requires authentication with an external service.',
2324
},
2425
];

libs/a2a-core/src/react/components/Message/__tests__/CodeBlockHeader.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
2-
import { vi } from 'vitest';
2+
import { vi, describe, expect, it, beforeEach } from 'vitest';
33
import '@testing-library/jest-dom';
44
import { CodeBlockHeader } from '../CodeBlockHeader';
55

libs/a2a-core/src/react/components/Message/__tests__/Message.codeblock.test.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { render, screen, fireEvent } from '@testing-library/react';
2-
import { vi } from 'vitest';
2+
import { vi, describe, beforeEach, it, expect } from 'vitest';
33
import '@testing-library/jest-dom';
44
import { Message } from '../Message';
55
import type { Message as MessageType } from '../../../types';
@@ -22,7 +22,7 @@ describe('Message - Code Block Headers', () => {
2222
content: '```javascript\nconsole.log("Hello, World!");\n```',
2323
sender: 'assistant',
2424
timestamp: new Date(),
25-
status: 'delivered',
25+
status: 'sent',
2626
};
2727

2828
render(<Message message={message} />);
@@ -48,7 +48,7 @@ greeting = "Hello"
4848
\`\`\``,
4949
sender: 'assistant',
5050
timestamp: new Date(),
51-
status: 'delivered',
51+
status: 'sent',
5252
};
5353

5454
render(<Message message={message} />);
@@ -69,7 +69,7 @@ greeting = "Hello"
6969
content: `\`\`\`javascript\n${codeContent}\n\`\`\``,
7070
sender: 'assistant',
7171
timestamp: new Date(),
72-
status: 'delivered',
72+
status: 'sent',
7373
};
7474

7575
render(<Message message={message} />);
@@ -86,7 +86,7 @@ greeting = "Hello"
8686
content: '```\nplain code block\n```',
8787
sender: 'assistant',
8888
timestamp: new Date(),
89-
status: 'delivered',
89+
status: 'sent',
9090
};
9191

9292
render(<Message message={message} />);
@@ -101,7 +101,7 @@ greeting = "Hello"
101101
content: 'Use the `console.log()` function to debug.',
102102
sender: 'assistant',
103103
timestamp: new Date(),
104-
status: 'delivered',
104+
status: 'sent',
105105
};
106106

107107
render(<Message message={message} />);
@@ -117,7 +117,7 @@ greeting = "Hello"
117117
content: 'Generated code',
118118
sender: 'assistant',
119119
timestamp: new Date(),
120-
status: 'delivered',
120+
status: 'sent',
121121
metadata: {
122122
isArtifact: true,
123123
artifactName: 'test.js',
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { render } from '@testing-library/react';
2+
import '@testing-library/jest-dom';
3+
import { Message } from '../Message';
4+
import type { Message as MessageType } from '../../../types';
5+
import { describe, it, expect } from 'vitest';
6+
7+
describe('Message - link attribute escaping', () => {
8+
const createAssistantMessage = (content: string): MessageType => ({
9+
id: '1',
10+
content,
11+
sender: 'assistant',
12+
timestamp: new Date(),
13+
status: 'sent',
14+
});
15+
16+
it('should escape double quotes in link title attributes', () => {
17+
const message = createAssistantMessage('Check [this link](https://example.com "My \\"quoted\\" title")');
18+
const { container } = render(<Message message={message} />);
19+
20+
const link = container.querySelector('a');
21+
// The link should render without breaking the HTML structure
22+
expect(link).toBeInTheDocument();
23+
expect(link?.getAttribute('target')).toBe('_blank');
24+
expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
25+
});
26+
27+
it('should not create script elements from angle brackets in href', () => {
28+
const message = createAssistantMessage('[click](https://example.com/path?q=<script>alert(1)</script>)');
29+
const { container } = render(<Message message={message} />);
30+
31+
const link = container.querySelector('a');
32+
expect(link).toBeInTheDocument();
33+
// Escaping prevents <script> in href from becoming a DOM element
34+
expect(container.querySelector('script')).not.toBeInTheDocument();
35+
});
36+
37+
it('should block javascript: protocol in href', () => {
38+
const message = createAssistantMessage('[click](javascript:alert(1))');
39+
const { container } = render(<Message message={message} />);
40+
41+
const link = container.querySelector('a');
42+
if (link) {
43+
const href = link.getAttribute('href') ?? '';
44+
expect(href).not.toMatch(/javascript:/i);
45+
}
46+
});
47+
48+
it('should preserve valid links with special characters', () => {
49+
const message = createAssistantMessage('[search](https://example.com/search?q=hello&lang=en)');
50+
const { container } = render(<Message message={message} />);
51+
52+
const link = container.querySelector('a');
53+
expect(link).toBeInTheDocument();
54+
expect(link?.getAttribute('target')).toBe('_blank');
55+
expect(link?.getAttribute('rel')).toBe('noopener noreferrer');
56+
});
57+
58+
it('should render links with no title attribute when title is absent', () => {
59+
const message = createAssistantMessage('[example](https://example.com)');
60+
const { container } = render(<Message message={message} />);
61+
62+
const link = container.querySelector('a');
63+
expect(link).toBeInTheDocument();
64+
expect(link?.hasAttribute('title')).toBe(false);
65+
expect(link?.getAttribute('href')).toContain('example.com');
66+
});
67+
});

0 commit comments

Comments
 (0)