chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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 & b < c</p>')).toBe('<p>a & b < c</p>');
});
it('escapes an angle bracket that opens nothing', () => {
expect(clean('<p>1 < 2 > 0</p>')).toBe('<p>1 < 2 > 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="javascript:alert(1)">x</a>')).toBe('<a>x</a>');
expect(clean('<a href="javascript: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="" onerror="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&<>"'b');
});
it('leaves an entity in text alone and escapes a bare ampersand', () => {
expect(escapeText('& & ' ¬real')).toBe(
'& & ' &notreal',
);
});
});