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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
198 lines · 6.1 KB Raw History
/**
 * The HTML a repository's markdown is allowed to keep.
 *
 * The tags belong to whoever wrote the repository and the result is put
 * straight into the document. Every test here asks one of two questions: does
 * the tag a README needs survive, and does everything else go.
 */

import { describe, expect, it } from 'vitest';
import {
  escapeAttribute,
  escapeText,
  sanitizeHtml,
  scanHtml,
} from '../src/lib/html.js';

/** Addresses as the markdown renderer supplies them. */
const ctx = {
  link: (href) =>
    href.startsWith('javascript:')
      ? null
      : { href, external: href.startsWith('https:') },
  image: (src) =>
    src.startsWith('javascript:') ? null : { 'data-repo-path': src },
};

const clean = (raw) => sanitizeHtml(raw, ctx);

describe('the tags a README needs', () => {
  it('keeps a row of sized images', () => {
    expect(
      clean('<p><img src="a.png" width="250" /><img src="b.png" /></p>'),
    ).toBe(
      '<p><img data-repo-path="a.png" width="250"><img data-repo-path="b.png"></p>',
    );
  });

  it('keeps a table with its spans and its alignment', () => {
    expect(
      clean('<table><tr><td colspan="2" align="CENTER">x</td></tr></table>'),
    ).toBe('<table><tr><td colspan="2" align="center">x</td></tr></table>');
  });

  it('keeps a collapsed section', () => {
    expect(clean('<details open><summary>why</summary>because</details>')).toBe(
      '<details open><summary>why</summary>because</details>',
    );
  });

  it('keeps a list that starts partway through', () => {
    expect(clean('<ol start="3" type="a"><li>x</li></ol>')).toBe(
      '<ol start="3" type="a"><li>x</li></ol>',
    );
  });

  it('writes no closing tag for a tag that takes none', () => {
    expect(clean('a<br>b<br/>c</br>')).toBe('a<br>b<br>c');
  });

  it('leaves an entity in the text as one character', () => {
    expect(clean('<p>a &amp; b &lt; c</p>')).toBe('<p>a &amp; b &lt; c</p>');
  });

  it('escapes an angle bracket that opens nothing', () => {
    expect(clean('<p>1 < 2 > 0</p>')).toBe('<p>1 &lt; 2 &gt; 0</p>');
  });
});

describe('the tags the page refuses', () => {
  it('drops a tag that is not on the list and keeps its words', () => {
    expect(clean('<form action="/x">go</form>')).toBe('go');
  });

  it('drops an opaque tag along with its text', () => {
    expect(clean('a<script>alert(1)</script>b')).toBe('ab');
    expect(clean('a<style>p{}</style>b')).toBe('ab');
    expect(clean('a<svg><circle onload="x"/></svg>b')).toBe('ab');
  });

  it('drops everything after an opaque tag that never closes', () => {
    expect(clean('a<script>alert(1)<p>b</p>')).toBe('a');
  });

  it('drops an attribute that is not on the list', () => {
    expect(clean('<div onmouseover="x" class="y" style="z">t</div>')).toBe(
      '<div>t</div>',
    );
  });

  it('drops an attribute whose value is not the shape it names', () => {
    expect(clean('<img src="a.png" width="250px" height="1e3">')).toBe(
      '<img data-repo-path="a.png">',
    );
    expect(clean('<td align="url(x)">t</td>')).toBe('<td>t</td>');
  });

  it('drops a comment and a doctype', () => {
    expect(clean('a<!-- <img src=x> -->b<!DOCTYPE html>c')).toBe('abc');
  });

  it('refuses an address the context refuses', () => {
    expect(clean('<a href="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
    expect(clean('<img src="javascript:alert(1)" alt="x">')).toBe(
      '<img alt="x">',
    );
  });

  it('sends an outside link away from this page safely', () => {
    expect(clean('<a href="https://example.com">x</a>')).toBe(
      '<a href="https://example.com" target="_blank" rel="noopener noreferrer">x</a>',
    );
  });

  it('reads an entity in an address before it judges the address', () => {
    expect(clean('<a href="&#106;avascript:alert(1)">x</a>')).toBe('<a>x</a>');
    expect(clean('<a href="javascript&colon;alert(1)">x</a>')).toBe('<a>x</a>');
  });

  it('escapes a quote in a value rather than ending the attribute', () => {
    expect(clean('<img src="a.png" alt=\'" onerror="alert(1)\'>')).toBe(
      '<img data-repo-path="a.png" alt="&quot; onerror=&quot;alert(1)">',
    );
  });

  it('opens nothing from a tag that never ends', () => {
    // The bracket that would end it is missing, so the attributes run to the
    // end of the fragment. What comes out is still one tag this file wrote.
    expect(clean('<img src=a.png onerror=alert(1)')).toBe(
      '<img data-repo-path="a.png">',
    );
  });

  it('keeps the first of two attributes of one name', () => {
    expect(clean('<td align="left" align="right">t</td>')).toBe(
      '<td align="left">t</td>',
    );
  });
});

describe('scanHtml', () => {
  it('reads a tag, its attributes and the text around it', () => {
    expect(scanHtml('a<img src="x.png" hidden>b')).toEqual([
      { type: 'text', text: 'a' },
      {
        type: 'tag',
        name: 'img',
        closing: false,
        selfClosing: false,
        attributes: new Map([
          ['src', 'x.png'],
          ['hidden', ''],
        ]),
      },
      { type: 'text', text: 'b' },
    ]);
  });

  it('reads a value with no quotes around it', () => {
    const [tag] = scanHtml('<img src=x.png width=250>');
    expect(tag.attributes).toEqual(
      new Map([
        ['src', 'x.png'],
        ['width', '250'],
      ]),
    );
  });

  it('reads a closing tag', () => {
    expect(scanHtml('</DIV>')[0]).toMatchObject({
      name: 'div',
      closing: true,
    });
  });

  it('reads a tag that closes itself', () => {
    expect(scanHtml('<br />')[0]).toMatchObject({
      name: 'br',
      selfClosing: true,
    });
  });

  it('reads an unterminated quote to the end of the fragment', () => {
    const [tag] = scanHtml('<img alt="never ends');
    expect(tag.attributes.get('alt')).toBe('never ends');
  });
});

describe('escaping', () => {
  it('escapes every character an attribute could break on', () => {
    expect(escapeAttribute('a&<>"\'b')).toBe('a&amp;&lt;&gt;&quot;&#39;b');
  });

  it('leaves an entity in text alone and escapes a bare ampersand', () => {
    expect(escapeText('&amp; & &#39; &notreal')).toBe(
      '&amp; &amp; &#39; &amp;notreal',
    );
  });
});