OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / test / admin-assets.test.js

78 lines · javascript ref:
// The served module graph must be CLOSED, and the hashing must need no secure context.
//
// ⛔ TWO FAILURES THAT LOOK THE SAME FROM A BROWSER. A missing transitive import and a missing
// `crypto.subtle` both surface as a sign-in page that does nothing — no server log, no useful console
// line, nothing the operator can act on. Both were real here:
//
//   1. The allowlist held only the three files anproto-client.js imports DIRECTLY, while two of those
//      import ./noble-crypto.js. The entry point served fine and its dependency 404'd.
//   2. hash() used `crypto.subtle`, which exists only on HTTPS or localhost. Over plain HTTP at
//      http://stortron.local:3401 — which is the whole point of this surface — it is undefined.

// ⛔ FIRST — ap-bridge's own globals shim, for the same reason index.js imports it first: deps touch
// TextEncoder at module load and bare does not define it.
import '../lib/bare-globals.js'
import test from 'brittle'
import fs from 'bare-fs'
import { join } from 'bare-path'
import { staticAsset } from '../lib/admin-surface.js'

const ROOT = new URL('..', import.meta.url).pathname
const ENTRY = 'anproto-client.js'

// Every `import ... from './x.js'` / `import "./x.js"` in a served file.
function localImports (src) {
  const out = new Set()
  for (const m of src.matchAll(/import\s*(?:[^'"]*?from\s*)?['"]\.\/([^'"]+)['"]/g)) out.add(m[1])
  return out
}

test('★★★ every module the client pulls in is itself served', (t) => {
  // ⛔ WALKED, not listed. A hand-kept list of "the files it needs" is the thing that went wrong; this
  // follows the imports so adding a dependency without serving it fails here rather than in a browser.
  const seen = new Set()
  const queue = [ENTRY]
  while (queue.length) {
    const name = queue.pop()
    if (seen.has(name)) continue
    seen.add(name)

    const served = staticAsset(name, ROOT)
    t.ok(served, name + ' is served')
    if (!served) continue
    for (const dep of localImports(String(served.body))) queue.push(dep)
  }
  t.ok(seen.size >= 4, 'the graph has real depth (' + seen.size + ' modules), so this is not vacuous')
  t.ok(seen.has('noble-crypto.js'), 'including the transitive one the first list forgot')
})

// ⚠ Comments are stripped first. The claim is about what the client CALLS, and the file quite
// deliberately explains in prose why it no longer calls it — a grep over raw text fails on its own
// explanation, which is a test measuring the wrong thing rather than a real finding.
const codeOf = (src) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '').replace(/\/\/.*$/gm, '')

test('★★★ the client needs no secure context to hash', (t) => {
  const code = codeOf(String(staticAsset(ENTRY, ROOT).body))
  t.absent(/crypto\s*\.\s*subtle/.test(code),
    'crypto.subtle exists only on HTTPS or localhost — this surface is reached on a LAN name over HTTP')
  t.ok(/sha256\(/.test(code), 'it hashes with the vendored pure-JS implementation instead')
})

test('★★ nothing outside the graph is reachable', (t) => {
  for (const evil of ['../lib/admin.js', '../package.json', '../../favela-peer/lib/browsers.js', 'index.js']) {
    t.absent(staticAsset(evil, ROOT), evil + ' is not served')
  }
})

test('★★ the vendored hash is the one the server verifies against', async (t) => {
  // ⚠ If these ever diverge the signature VERIFIES and then fails the hash check — the failure lands
  // one layer away from the cause, which is why it is asserted rather than assumed.
  const { sha256 } = await import('../static/noble-sha256.js')
  const { an } = await import('anproto')
  const b4a = (await import('b4a')).default
  for (const s of ['hello', '', 'a challenge string']) {
    const mine = b4a.toString(b4a.from(sha256(new TextEncoder().encode(s))), 'base64')
    t.is(mine, await an.hash(s), 'sha256 matches an.hash for ' + JSON.stringify(s))
  }
})