+ }
@if (message.role === 'assistant' && message.references?.length) {
@for (reference of message.references; track reference.id) {
diff --git a/src-ui/src/app/components/chat/chat/chat.component.scss b/src-ui/src/app/components/chat/chat/chat.component.scss
index f14be66e2..4b85363bb 100644
--- a/src-ui/src/app/components/chat/chat/chat.component.scss
+++ b/src-ui/src/app/components/chat/chat/chat.component.scss
@@ -8,10 +8,6 @@
white-space: pre-wrap;
}
-.chat-references {
- font-family: var(--bs-font-sans-serif);
-}
-
.dropdown-toggle::after {
display: none;
}
@@ -40,3 +36,19 @@
opacity: 1;
}
}
+
+.chat-markdown ::ng-deep {
+ h1, h2, h3, h4, h5, h6 {
+ font-size: 1em;
+ font-weight: bold;
+ }
+
+ th, td {
+ padding: 0.15rem 0.4rem;
+ border: 1px solid var(--bs-border-color);
+ }
+
+ > :last-child {
+ margin-bottom: 0;
+ }
+}
diff --git a/src-ui/src/app/components/chat/chat/chat.component.spec.ts b/src-ui/src/app/components/chat/chat/chat.component.spec.ts
index 5b8e12af8..04948f3e9 100644
--- a/src-ui/src/app/components/chat/chat/chat.component.spec.ts
+++ b/src-ui/src/app/components/chat/chat/chat.component.spec.ts
@@ -207,4 +207,33 @@ describe('ChatComponent', () => {
component.searchInputKeyDown(event)
expect(component.sendMessage).not.toHaveBeenCalled()
})
+
+ it('should render markdown for assistant messages only', () => {
+ component.messages.set([
+ { role: 'user', content: '**user**' },
+ { role: 'assistant', content: '**assistant** ' },
+ ])
+ fixture.detectChanges()
+ const el: HTMLElement = fixture.nativeElement
+ expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
+ 'assistant'
+ )
+ expect(el.querySelector('.chat-markdown script')).toBeNull()
+ expect(el.textContent).toContain('**user**')
+ })
+
+ it('should keep the destination hostname visible after sanitizing chat links', () => {
+ component.messages.set([
+ {
+ role: 'assistant',
+ content:
+ '[View original document](https://otherdomain.example/?data=secret)',
+ },
+ ])
+ fixture.detectChanges()
+ const el: HTMLElement = fixture.nativeElement
+ const link = el.querySelector('.chat-markdown a')
+ expect(link?.textContent).toBe('View original document')
+ expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
+ })
})
diff --git a/src-ui/src/app/components/chat/chat/chat.component.ts b/src-ui/src/app/components/chat/chat/chat.component.ts
index 4525808a4..3b74a04bc 100644
--- a/src-ui/src/app/components/chat/chat/chat.component.ts
+++ b/src-ui/src/app/components/chat/chat/chat.component.ts
@@ -10,6 +10,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
+import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
import { filter, map } from 'rxjs'
import {
ChatMessage,
@@ -25,6 +26,7 @@ import {
RouterModule,
NgxBootstrapIconsModule,
NgbDropdownModule,
+ MarkdownPipe,
],
templateUrl: './chat.component.html',
styleUrl: './chat.component.scss',
diff --git a/src-ui/src/app/pipes/markdown.pipe.spec.ts b/src-ui/src/app/pipes/markdown.pipe.spec.ts
new file mode 100644
index 000000000..a0655486e
--- /dev/null
+++ b/src-ui/src/app/pipes/markdown.pipe.spec.ts
@@ -0,0 +1,90 @@
+import { MarkdownPipe } from './markdown.pipe'
+
+describe('MarkdownPipe', () => {
+ const pipe = new MarkdownPipe()
+
+ it('should return empty string for empty input', () => {
+ expect(pipe.transform(null)).toEqual('')
+ expect(pipe.transform(undefined)).toEqual('')
+ expect(pipe.transform('')).toEqual('')
+ })
+
+ it('should render basic markdown', () => {
+ const html = pipe.transform(
+ '**bold** _em_ `code`\n\n- one\n- two\n\n| a | b |\n|---|---|\n| 1 | 2 |'
+ )
+ expect(html).toContain('
bold')
+ expect(html).toContain('
em')
+ expect(html).toContain('
code')
+ expect(html).toContain('
one')
+ expect(html).toContain('
')
+ })
+
+ it('should escape raw html', () => {
+ const html = pipe.transform(
+ 'hi
x\n\n'
+ )
+ expect(html).not.toContain('
')
+ expect(html).toContain('<script>')
+ })
+
+ it('should not render images', () => {
+ const html = pipe.transform(
+ ''
+ )
+ expect(html).not.toContain('
{
+ const html = pipe.transform('[docs](https://docs.paperless-ngx.com "Docs")')
+ expect(html).toContain(
+ 'docs'
+ )
+ expect(pipe.transform('[mail](mailto:a@b.c)')).toContain(
+ 'href="mailto:a@b.c"'
+ )
+ })
+
+ it('should drop unsafe or relative links but keep their text', () => {
+ for (const href of [
+ 'javascript:alert(1)',
+ 'JaVaScRiPt:alert(1)',
+ 'data:text/html,',
+ 'vbscript:msgbox',
+ '/api/documents/',
+ ]) {
+ const html = pipe.transform(`[click](${href})`)
+ expect(html).not.toContain(' {
+ for (const protocol of ['http', 'https']) {
+ const html = pipe.transform(
+ `[docs.paperless-ngx.com](${protocol}://docs.paperless-ngx.com@otherdomain.example/path?secret=1)`
+ )
+ expect(html).toContain(
+ ' (otherdomain.example)'
+ )
+ expect(html).not.toContain('(docs.paperless-ngx.com)')
+ }
+ })
+
+ it('should omit the hostname label for mailto links', () => {
+ expect(pipe.transform('[mail](mailto:a@b.c)')).toBe(
+ 'mail
\n'
+ )
+ })
+
+ it('should escape link titles', () => {
+ const html = pipe.transform(
+ '[x](https://a.example "a\\" onmouseover=\\"1")'
+ )
+ expect(html).not.toMatch(/"\s*onmouseover=/)
+ })
+})
diff --git a/src-ui/src/app/pipes/markdown.pipe.ts b/src-ui/src/app/pipes/markdown.pipe.ts
new file mode 100644
index 000000000..992714c14
--- /dev/null
+++ b/src-ui/src/app/pipes/markdown.pipe.ts
@@ -0,0 +1,52 @@
+import { Pipe, PipeTransform } from '@angular/core'
+import { Marked, Renderer, Tokens } from 'marked'
+
+const ALLOWED_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:'])
+
+function allowedUrl(href: string): URL | null {
+ try {
+ const url = new URL(href)
+ return ALLOWED_LINK_PROTOCOLS.has(url.protocol) ? url : null
+ } catch {
+ return null
+ }
+}
+
+// Treat chat content as untrusted: no raw HTML, no images, and only
+// absolute links. All escaping is done by marked's default renderer, and
+// Angular's sanitizer runs on top of this as well.
+const renderer: Partial = {
+ html(this: Renderer, { raw, text }: Tokens.HTML | Tokens.Tag): string {
+ return this.text({ type: 'text', raw, text })
+ },
+ image(this: Renderer, { raw, text }: Tokens.Image): string {
+ return this.text({ type: 'text', raw, text })
+ },
+ link(this: Renderer, token: Tokens.Link): string {
+ const url = allowedUrl(token.href)
+ if (!url) return this.parser.parseInline(token.tokens)
+ const anchor = Renderer.prototype.link
+ .call(this, token)
+ .replace('(${hostname})`
+ },
+}
+
+const markdown = new Marked({
+ async: false,
+ gfm: true,
+ breaks: true,
+ renderer,
+})
+
+@Pipe({
+ name: 'markdown',
+})
+export class MarkdownPipe implements PipeTransform {
+ transform(value: string | null | undefined): string {
+ if (!value) return ''
+ return markdown.parse(value) as string
+ }
+}