OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / markdown-render.test.js

241 lines · javascript ref:
/**
 * Markdown rendering across the SSR pages.
 *
 * Pins the two defects that took the docs + file viewers out:
 *
 *  1. INLINE-SCRIPT INJECTION. Every markdown surface shipped its source
 *     as `var src = ${JSON.stringify(content)}` inside an inline
 *     <script>. JSON.stringify does not escape `</script`, so any file
 *     containing that sequence closed the element early: SyntaxError,
 *     blank viewer (37 of 4188 markdown files in the served repos), and
 *     an XSS vector — everything after the terminator was parsed as HTML
 *     in the viewer's origin. Source now travels as base64 in a data
 *     attribute, whose alphabet cannot close a tag or an attribute.
 *
 *  2. TEMPLATE-LITERAL ESCAPE ROT. The Docs tab's inline script held a
 *     regex written `/…|\/\/|#/`. Inside a template literal `\/` reduces
 *     to `/`, so the emitted regex was invalid, the whole block was a
 *     SyntaxError, and the docs viewer stayed empty for EVERY repo. The
 *     renderer now lives in a real .js file — there is no template layer
 *     left to escape through.
 *
 * The client-side renderer's URL resolution is exercised directly by
 * evaluating static/pgv3-markdown.js's `resolve` semantics, so the
 * link-rewriting rules are pinned without needing a browser.
 */

import test from 'brittle'
import { readFileSync } from '#node:fs'
import { fileURLToPath } from '#node:url'
import { dirname, join } from '#node:path'
import b4a from 'b4a'

import { markdownBody, dirOf } from '../lib/web/helpers.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const STATIC_DIR = join(__dirname, '..', 'lib', 'web', 'static')

// The exact sequence that used to break every markdown viewer.
const HOSTILE = '# Title\n\nExample:\n\n```html\n<script>alert(1)</script>\n```\n'

function b64Of (html) {
  const m = html.match(/data-md-src-b64="([^"]*)"/)
  return m ? m[1] : null
}

function decode (b64) {
  return b4a.toString(b4a.from(b64, 'base64'), 'utf8')
}

// ── 1. injection ──────────────────────────────────────────────

test('markdownBody never emits a script-closing sequence', (t) => {
  const html = markdownBody(HOSTILE)
  t.absent(html.includes('</script'), 'no script terminator survives into the HTML')
  t.absent(html.includes('<script'), 'the source is not wrapped in a script element')
  t.absent(html.includes('alert(1)'), 'raw source does not appear as HTML text')
})

test('markdownBody round-trips the source byte-for-byte', (t) => {
  for (const src of [HOSTILE, '# hi', 'a "quote" & <angle>', 'ünïcødé — em dash', '']) {
    const b64 = b64Of(markdownBody(src))
    t.is(decode(b64), src, 'round-trip: ' + JSON.stringify(src.slice(0, 24)))
  }
})

test('base64 payload cannot break out of the attribute', (t) => {
  // Base64's alphabet is [A-Za-z0-9+/=] — no quote, no angle bracket. If
  // that ever stops holding, the attribute is injectable again.
  const b64 = b64Of(markdownBody(HOSTILE + '"><img onerror=alert(1) src=x>'))
  t.ok(/^[A-Za-z0-9+/=]*$/.test(b64), 'payload is pure base64, got: ' + b64.slice(0, 40))
})

test('no SSR page inlines markdown source into a script element', (t) => {
  // The regression guard for the whole defect class: a page that goes
  // back to `var src = ${JSON.stringify(...)}` reintroduces it.
  const pages = ['repo-browser.js', 'repo-docs.js', 'docs.js', 'orgs.js']
  for (const name of pages) {
    const src = readFileSync(join(__dirname, '..', 'lib', 'web', 'pages', name), 'utf8')
    t.absent(/var\s+src\s*=\s*\$\{JSON\.stringify/.test(src),
      name + ' does not inline a markdown source as JSON in a script')
    t.absent(/var\s+DOCS\s*=\s*\$\{JSON\.stringify/.test(src),
      name + ' does not inline a markdown map as JSON in a script')
  }
})

// ── 2. escape rot ─────────────────────────────────────────────

test('the client renderers are real .js files, not template literals', (t) => {
  for (const name of ['pgv3-markdown.js', 'pgv3-docs-switch.js']) {
    const src = readFileSync(join(STATIC_DIR, name), 'utf8')
    t.ok(src.length > 0, name + ' exists')
    // A regex escaped for a template literal (\\/) is the signature of
    // code that was moved here from a page template without unescaping.
    t.absent(src.includes('\\\\/'), name + ' has no template-escaped regex')
  }
})

test('pgv3-markdown.js parses as JavaScript', (t) => {
  // The Docs tab's bug WAS a parse error in emitted script text. Compile
  // both client scripts so a syntax error can never ship silently again.
  for (const name of ['pgv3-markdown.js', 'pgv3-docs-switch.js']) {
    const src = readFileSync(join(STATIC_DIR, name), 'utf8')
    t.execution(() => new Function(src), name + ' compiles') // eslint-disable-line no-new-func
  }
})

test('every regex literal in the client renderer is valid', (t) => {
  const src = readFileSync(join(STATIC_DIR, 'pgv3-markdown.js'), 'utf8')
  // The specific shape that broke: an alternation containing an escaped
  // slash. If the backslashes were lost it would not compile.
  t.ok(src.includes('\\/\\/'), 'the scheme-relative alternation is single-escaped')
  t.execution(() => new RegExp('^([a-z][a-z0-9+.-]*:|\\/\\/|#)', 'i'),
    'the emitted absolute-URL regex is constructible')
})

// ── attribute wiring ─────────────────────────────────────────

test('markdownBody emits only the bases it was given', (t) => {
  const bare = markdownBody('x')
  t.absent(bare.includes('data-md-link-base'), 'no link base when none passed')
  t.absent(bare.includes('data-md-sibling-base'), 'no sibling base when none passed')

  const full = markdownBody('x', {
    id: 'md-preview',
    dir: 'docs',
    linkBase: '/git/repos/r/blob/',
    imgBase: '/git/repos/r/raw/',
    siblingBase: '/git/repos/',
    stripExt: 'md,markdown'
  })
  t.ok(full.includes('id="md-preview"'))
  t.ok(full.includes('data-md-dir="docs"'))
  t.ok(full.includes('data-md-link-base="/git/repos/r/blob/"'))
  t.ok(full.includes('data-md-img-base="/git/repos/r/raw/"'))
  t.ok(full.includes('data-md-sibling-base="/git/repos/"'))
  t.ok(full.includes('data-md-strip-ext="md,markdown"'))
  t.ok(full.includes('class="markdown-body"'))
})

test('markdownBody escapes attribute values', (t) => {
  const html = markdownBody('x', { id: 'a"><b', linkBase: '/x"y' })
  t.absent(html.includes('"><b'), 'id is escaped')
  t.absent(html.includes('/x"y'), 'base is escaped')
})

test('dirOf gives the directory a relative link resolves against', (t) => {
  t.is(dirOf('README.md'), '', 'root file has no directory')
  t.is(dirOf('docs/overview.md'), 'docs')
  t.is(dirOf('docs/plans/a/b.md'), 'docs/plans/a')
})

// ── link resolution semantics ────────────────────────────────
//
// Load the real `resolve` out of the static file so these assertions
// track the shipped implementation rather than a copy.

function loadResolve () {
  const src = readFileSync(join(STATIC_DIR, 'pgv3-markdown.js'), 'utf8')
  const start = src.indexOf('function isAbsolute')
  const end = src.indexOf('  /**\n   * marked v5+')
  const body = src.slice(start, end)
  // eslint-disable-next-line no-new-func
  return new Function(body + '\nreturn resolve')()
}

const BLOB = '/git/repos/r/blob/'
const RAW = '/git/repos/r/raw/'
const REPOS = '/git/repos/'

test('relative links resolve against the source file directory', (t) => {
  const resolve = loadResolve()
  // A link in docs/a.md to b.md must land on docs/b.md, not on the root.
  t.is(resolve('b.md', 'docs', BLOB, REPOS, false), BLOB + 'docs/b.md')
  t.is(resolve('./b.md', 'docs', BLOB, REPOS, false), BLOB + 'docs/b.md')
  t.is(resolve('sub/b.md', 'docs', BLOB, REPOS, false), BLOB + 'docs/sub/b.md')
  // '..' inside the repo stays inside the repo.
  t.is(resolve('../b.md', 'docs', BLOB, REPOS, false), BLOB + 'b.md')
  t.is(resolve('b.md', '', BLOB, REPOS, false), BLOB + 'b.md')
})

test('absolute and scheme-relative hrefs pass through untouched', (t) => {
  const resolve = loadResolve()
  for (const url of ['/abs', 'https://x/y', '//cdn/x', '#frag', 'mailto:a@b']) {
    t.is(resolve(url, 'docs', BLOB, REPOS, false), url, url)
  }
})

test('non-string hrefs never throw', (t) => {
  const resolve = loadResolve()
  for (const v of [null, undefined, 0, {}, []]) {
    t.execution(() => resolve(v, '', BLOB, REPOS, false))
  }
})

test('climbing above the repo root means a SIBLING repo', (t) => {
  const resolve = loadResolve()
  t.is(resolve('../other', '', BLOB, REPOS, false), REPOS + 'other')
  t.is(resolve('../other/docs/x.md', '', BLOB, REPOS, false),
    REPOS + 'other/blob/docs/x.md')
  // An image in a sibling repo has to point at the byte route.
  t.is(resolve('../other/logo.png', '', BLOB, REPOS, true),
    REPOS + 'other/raw/logo.png')
  // With no sibling base configured the link is left alone rather than
  // silently rewritten to a wrong in-repo path.
  t.is(resolve('../other', '', BLOB, '', false), '../other')
})

test('fragments and queries survive rewriting', (t) => {
  const resolve = loadResolve()
  t.is(resolve('b.md#heading', 'docs', BLOB, REPOS, false), BLOB + 'docs/b.md#heading')
  t.is(resolve('b.md?ref=dev', 'docs', BLOB, REPOS, false), BLOB + 'docs/b.md?ref=dev')
})

test('images resolve against the raw base, not the viewer', (t) => {
  const resolve = loadResolve()
  t.is(resolve('img/x.png', 'docs', RAW, REPOS, true), RAW + 'docs/img/x.png')
})

test('path segments are URL-encoded', (t) => {
  const resolve = loadResolve()
  t.is(resolve('my file.md', '', BLOB, REPOS, false), BLOB + 'my%20file.md')
  // The separators must NOT be encoded — only the segments.
  t.is(resolve('a/b c.md', '', BLOB, REPOS, false), BLOB + 'a/b%20c.md')
})

test('stripExt drops the extension for slug-addressed links only', (t) => {
  const resolve = loadResolve()
  const DOCS = '/git/docs/'
  // The site docs are addressed by slug: ./origin-setup.md → /docs/origin-setup
  t.is(resolve('./origin-setup.md', '', DOCS, '', false, ['md', 'markdown']),
    DOCS + 'origin-setup')
  t.is(resolve('./x.markdown', '', DOCS, '', false, ['md', 'markdown']),
    DOCS + 'x')
  // Not a listed extension → untouched.
  t.is(resolve('./x.txt', '', DOCS, '', false, ['md']), DOCS + 'x.txt')
  // Images keep their extension — they must point at real bytes.
  t.is(resolve('./x.md', '', DOCS, '', true, ['md']), DOCS + 'x.md')
  // Off by default.
  t.is(resolve('./x.md', '', DOCS, '', false), DOCS + 'x.md')
})