From 82cfe868b53f72e81c95403a678d6a35abc373a9 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:43:26 -0700 Subject: [PATCH] markdown pipe --- src-ui/src/app/pipes/markdown.pipe.spec.ts | 70 ++++++++++++++++++++++ src-ui/src/app/pipes/markdown.pipe.ts | 57 ++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src-ui/src/app/pipes/markdown.pipe.spec.ts create mode 100644 src-ui/src/app/pipes/markdown.pipe.ts 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..4dbfc719e --- /dev/null +++ b/src-ui/src/app/pipes/markdown.pipe.spec.ts @@ -0,0 +1,70 @@ +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('![secret](https://evil.example/x.png?d=1)') + 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(' { + 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..492cce77b --- /dev/null +++ b/src-ui/src/app/pipes/markdown.pipe.ts @@ -0,0 +1,57 @@ +import { Pipe, PipeTransform } from '@angular/core' +import { Marked, Renderer, Tokens } from 'marked' + +const ALLOWED_LINK_PROTOCOLS = ['http:', 'https:', 'mailto:'] + +function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +function safeHref(href: string): string | null { + try { + const url = new URL(href) + return ALLOWED_LINK_PROTOCOLS.includes(url.protocol) ? url.href : null + } catch { + return null + } +} + +// Treat chat content as untrusted: no raw HTML, no images, and only +// absolute links. Angular sanitizer runs on top of this as well. +const renderer: Partial = { + html({ text }: Tokens.HTML | Tokens.Tag): string { + return escapeHtml(text) + }, + image({ text }: Tokens.Image): string { + return escapeHtml(text) + }, + link(this: Renderer, { href, title, tokens }: Tokens.Link): string { + const text = this.parser.parseInline(tokens) + const url = safeHref(href) + if (!url) return text + const titleAttr = title ? ` title="${escapeHtml(title)}"` : '' + return `${text}` + }, +} + +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 + } +}