/** * Rendering a repository's markdown into this page. * * The text belongs to whoever wrote the repository and the result is put * straight into the document, so most of what follows is about what must not * come out the other side. */ import { describe, expect, it } from 'vitest'; import { isSafeHref, renderMarkdown, resolveRepoPath, } from '../src/lib/markdown.js'; const render = (text, ctx = {}) => renderMarkdown(text, { repo: 'demo', refName: 'main', ...ctx }); describe('renderMarkdown, with no repository to resolve against', () => { // Prose about the work rather than in it: a pull request's description, a // review, an issue. It names no tree, so it is rendered with no context at // all, and asking for one threw. it('renders without a context', () => { const html = renderMarkdown( '# Title\n\nSome `code` and a [link](https://a.example/).', ); expect(html).toContain(' { const html = renderMarkdown( ['', '', '[bad](javascript:alert(1))'].join( '\n', ), ); expect(html).not.toContain(' { // A link would otherwise point at /undefined/blob/undefined/... const link = renderMarkdown('[a file](./count.js)'); expect(link).not.toContain('undefined'); expect(link).not.toContain(' { it('renders the ordinary things a README is made of', () => { const html = render( [ '# Title', '', 'Some *emphasis* and **weight**.', '', '- one', '- two', ].join('\n'), ); expect(html).toContain('

Title

'); expect(html).toContain('emphasis'); expect(html).toContain('weight'); expect(html).toContain('
  • one
  • '); }); it('renders the GitHub flavoured additions', () => { const table = ['| a | b |', '| - | - |', '| 1 | 2 |'].join('\n'); expect(render(table)).toContain(''); expect(render('~~gone~~')).toContain('gone'); }); it('colours a fenced code block the way a file is coloured', () => { const html = render(['```js', 'const a = 1;', '```'].join('\n')); expect(html).toContain('md-code'); expect(html).toContain('hljs-keyword'); }); it('leaves a fence with no language uncoloured but escaped', () => { const html = render(['```', '', '```'].join('\n')); expect(html).not.toContain(''); expect(html).not.toContain('script'); expect(html).not.toContain('alert(1)'); }); it('keeps an image and drops the handler on it', () => { const html = render('text more'); expect(html).not.toContain('onerror'); expect(html).toContain(''); }); it('drops an iframe rather than embedding it', () => { const html = render(''); expect(html).not.toContain('iframe'); expect(html).not.toContain('evil.example'); }); it('keeps a block element and drops the handler on it', () => { const html = render('
    hover
    '); expect(html).toContain('
    hover
    '); expect(html).not.toContain('onmouseover'); }); it('opens no tag beyond the ones the allowlist names', () => { const html = render( [ '', '', 'x', '', '', ].join('\n'), ); const tags = [...html.matchAll(/<([a-z][a-z0-9]*)/gi)].map((m) => m[1]); expect(new Set(tags)).toEqual(new Set(['p', 'a'])); expect(html).not.toContain('javascript:'); }); it('escapes the text between an inline code tag and its close', () => { // marked marks that text escaped and prints it as it stands, so an // unterminated tag in it would otherwise open a tag of its own. const html = render('hi x '); expect(html).not.toMatch(/ { it('refuses a javascript: link, keeping only its words', () => { const html = render('[click](javascript:alert(1))'); expect(html).not.toContain('javascript:'); expect(html).toContain('click'); expect(html).not.toContain(' { for (const href of [ 'JavaScript:alert(1)', ' javascript:alert(1)', 'java\tscript:alert(1)', 'java\nscript:alert(1)', ]) { expect(isSafeHref(href), href).toBe(false); } }); it('refuses a data: image', () => { // A data: URL can carry SVG, and SVG can carry script. const html = render('![x](data:image/svg+xml;base64,PHN2Zz48L3N2Zz4=)'); expect(html).not.toContain('data:'); }); it('allows the schemes a document actually needs', () => { for (const href of [ 'https://example.com', 'http://example.com', 'mailto:someone@example.com', '#anchor', 'docs/guide.md', '../up.md', '/rooted.md', ]) { expect(isSafeHref(href), href).toBe(true); } }); it('sends an outside link away from this page safely', () => { const html = render('[docs](https://example.com)'); expect(html).toContain('target="_blank"'); expect(html).toContain('rel="noopener noreferrer"'); }); it('points a repository-relative link at the file in this app', () => { const html = render('[guide](docs/guide.md)'); expect(html).toContain('href="/demo/blob/main/docs/guide.md"'); expect(html).not.toContain('target="_blank"'); }); it('resolves a relative link against the document it sits in', () => { const html = render('[up](../README.md)', { basePath: 'docs/deep' }); expect(html).toContain('href="/demo/blob/main/docs/README.md"'); }); it('marks a repository image for the view to fill in', () => { const html = render('![logo](docs/logo.png)'); expect(html).toContain('data-repo-path="docs/logo.png"'); expect(html).not.toContain('src='); }); it('escapes a title so it cannot break out of the attribute', () => { const html = render( '[x](https://example.com "a\\" onmouseover=\\"alert(1)")', ); expect(html).not.toMatch(/onmouseover="/); }); }); describe('resolveRepoPath', () => { it('joins against the document directory', () => { expect(resolveRepoPath('docs', 'img.png')).toBe('docs/img.png'); expect(resolveRepoPath('', 'img.png')).toBe('img.png'); }); it('walks up and stops at the root', () => { expect(resolveRepoPath('docs/deep', '../img.png')).toBe('docs/img.png'); expect(resolveRepoPath('docs', '../../../img.png')).toBe('img.png'); }); it('ignores the steps that mean nothing', () => { expect(resolveRepoPath('docs', './a//b.png')).toBe('docs/a/b.png'); }); });