chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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('<h1');
expect(html).toContain('<code');
expect(html).toContain('https://a.example/');
});
it('keeps every guarantee it keeps for a README', () => {
const html = renderMarkdown(
['<img src=x onerror=alert(1)>', '', '[bad](javascript:alert(1))'].join(
'\n',
),
);
expect(html).not.toContain('<img src=x');
expect(html).not.toContain('onerror');
expect(html).not.toContain('javascript:');
// No tree, so the relative source resolves to nothing and is dropped
// rather than written as a path that finds nothing.
expect(html).not.toContain('data-repo-path');
});
it('leaves a relative address alone, having no tree to find it in', () => {
// 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('<a href');
expect(link).toContain('a file');
const image = renderMarkdown('');
expect(image).not.toContain('undefined');
});
});
describe('renderMarkdown', () => {
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('<h1>Title</h1>');
expect(html).toContain('<em>emphasis</em>');
expect(html).toContain('<strong>weight</strong>');
expect(html).toContain('<li>one</li>');
});
it('renders the GitHub flavoured additions', () => {
const table = ['| a | b |', '| - | - |', '| 1 | 2 |'].join('\n');
expect(render(table)).toContain('<table>');
expect(render('~~gone~~')).toContain('<del>gone</del>');
});
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(['```', '<script>x</script>', '```'].join('\n'));
expect(html).not.toContain('<script>');
expect(html).toContain('<script>');
});
});
describe('the tags a document writes itself', () => {
it('lays out a row of screenshots the way the document asked', () => {
const html = render(
[
'<p float="left">',
'<img src="Screenshots/feed.png" width="250" />',
'<img src="Screenshots/notifications.png" width="250" />',
'</p>',
].join('\n'),
);
expect(html).toContain('<p>');
expect(html).toContain(
'<img data-repo-path="Screenshots/feed.png" width="250">',
);
expect(html).toContain(
'<img data-repo-path="Screenshots/notifications.png" width="250">',
);
});
it('centres a banner and keeps the link under it', () => {
const html = render(
'<div align="center">\n<a href="https://example.com">home</a>\n</div>',
);
expect(html).toContain('<div align="center">');
expect(html).toContain('<a href="https://example.com" target="_blank"');
expect(html).toContain('</div>');
});
it('opens a collapsed section', () => {
const html = render(
'<details open><summary>More</summary>\n\nbody\n\n</details>',
);
expect(html).toContain('<details open>');
expect(html).toContain('<summary>More</summary>');
expect(html).toContain('<p>body</p>');
});
it('keeps the fallback image of a picture it cannot serve', () => {
const html = render(
'<picture><source srcset="dark.png"><img src="light.png" alt="logo"></picture>',
);
expect(html).toContain('<picture>');
expect(html).not.toContain('srcset');
expect(html).toContain('<img data-repo-path="light.png" alt="logo">');
});
it('points a relative link in a written tag at the file in this app', () => {
const html = render('<p><a href="docs/guide.md">guide</a></p>');
expect(html).toContain('href="/demo/blob/main/docs/guide.md"');
});
it('resolves a written image against the document it sits in', () => {
const html = render('<img src="../logo.png">', { basePath: 'docs/deep' });
expect(html).toContain('data-repo-path="docs/logo.png"');
});
});
describe('raw HTML the page refuses', () => {
it('drops a script tag along with its words', () => {
const html = render('<script>alert(1)</script>');
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 <img src=x onerror=alert(1)> more');
expect(html).not.toContain('onerror');
expect(html).toContain('<img data-repo-path="x">');
});
it('drops an iframe rather than embedding it', () => {
const html = render('<iframe src="https://evil.example"></iframe>');
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('<div onmouseover="alert(1)">hover</div>');
expect(html).toContain('<div>hover</div>');
expect(html).not.toContain('onmouseover');
});
it('opens no tag beyond the ones the allowlist names', () => {
const html = render(
[
'<svg onload="alert(1)"></svg>',
'',
'<a href="javascript:alert(1)">x</a>',
'',
'<form action="/x"><button>go</button></form>',
].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 <code>x <img src=y onerror=alert(1) more</code>');
expect(html).not.toMatch(/<img/);
expect(html).toContain('<img src=y onerror=alert(1) more');
});
});
describe('link and image addresses', () => {
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('<a ');
});
it('refuses a javascript: link however it is cased or spaced', () => {
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('');
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('');
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');
});
});