also looking at this
fix(chrome): ship NSS's runtime providers and gate on a real render
#246
3 files
+114
-2
| @@ -26,6 +26,40 @@ async function run(name: string, args: string[], timeout = 30_000): Promise<stri | ||
| 26 | 26 | return output |
| 27 | 27 | } |
| 28 | 28 | |
| 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 | */ | |
| 34 | async 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 | */ | |
| 51 | async 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 | } | |
| 61 | let loadedProfile: string | undefined | |
| 62 | ||
| 29 | 63 | try { |
| 30 | 64 | proof.cliVersion = (await run('cli-version', [cli, '--version'])).trim() |
| 31 | 65 | // The exact published package and ordinary install path used by HQ's CI. |
| @@ -37,9 +71,37 @@ try { | ||
| 37 | 71 | proof.executableSha256 = Bun.SHA256.hash(await readFile(executable), 'hex') |
| 38 | 72 | proof.version = (await run('chrome-version', [entry, '--version'])).trim() |
| 39 | 73 | 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 | } | |
| 40 | 101 | proof.passed = true |
| 41 | 102 | } |
| 42 | 103 | finally { |
| 104 | if (loadedProfile) Bun.spawnSync(['sudo', '-n', 'apparmor_parser', '-R', loadedProfile]) | |
| 43 | 105 | await rm(root, { recursive: true, force: true }) |
| 44 | 106 | proof.cleaned = true |
| 45 | 107 | await Bun.write(join(artifacts, 'proof.json'), JSON.stringify(proof, null, 2) + '\n') |
| @@ -1,5 +1,15 @@ | ||
| 1 | 1 | import type { Recipe } from '../../../scripts/recipe-types' |
| 2 | 2 | |
| 3 | /** NSS modules loaded with dlopen at runtime, so no dependency walk finds them. */ | |
| 4 | export 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 | ||
| 3 | 13 | export const recipe: Recipe = { |
| 4 | 14 | domain: 'google.com/chrome', |
| 5 | 15 | name: 'chrome-for-testing', |
| @@ -49,8 +59,13 @@ export const recipe: Recipe = { | ||
| 49 | 59 | 'mkdir -p /lib /bin', |
| 50 | 60 | 'cp -a chrome-linux64 /lib/chrome-for-testing', |
| 51 | 61 | '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/`, | |
| 54 | 69 | ], |
| 55 | 70 | }, |
| 56 | 71 | test: { |
| @@ -0,0 +1,35 @@ | ||
| 1 | import { expect, test } from 'bun:test' | |
| 2 | import { readFileSync } from 'node:fs' | |
| 3 | import { resolve } from 'node:path' | |
| 4 | import { 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 | */ | |
| 13 | test('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 | ||
| 23 | test('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 | }) | |