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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
432 lines · 11.1 KB Raw History
// The HTML a repository's markdown is allowed to keep.
//
// A README writes tags of its own for what markdown has no syntax for: a row
// of screenshots, a centred banner, a collapsed section. That text belongs to
// whoever wrote the repository, so none of it may reach the page as markup of
// its own choosing.
//
// This module reads the tags and writes them again. A tag in the result
// carries a name from the list below, attributes from the list below, and
// values this file escaped. Every other tag is dropped, and for a few of them
// the text between the tags is dropped as well. Rewriting is what makes the
// result small enough to read: a mistake in the reader can lose a tag, but it
// cannot invent one.

/** Tags a document may open, each with the attributes it may carry. */
const TAGS = {
  a: ['href'],
  abbr: [],
  b: [],
  big: [],
  blockquote: [],
  br: [],
  caption: [],
  center: [],
  cite: [],
  code: [],
  col: ['span', 'width'],
  colgroup: ['span'],
  dd: [],
  del: [],
  details: ['open'],
  dfn: [],
  div: [],
  dl: [],
  dt: [],
  em: [],
  figcaption: [],
  figure: [],
  h1: [],
  h2: [],
  h3: [],
  h4: [],
  h5: [],
  h6: [],
  hr: [],
  i: [],
  img: ['src', 'alt', 'width', 'height'],
  ins: [],
  kbd: [],
  li: [],
  mark: [],
  ol: ['start', 'type'],
  p: [],
  picture: [],
  pre: [],
  q: [],
  s: [],
  samp: [],
  small: [],
  span: [],
  strike: [],
  strong: [],
  sub: [],
  summary: [],
  sup: [],
  table: [],
  tbody: [],
  td: ['colspan', 'rowspan', 'valign'],
  tfoot: [],
  th: ['colspan', 'rowspan', 'valign'],
  thead: [],
  time: [],
  tr: [],
  tt: [],
  u: [],
  ul: [],
  var: [],
  wbr: [],
};

/** Attributes any tag on the list may carry. */
const GLOBAL = ['align', 'title'];

/** Tags that take no closing tag. */
const VOID = new Set(['br', 'col', 'hr', 'img', 'wbr']);

/** Attributes whose presence is the whole value. */
const BOOLEAN = new Set(['open']);

/**
 * Tags whose text is dropped along with the tag. A `<script>` reads as words
 * if only its tags go, and those words are not what the page is for.
 */
const OPAQUE = new Set([
  'embed',
  'iframe',
  'math',
  'noembed',
  'noframes',
  'noscript',
  'object',
  'script',
  'style',
  'svg',
  'template',
  'textarea',
  'title',
  'xmp',
]);

const ALIGNMENTS = new Set(['left', 'right', 'center', 'justify']);
const VERTICAL = new Set(['top', 'middle', 'bottom', 'baseline']);

/** A count of rows, columns or list steps. */
const count = (/** @type {string} */ value) =>
  /^\d{1,4}$/.test(value) ? value : null;

/** A length in pixels or in percent of the line. */
const size = (/** @type {string} */ value) =>
  /^\d{1,5}%?$/.test(value) ? value : null;

/** @type {Record<string, (value: string) => string | null>} */
const VALUES = {
  align: (value) =>
    ALIGNMENTS.has(value.toLowerCase()) ? value.toLowerCase() : null,
  valign: (value) =>
    VERTICAL.has(value.toLowerCase()) ? value.toLowerCase() : null,
  colspan: count,
  rowspan: count,
  span: count,
  start: count,
  width: size,
  height: size,
  type: (value) => (/^[1aAiI]$/.test(value) ? value : null),
};

const ESCAPES = {
  '&': '&amp;',
  '<': '&lt;',
  '>': '&gt;',
  '"': '&quot;',
  "'": '&#39;',
};

/**
 * Escape a value on its way into an attribute.
 * @param {string} value
 * @returns {string}
 */
export function escapeAttribute(value) {
  return String(value).replace(
    /[&<>"']/g,
    (character) => ESCAPES[/** @type {keyof typeof ESCAPES} */ (character)],
  );
}

/**
 * Escape text on its way into the document. An entity the text already
 * carries is left alone, so `&amp;` reads as one ampersand rather than as
 * five characters. A decoded entity in a text node is a character and never
 * a tag.
 * @param {string} text
 * @returns {string}
 */
export function escapeText(text) {
  return String(text).replace(
    /[<>"']|&(?!(?:#\d{1,7}|#[Xx][0-9A-Fa-f]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});)/g,
    (character) => ESCAPES[/** @type {keyof typeof ESCAPES} */ (character)],
  );
}

/**
 * Entities an attribute value may carry. The browser decodes these before it
 * reads an address, so this file decodes them before it judges one. `colon`
 * and `Tab` are here because they are the ones that hide a scheme.
 */
const ENTITIES = {
  amp: '&',
  lt: '<',
  gt: '>',
  quot: '"',
  apos: "'",
  nbsp: ' ',
  colon: ':',
  sol: '/',
  num: '#',
  semi: ';',
  lpar: '(',
  rpar: ')',
  period: '.',
  comma: ',',
  Tab: '\t',
  NewLine: '\n',
};

/**
 * Read an attribute value the way the browser reads one.
 * @param {string} value
 * @returns {string}
 */
function decodeEntities(value) {
  return value.replace(
    /&(#\d{1,7}|#[Xx][0-9A-Fa-f]{1,6}|[A-Za-z][A-Za-z0-9]{1,31});?/g,
    (whole, body) => {
      if (body[0] === '#') {
        const code =
          body[1] === 'x' || body[1] === 'X'
            ? Number.parseInt(body.slice(2), 16)
            : Number.parseInt(body.slice(1), 10);
        return code > 0 && code <= 0x10ffff
          ? String.fromCodePoint(code)
          : whole;
      }
      return ENTITIES[/** @type {keyof typeof ENTITIES} */ (body)] ?? whole;
    },
  );
}

/**
 * @typedef {Object} HtmlTag
 * @property {'tag'} type
 * @property {string} name - lower case
 * @property {boolean} closing
 * @property {boolean} selfClosing
 * @property {Map<string, string>} attributes - lower case names, decoded values
 */

/**
 * @typedef {Object} HtmlText
 * @property {'text'} type
 * @property {string} text
 */

/**
 * Read a fragment of HTML into its tags and the text between them. A comment,
 * a doctype and a processing instruction each read as nothing at all.
 * @param {string} raw
 * @returns {Array<HtmlTag | HtmlText>}
 */
export function scanHtml(raw) {
  /** @type {Array<HtmlTag | HtmlText>} */
  const tokens = [];
  /** @param {string} text */
  const addText = (text) => {
    if (text !== '') tokens.push({ type: 'text', text });
  };

  let at = 0;
  while (at < raw.length) {
    const angle = raw.indexOf('<', at);
    if (angle === -1) {
      addText(raw.slice(at));
      break;
    }
    addText(raw.slice(at, angle));

    if (raw.startsWith('<!--', angle)) {
      const end = raw.indexOf('-->', angle + 4);
      at = end === -1 ? raw.length : end + 3;
      continue;
    }
    if (raw[angle + 1] === '!' || raw[angle + 1] === '?') {
      const end = raw.indexOf('>', angle + 1);
      at = end === -1 ? raw.length : end + 1;
      continue;
    }

    const opening = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)/.exec(raw.slice(angle));
    if (!opening) {
      // A bare angle bracket. It is text, and the caller escapes it.
      addText('<');
      at = angle + 1;
      continue;
    }
    const read = readAttributes(raw, angle + opening[0].length);
    tokens.push({
      type: 'tag',
      name: opening[2].toLowerCase(),
      closing: opening[1] === '/',
      selfClosing: read.selfClosing,
      attributes: read.attributes,
    });
    at = read.end;
  }
  return tokens;
}

const SPACE = /\s/;
const NAME_END = /[\s/>=]/;

/**
 * Read the attributes of one tag, from just after its name to just after the
 * bracket that ends it.
 * @param {string} raw
 * @param {number} start
 * @returns {{ attributes: Map<string, string>, end: number, selfClosing: boolean }}
 */
function readAttributes(raw, start) {
  /** @type {Map<string, string>} */
  const attributes = new Map();
  let at = start;
  let selfClosing = false;

  while (at < raw.length) {
    while (at < raw.length && SPACE.test(raw[at])) at++;
    if (at >= raw.length) break;
    if (raw[at] === '>') {
      at++;
      break;
    }
    if (raw[at] === '/') {
      selfClosing = raw[at + 1] === '>';
      at++;
      continue;
    }

    const nameStart = at;
    while (at < raw.length && !NAME_END.test(raw[at])) at++;
    if (at === nameStart) {
      // An `=` with no name before it. One step keeps the loop moving.
      at++;
      continue;
    }
    const name = raw.slice(nameStart, at).toLowerCase();

    while (at < raw.length && SPACE.test(raw[at])) at++;
    let value = '';
    if (raw[at] === '=') {
      at++;
      while (at < raw.length && SPACE.test(raw[at])) at++;
      const quote = raw[at];
      if (quote === '"' || quote === "'") {
        const end = raw.indexOf(quote, at + 1);
        value = end === -1 ? raw.slice(at + 1) : raw.slice(at + 1, end);
        at = end === -1 ? raw.length : end + 1;
      } else {
        const valueStart = at;
        while (at < raw.length && !/[\s>]/.test(raw[at])) at++;
        value = raw.slice(valueStart, at);
      }
    }
    if (!attributes.has(name)) attributes.set(name, decodeEntities(value));
  }
  return { attributes, end: at, selfClosing };
}

/**
 * @typedef {Object} HtmlContext
 * @property {(href: string) => { href: string, external: boolean } | null} link
 *   - the address a link may point at, or null to drop the attribute
 * @property {(src: string) => Record<string, string> | null} image - the
 *   attributes that fetch an image, or null to drop the source
 */

/**
 * Write the attributes of one tag.
 * @param {string} tag
 * @param {Map<string, string>} attributes
 * @param {HtmlContext} ctx
 * @returns {string}
 */
function renderAttributes(tag, attributes, ctx) {
  const allowed = /** @type {readonly string[]} */ (
    TAGS[/** @type {keyof typeof TAGS} */ (tag)]
  );
  let out = '';
  for (const [name, value] of attributes) {
    if (tag === 'a' && name === 'href') {
      const link = ctx.link(value);
      if (!link) continue;
      out += ` href="${escapeAttribute(link.href)}"`;
      // An address outside the repository opens away from this page and is
      // told not to carry it along.
      if (link.external) out += ' target="_blank" rel="noopener noreferrer"';
      continue;
    }
    if (tag === 'img' && name === 'src') {
      const image = ctx.image(value);
      if (!image) continue;
      for (const [key, address] of Object.entries(image)) {
        out += ` ${key}="${escapeAttribute(address)}"`;
      }
      continue;
    }
    if (!allowed.includes(name) && !GLOBAL.includes(name)) continue;
    if (BOOLEAN.has(name)) {
      out += ` ${name}`;
      continue;
    }
    const clean = (VALUES[name] ?? ((given) => given))(value);
    if (clean === null) continue;
    out += ` ${name}="${escapeAttribute(clean)}"`;
  }
  return out;
}

/**
 * Rewrite a fragment of a document's own HTML as the tags this page allows.
 * @param {string} raw
 * @param {HtmlContext} ctx
 * @returns {string} HTML
 */
export function sanitizeHtml(raw, ctx) {
  let out = '';
  /** The opaque tag whose text is being dropped, if there is one. */
  let opaque = '';

  for (const token of scanHtml(raw)) {
    if (opaque) {
      if (token.type === 'tag' && token.closing && token.name === opaque) {
        opaque = '';
      }
      continue;
    }
    if (token.type === 'text') {
      out += escapeText(token.text);
      continue;
    }
    const { name, closing, selfClosing, attributes } = token;
    if (OPAQUE.has(name)) {
      if (!closing && !selfClosing) opaque = name;
      continue;
    }
    if (!Object.hasOwn(TAGS, name)) continue;
    if (closing) {
      if (!VOID.has(name)) out += `</${name}>`;
      continue;
    }
    out += `<${name}${renderAttributes(name, attributes, ctx)}>`;
  }
  return out;
}