~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
285 lines · 9.7 KB Raw History
/**
 * 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('![a](./diagram.png)');
    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('&lt;script&gt;');
  });
});

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('&lt;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('![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');
  });
});