ReviewOS

also looking at this

pantry-pm/pantry

fix(chrome): ship NSS's runtime providers and gate on a real render

#246
Open chris wants to merge fix/chrome-nss-runtime-modules into main
3 files +114 -2
.github/scripts/linux-native-browser.tsmodified+62-0
Changes to .github/scripts/linux-native-browser.ts
@@ -26,6 +26,40 @@ async function run(name: string, args: string[], timeout = 30_000): Promise<stri
2626 return output
2727}
2828
29/**
30 * Run with nothing inherited but HOME and a PATH without the package, so only
31 * its own layout resolves libraries. LD_DEBUG=files makes the dynamic loader
32 * report each library it initialises (on stderr) without changing a lookup.
33 */
34async function runClean(name: string, args: string[], timeout = 60_000): Promise<{ stdout: string, stderr: string }> {
35 const stdout = join(artifacts, `${name}.stdout.log`)
36 const stderr = join(artifacts, `${name}.stderr.log`)
37 const child = Bun.spawn(args, { cwd: root, env: { HOME: root, PATH: '/usr/bin:/bin', LD_DEBUG: 'files' }, stdout: Bun.file(stdout), stderr: Bun.file(stderr), signal: AbortSignal.timeout(timeout) })
38 const code = await child.exited
39 const output = { stdout: await Bun.file(stdout).text(), stderr: await Bun.file(stderr).text() }
40 assert.equal(code, 0, `${name}: ${output.stdout}${output.stderr.slice(-4000)}`)
41 return output
42}
43
44/**
45 * Ubuntu 23.10+ lets an unprivileged program create user namespaces - which
46 * Chrome's sandbox needs - only under an AppArmor profile that allows it.
47 * Ubuntu ships one for /opt/google/chrome/chrome; this grants the same, and
48 * only that, to the installed binary. The browser keeps its sandbox: no
49 * --no-sandbox, and the restriction stays on for everything else.
50 */
51async function allowSandbox(binary: string): Promise<string> {
52 const restricted = await readFile('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').then(value => value.trim() === '1').catch(() => false)
53 if (!restricted) return 'not restricted'
54 const profile = join(root, 'pantry-chrome-userns')
55 await Bun.write(profile, `abi <abi/4.0>,\ninclude <tunables/global>\nprofile pantry-chrome-userns "${binary}" flags=(unconfined) {\n userns,\n}\n`)
56 const load = Bun.spawnSync(['sudo', '-n', 'apparmor_parser', '-r', profile], { stdout: 'pipe', stderr: 'pipe' })
57 assert.equal(load.exitCode, 0, `Could not load the sandbox profile: ${load.stderr.toString()}`)
58 loadedProfile = profile
59 return 'pantry-chrome-userns'
60}
61let loadedProfile: string | undefined
62
2963try {
3064 proof.cliVersion = (await run('cli-version', [cli, '--version'])).trim()
3165 // The exact published package and ordinary install path used by HQ's CI.
@@ -37,9 +71,37 @@ try {
3771 proof.executableSha256 = Bun.SHA256.hash(await readFile(executable), 'hex')
3872 proof.version = (await run('chrome-version', [entry, '--version'])).trim()
3973 assert.match(String(proof.version), /\b155\.0\.8059\.84\b/)
74
75 // --version starts no browser: NSS, the sandbox and the renderer never load.
76 // Render a page with the sandbox on, from a clean environment - no
77 // LD_LIBRARY_PATH, nothing inherited - so every library has to come from the
78 // package's own layout. This is what failed with nss_error=-8023 when the
79 // package left out libfreeblpriv3, a provider NSS loads with dlopen.
80 proof.sandboxProfile = await allowSandbox(executable)
81 const page = join(root, 'render-profile')
82 await mkdir(page)
83 const rendered = await runClean('chrome-render', [entry, '--headless=new', '--disable-gpu', '--no-first-run', `--user-data-dir=${page}`, '--dump-dom', 'data:text/html,<h1 id="pantry">rendered by the installed package</h1>'])
84 assert.match(rendered.stdout, /<h1 id="pantry">rendered by the installed package<\/h1>/, 'Chrome must render with its own libraries and its sandbox')
85 proof.rendered = true
86
87 // A render alone is not proof: a host NSS (GitHub's image ships one with its
88 // own Chrome) answers NSS's dlopen fallback for a provider the package left
89 // out, and the page renders anyway. Require the loader to have initialised
90 // softokn and its crypto provider from the installed package itself.
91 // Whole lines: the gate's root path has spaces in it, on purpose.
92 const initialised = [...rendered.stderr.matchAll(/calling init: (.+)$/gm)].map(match => match[1]!.trim())
93 const nss = initialised.filter(path => /\/lib(?:softokn3|freeblpriv3)\.so$/.test(path))
94 proof.nssModules = [...new Set(nss)]
95 for (const module of ['libsoftokn3.so', 'libfreeblpriv3.so']) {
96 const loaded = nss.filter(path => path.endsWith(`/${module}`))
97 assert(loaded.length > 0, `Chrome never initialised ${module}`)
98 for (const path of loaded)
99 assert(path.startsWith(`${root}/pantry/`), `${module} came from ${path}, not the installed package`)
100 }
40101 proof.passed = true
41102}
42103finally {
104 if (loadedProfile) Bun.spawnSync(['sudo', '-n', 'apparmor_parser', '-R', loadedProfile])
43105 await rm(root, { recursive: true, force: true })
44106 proof.cleaned = true
45107 await Bun.write(join(artifacts, 'proof.json'), JSON.stringify(proof, null, 2) + '\n')