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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

.github/scripts/linux-native-browser.tsmodified+43-0
Changes to .github/scripts/linux-native-browser.ts
@@ -26,6 +26,36 @@ async function run(name: string, args: string[], timeout = 30_000): Promise<stri
2626 return output
2727}
2828
29/** Run with nothing inherited but HOME and a PATH without the package, so only its own layout resolves libraries. */
30async function runClean(name: string, args: string[], timeout = 60_000): Promise<string> {
31 const stdout = join(artifacts, `${name}.stdout.log`)
32 const stderr = join(artifacts, `${name}.stderr.log`)
33 const child = Bun.spawn(args, { cwd: root, env: { HOME: root, PATH: '/usr/bin:/bin' }, stdout: Bun.file(stdout), stderr: Bun.file(stderr), signal: AbortSignal.timeout(timeout) })
34 const code = await child.exited
35 const output = await Bun.file(stdout).text()
36 assert.equal(code, 0, `${name}: ${output}${await Bun.file(stderr).text()}`)
37 return output
38}
39
40/**
41 * Ubuntu 23.10+ lets an unprivileged program create user namespaces - which
42 * Chrome's sandbox needs - only under an AppArmor profile that allows it.
43 * Ubuntu ships one for /opt/google/chrome/chrome; this grants the same, and
44 * only that, to the installed binary. The browser keeps its sandbox: no
45 * --no-sandbox, and the restriction stays on for everything else.
46 */
47async function allowSandbox(binary: string): Promise<string> {
48 const restricted = await readFile('/proc/sys/kernel/apparmor_restrict_unprivileged_userns', 'utf8').then(value => value.trim() === '1').catch(() => false)
49 if (!restricted) return 'not restricted'
50 const profile = join(root, 'pantry-chrome-userns')
51 await Bun.write(profile, `abi <abi/4.0>,\ninclude <tunables/global>\nprofile pantry-chrome-userns "${binary}" flags=(unconfined) {\n userns,\n}\n`)
52 const load = Bun.spawnSync(['sudo', '-n', 'apparmor_parser', '-r', profile], { stdout: 'pipe', stderr: 'pipe' })
53 assert.equal(load.exitCode, 0, `Could not load the sandbox profile: ${load.stderr.toString()}`)
54 loadedProfile = profile
55 return 'pantry-chrome-userns'
56}
57let loadedProfile: string | undefined
58
2959try {
3060 proof.cliVersion = (await run('cli-version', [cli, '--version'])).trim()
3161 // The exact published package and ordinary install path used by HQ's CI.
@@ -37,9 +67,22 @@ try {
3767 proof.executableSha256 = Bun.SHA256.hash(await readFile(executable), 'hex')
3868 proof.version = (await run('chrome-version', [entry, '--version'])).trim()
3969 assert.match(String(proof.version), /\b155\.0\.8059\.84\b/)
70
71 // --version starts no browser: NSS, the sandbox and the renderer never load.
72 // Render a page with the sandbox on, from a clean environment - no
73 // LD_LIBRARY_PATH, nothing inherited - so every library has to come from the
74 // package's own layout. This is what failed with nss_error=-8023 when the
75 // package left out libfreeblpriv3, a provider NSS loads with dlopen.
76 proof.sandboxProfile = await allowSandbox(executable)
77 const page = join(root, 'render-profile')
78 await mkdir(page)
79 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>'])
80 assert.match(rendered, /<h1 id="pantry">rendered by the installed package<\/h1>/, 'Chrome must render with its own libraries and its sandbox')
81 proof.rendered = true
4082 proof.passed = true
4183}
4284finally {
85 if (loadedProfile) Bun.spawnSync(['sudo', '-n', 'apparmor_parser', '-R', loadedProfile])
4386 await rm(root, { recursive: true, force: true })
4487 proof.cleaned = true
4588 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+32-0
Changes to packages/ts-pantry/test/chrome-nss-runtime.test.ts
@@ -0,0 +1,32 @@
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' }`)
29 // The sandbox stays on.
30 expect(gate).not.toContain("'--no-sandbox'")
31 expect(gate).toContain('userns,')
32})