@@ -98,7 +120,9 @@ const streamingComponents = buildComponents(true)
* pass a reference-stable object (memoized per locale revision), because the
* component table memoizes on its identity and a fresh literal per render
* would rebuild it every streaming chunk.
- * @returns A GFM document with TeX math rendered through KaTeX and raw HTML, relative links, unsafe protocols, and remote images disabled.
+ * @returns A GFM document with TeX math rendered through KaTeX; raw HTML,
+ * relative links, and unsafe protocols are disabled, while absolute HTTP(S)
+ * images render directly.
*/
export function MarkdownText({ text, streaming = false, codeLabels }: {
text: string
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx
index 030856a261..10af104e5a 100644
--- a/packages/client/ui-primitives/tests/markdown.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.spec.tsx
@@ -99,13 +99,35 @@ describe('MarkdownText', () => {
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
})
- it('neutralizes raw HTML, unsafe or relative links, and remote images', () => {
+ it('renders absolute HTTP(S) images with bounded presentation', () => {
+ const markdown = [
+ '',
+ '',
+ ].join('\n\n')
+ const { container } = render(
)
+ const images = [...container.querySelectorAll('img')]
+ expect(images.map(image => image.getAttribute('src'))).toEqual([
+ 'https://example.com/secure.png',
+ 'http://example.com/plain.png',
+ ])
+ for (const image of images) {
+ expect(image.getAttribute('loading')).toBe('lazy')
+ expect(image.getAttribute('decoding')).toBe('async')
+ expect(image.getAttribute('referrerpolicy')).toBe('no-referrer')
+ }
+ })
+
+ it('neutralizes raw HTML, unsafe or relative links, and unsupported images', () => {
const markdown = [
'',
'

',
'[script](javascript:alert(1)) [relative](/settings)',
'[mail](mailto:dev@example.com) [web](http://example.com) [upper](HTTPS://example.com)',
- '',
+ '',
+ '',
+ '',
+ ')',
+ '',
].join('\n\n')
const { container } = render(
)
@@ -117,7 +139,11 @@ describe('MarkdownText', () => {
expect(screen.getByRole('link', { name: 'mail' }).getAttribute('target')).toBeNull()
expect(screen.getByRole('link', { name: 'web' }).getAttribute('rel')).toBe('noopener noreferrer')
expect(screen.getByRole('link', { name: 'upper' }).getAttribute('target')).toBe('_blank')
- expect(screen.getByText('remote diagram')).toBeTruthy()
+ expect(screen.getByText('relative diagram')).toBeTruthy()
+ expect(screen.getByText('absolute diagram')).toBeTruthy()
+ expect(screen.getByText('file diagram')).toBeTruthy()
+ expect(screen.getByText('script diagram')).toBeTruthy()
+ expect(screen.getByText('mail diagram')).toBeTruthy()
})
it('keeps incomplete streaming Markdown renderable', () => {
diff --git a/tsconfig.host.json b/tsconfig.host.json
index 015998c271..70f5854c69 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -33,6 +33,7 @@
"apps/web/tests/cordis-tool-round.e2e.ts",
"apps/web/tests/web-search-round.e2e.ts",
"apps/web/tests/message-actions.e2e.ts",
+ "apps/web/tests/markdown-images.e2e.ts",
"apps/web/tests/queue-actions.e2e.ts",
"apps/web/tests/skill-invocation-policy.e2e.ts",
"apps/web/tests/permission-policy-context.e2e.ts",