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')
packages/ts-pantry/src/recipes/google.com/chrome.tsmodified+17-2
Changes to packages/ts-pantry/src/recipes/google.com/chrome.ts
@@ -1,5 +1,15 @@
11import type { Recipe } from '../../../scripts/recipe-types'
22
3/** NSS modules loaded with dlopen at runtime, so no dependency walk finds them. */
4export const NSS_RUNTIME_PROVIDERS: readonly string[] = [
5 'libsoftokn3.so',
6 'libsoftokn3.chk',
7 'libfreebl3.so',
8 'libfreeblpriv3.so',
9 'libfreeblpriv3.chk',
10 'libnssckbi.so',
11]
12
313export const recipe: Recipe = {
414 domain: 'google.com/chrome',
515 name: 'chrome-for-testing',
@@ -49,8 +59,13 @@ export const recipe: Recipe = {
4959 'mkdir -p /lib /bin',
5060 'cp -a chrome-linux64 /lib/chrome-for-testing',
5161 'ln -s ../lib/chrome-for-testing/chrome /bin/google-chrome-stable',
52 // NSS opens these providers dynamically, outside the ELF DT_NEEDED graph.
53 'cp /lib/libsoftokn3.so /lib/libfreebl3.so /lib/',
62 // NSS opens these providers dynamically, outside the ELF DT_NEEDED graph,
63 // from the directory of the libnss3 Chrome loads ($ORIGIN/.. here):
64 // softokn loads libfreeblpriv3 (freebl3 is only its stub), and the
65 // built-in root module is libnssckbi. Without libfreeblpriv3 the browser
66 // aborts at startup with nss_error=-8023, which --version never reaches.
67 // The .chk files are their FIPS integrity signatures.
68 `cp ${NSS_RUNTIME_PROVIDERS.map(file => `{{deps.mozilla.org/nss.prefix}}/lib/${file}`).join(' ')} /lib/`,
5469 ],
5570 },
5671 test: {
packages/ts-pantry/test/chrome-nss-runtime.test.tsadded+35-0
Changes to packages/ts-pantry/test/chrome-nss-runtime.test.ts
@@ -0,0 +1,35 @@
1import { expect, test } from 'bun:test'
2import { readFileSync } from 'node:fs'
3import { resolve } from 'node:path'
4import { NSS_RUNTIME_PROVIDERS, recipe as chrome } from '../src/recipes/google.com/chrome'
5
6/*
7 * Chrome loads libnss3 from its own lib directory ($ORIGIN/..), and NSS opens
8 * its providers from there with dlopen - outside the DT_NEEDED graph, so no
9 * dependency walk finds them. softokn's crypto is libfreeblpriv3 (libfreebl3 is
10 * its stub); without it the installed browser aborted at startup with
11 * nss_error=-8023 while `--version`, the recipe's only test, still passed.
12 */
13test('Chrome ships every NSS provider that NSS opens at runtime', () => {
14 for (const file of ['libsoftokn3.so', 'libfreebl3.so', 'libfreeblpriv3.so', 'libfreeblpriv3.chk', 'libnssckbi.so'])
15 expect(NSS_RUNTIME_PROVIDERS).toContain(file)
16
17 const script = (chrome.build?.script ?? []).map(step => typeof step === 'string' ? step : '').join('\n')
18 for (const file of NSS_RUNTIME_PROVIDERS)
19 expect(script).toContain(`/lib/${file}`)
20 expect(chrome.dependencies?.['mozilla.org/nss']).toBe('^3')
21})
22
23test('the Linux release gate renders a page with the installed package, not just --version', () => {
24 const gate = readFileSync(resolve(import.meta.dir, '../../../.github/scripts/linux-native-browser.ts'), 'utf8')
25 expect(gate).toContain('--dump-dom')
26 // A clean environment: only HOME and a PATH without the package, so an
27 // inherited LD_LIBRARY_PATH cannot supply the library the package lacks.
28 expect(gate).toContain(`env: { HOME: root, PATH: '/usr/bin:/bin', LD_DEBUG: 'files' }`)
29 // And a host NSS cannot stand in: the providers must load from the package.
30 expect(gate).toContain('calling init:')
31 expect(gate).toContain('not the installed package')
32 // The sandbox stays on.
33 expect(gate).not.toContain("'--no-sandbox'")
34 expect(gate).toContain('userns,')
35})