From dfa12dd862188dc2ae26c6cdb8c24f2503d62364 Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 01:59:51 +0000 Subject: [PATCH 1/2] test: prepare bounded offline Rust browser and telemetry checks --- package.json | 2 +- security/browser-headers/README.md | 5 + security/browser-headers/runtime/.gitignore | 1 + .../browser-headers/runtime/package-lock.json | 42 ++++++ security/browser-headers/runtime/package.json | 8 + .../browser-headers/rust-browser-inputs.cjs | 129 ++++++++++++++++ .../rust-browser-source.test.cjs | 98 +++++++++++++ .../browser-headers/rust-browser.test.cjs | 138 +++++++++++++----- .../rust-local-browser-packet.json | 36 +++++ .../rust-local-browser-plan.md | 137 +++++++++++++++++ 10 files changed, 559 insertions(+), 37 deletions(-) create mode 100644 security/browser-headers/runtime/.gitignore create mode 100644 security/browser-headers/runtime/package-lock.json create mode 100644 security/browser-headers/runtime/package.json create mode 100644 security/browser-headers/rust-browser-inputs.cjs create mode 100644 security/browser-headers/rust-browser-source.test.cjs create mode 100644 security/browser-headers/rust-local-browser-packet.json create mode 100644 security/browser-headers/rust-local-browser-plan.md diff --git a/package.json b/package.json index a2f6d119f97..d8f8511e8b2 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "serve": "docusaurus serve", "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", - "test:browser-security-headers": "node --test scripts/browser-security-headers.test.js" + "test:browser-security-headers": "node --test --test-concurrency=2 scripts/browser-security-headers.test.js security/browser-headers/rust-browser-source.test.cjs" }, "dependencies": { "@docusaurus/core": "3.10.2", diff --git a/security/browser-headers/README.md b/security/browser-headers/README.md index cf15119bb0b..46c27f9ee34 100644 --- a/security/browser-headers/README.md +++ b/security/browser-headers/README.md @@ -111,6 +111,11 @@ a browser compatibility check. No current-artifact browser run or provider stage is authorized by this source proposal. Follow `rust-stage-proposal.md` for the exact input bindings, ownership, proposed checks and rollback decision. +`rust-local-browser-plan.md` and `rust-local-browser-packet.json` define the +separately reviewable, unrun local browser/telemetry packet. Normal CI exercises +only its inert source controls. The optional browser entrypoint requires its +own exact run admission; it makes no provider or production request. + ## Rust-only staged acceptance and rollback 1. Bind the accepted package, source/tooling revisions, protected artifact ZIP diff --git a/security/browser-headers/runtime/.gitignore b/security/browser-headers/runtime/.gitignore new file mode 100644 index 00000000000..c2658d7d1b3 --- /dev/null +++ b/security/browser-headers/runtime/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/security/browser-headers/runtime/package-lock.json b/security/browser-headers/runtime/package-lock.json new file mode 100644 index 00000000000..e44f2c0076c --- /dev/null +++ b/security/browser-headers/runtime/package-lock.json @@ -0,0 +1,42 @@ +{ + "name": "durable-workflow-rust-browser-check", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "durable-workflow-rust-browser-check", + "version": "0.0.0", + "dependencies": { + "playwright": "1.64.0" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + } + } +} diff --git a/security/browser-headers/runtime/package.json b/security/browser-headers/runtime/package.json new file mode 100644 index 00000000000..96d8d97f4f2 --- /dev/null +++ b/security/browser-headers/runtime/package.json @@ -0,0 +1,8 @@ +{ + "name": "durable-workflow-rust-browser-check", + "private": true, + "version": "0.0.0", + "dependencies": { + "playwright": "1.64.0" + } +} diff --git a/security/browser-headers/rust-browser-inputs.cjs b/security/browser-headers/rust-browser-inputs.cjs new file mode 100644 index 00000000000..9aebc75b840 --- /dev/null +++ b/security/browser-headers/rust-browser-inputs.cjs @@ -0,0 +1,129 @@ +const assert = require('node:assert/strict'); +const crypto = require('node:crypto'); +const fs = require('node:fs'); +const path = require('node:path'); + +const ORIGIN = 'https://rust.durable-workflow.com'; +const BEACON = 'https://static.cloudflareinsights.com/beacon.min.js'; +const RUM = 'https://cloudflareinsights.com/cdn-cgi/rum'; +const PROMOTION = 'https://cloud.durable-workflow.com/early-access/promotion-events'; +const IMAGE_ORIGINS = new Set(['https://github.com', 'https://img.shields.io', 'https://sonarcloud.io']); +const MIME = {'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf'}; +const digest = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); + +function verifyPacketFiles(directory) { + const packet = JSON.parse(fs.readFileSync(path.join(directory, 'rust-local-browser-packet.json'))); + assert.equal(packet.run_status, 'not-run'); + for (const [name, hash] of Object.entries(packet.files)) { + assert.ok(!path.isAbsolute(name) && !name.split('/').includes('..'), 'Unsafe packet path'); + assert.equal(digest(fs.readFileSync(path.join(directory, name))), hash, 'Packet member changed'); + } + return packet; +} + +function loadInputs(root, packet, inventory) { + assert.equal(packet.rule.enabled, false); + assert.equal(packet.rollout_approved, false); + assert.equal(packet.compatibility_accepted, false); + assert.equal(inventory.origin, ORIGIN); + assert.equal(packet.inventory_sha256, digest(JSON.stringify(inventory, null, 2) + '\n')); + assert.deepEqual(packet.input_artifact, inventory.input_artifact); + assert.equal(packet.source_preparation, null); + assert.equal(inventory.source_preparation, null); + assert.ok(fs.lstatSync(root).isDirectory(), 'Artifact root must be a regular directory'); + const files = new Map(); + let bytes = 0; + function visit(directory) { + for (const entry of fs.readdirSync(directory, {withFileTypes: true})) { + const absolute = path.join(directory, entry.name); + assert.ok(!entry.isSymbolicLink(), 'Artifact symlinks are refused'); + if (entry.isDirectory()) visit(absolute); + else { + assert.ok(entry.isFile(), 'Artifact special files are refused'); + const relative = path.relative(root, absolute).split(path.sep).join('/'); + const size = fs.statSync(absolute).size; + bytes += size; + assert.ok(bytes <= 64 * 1024 * 1024 && files.size < 1000, 'Artifact exceeds the local input bound'); + files.set(relative, fs.readFileSync(absolute)); + } + } + } + visit(root); + assert.equal(files.size, inventory.files.length, 'Artifact file count changed'); + const seen = new Set(); + for (const record of inventory.files) { + assert.ok(!seen.has(record.path), 'Duplicate inventory path'); + seen.add(record.path); + const content = files.get(record.path); + assert.ok(content, 'Inventoried artifact file is missing'); + assert.equal(content.length, record.bytes, 'Artifact file size changed'); + assert.equal(digest(content), record.sha256, 'Artifact file digest changed'); + } + assert.deepEqual(JSON.parse(files.get('__build.json')), inventory.build_identity); + return {files, packet, inventory, headers: Object.fromEntries(Object.entries(packet.rule.action_parameters.headers).map(([name, record]) => [name, record.value]))}; +} + +function promotionEvent(request) { + assert.equal(request.method, 'POST', 'Promotion request method changed'); + const headers = Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name.toLowerCase(), value])); + assert.ok(!Object.hasOwn(headers, 'authorization'), 'Local telemetry cannot carry authorization'); + assert.ok(!Object.hasOwn(headers, 'cookie'), 'Local telemetry cannot carry cookies'); + assert.match(headers['content-type'] || '', /^text\/plain(?:;|$)/); + assert.ok(typeof request.body === 'string' && request.body.length < 256, 'Promotion body exceeds its bound'); + const event = JSON.parse(request.body); + assert.deepEqual(Object.keys(event).sort(), ['event', 'source']); + assert.equal(event.source, 'sdk-rust-reference'); + assert.ok(['impression', 'click'].includes(event.event), 'Unknown promotion event'); + return event.event; +} + +function localResponse(input, request, origin = ORIGIN) { + assert.ok([ORIGIN, 'https://example.invalid'].includes(origin), 'Unexpected local origin'); + const requestHeaders = Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name.toLowerCase(), value])); + assert.ok(!Object.hasOwn(requestHeaders, 'authorization'), 'Local requests cannot carry authorization'); + assert.ok(!Object.hasOwn(requestHeaders, 'cookie'), 'Local requests cannot carry cookies'); + const url = new URL(request.url); + if (url.origin === origin) { + assert.equal(request.method, 'GET', 'Artifact requests must be GET'); + const relative = decodeURIComponent(url.pathname).replace(/^\//, ''); + if (relative === '__frame_probe') return {kind: 'frame-probe', status: 200, headers: {'content-type': 'text/html'}, body: 'Framing control'}; + if (relative === '__telemetry_fixture') return {kind: 'promotion-dom-fixture', status: 200, headers: {...input.headers, 'content-type': 'text/html'}, body: 'Synthetic promotion control'}; + const file = [relative, relative.replace(/\/$/, '') + '/index.html', relative ? null : 'index.html'].find((name) => name && input.files.has(name)); + if (!file) return {kind: 'missing', status: 404, body: ''}; + return {kind: 'artifact', status: 200, headers: {...input.headers, 'content-type': MIME[path.extname(file)] || 'application/octet-stream', 'cache-control': 'no-store'}, body: input.files.get(file)}; + } + const cors = {'access-control-allow-origin': origin}; + if (request.url === BEACON && request.method === 'GET') { + // This finite fixture exercises CSP loader/connect paths, never vendor code or delivery. + return {kind: 'beacon-fixture', status: 200, headers: {...cors, 'content-type': 'text/javascript'}, body: `globalThis.__localBeaconFixture = true; fetch('${RUM}', {method: 'POST', credentials: 'omit', headers: {'Content-Type': 'text/plain'}, body: 'local-csp-probe'});`}; + } + if (request.url === RUM && request.method === 'POST' && request.body === 'local-csp-probe') { + return {kind: 'rum-fixture', status: 204, headers: cors, body: ''}; + } + if (request.url === PROMOTION) return {kind: 'promotion-' + promotionEvent(request), status: 204, headers: cors, body: ''}; + if (IMAGE_ORIGINS.has(url.origin) && request.method === 'GET' && request.resourceType === 'image') { + return {kind: 'image-fixture', status: 200, headers: {'content-type': 'image/svg+xml'}, body: ''}; + } + throw new Error('Request is outside the finite local response set'); +} + +async function installLocalRoutes(context, input, origin = ORIGIN) { + const audit = {requests: 0, kinds: {}, refused: []}; + await context.routeWebSocket('**/*', (socket) => { audit.refused.push('websocket'); socket.close(); }); + await context.route('**/*', async (route) => { + try { + audit.requests += 1; + assert.ok(audit.requests <= 4000, 'Local request budget exceeded'); + const request = route.request(); + const response = localResponse(input, {url: request.url(), method: request.method(), headers: request.headers(), body: request.postData(), resourceType: request.resourceType()}, origin); + audit.kinds[response.kind] = (audit.kinds[response.kind] || 0) + 1; + await route.fulfill({status: response.status, headers: response.headers, body: response.body}); + } catch { + audit.refused.push('http'); + await route.abort(); + } + }); + return audit; +} + +module.exports = {ORIGIN, BEACON, RUM, PROMOTION, digest, verifyPacketFiles, loadInputs, localResponse, installLocalRoutes}; diff --git a/security/browser-headers/rust-browser-source.test.cjs b/security/browser-headers/rust-browser-source.test.cjs new file mode 100644 index 00000000000..8bc8133b815 --- /dev/null +++ b/security/browser-headers/rust-browser-source.test.cjs @@ -0,0 +1,98 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); +const {ORIGIN, BEACON, RUM, PROMOTION, digest, loadInputs, localResponse, installLocalRoutes} = require('./rust-browser-inputs.cjs'); + +function fixture(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rust-browser-source-')); + t.after(() => fs.rmSync(root, {recursive: true, force: true})); + const build = {version: 'fixture'}; + const files = {'__build.json': JSON.stringify(build), 'index.html': '

Fixture

'}; + for (const [name, value] of Object.entries(files)) fs.writeFileSync(path.join(root, name), value); + const inventory = {origin: ORIGIN, input_artifact: {artifact_id: 'fixture'}, source_preparation: null, build_identity: build, files: Object.entries(files).map(([name, content]) => ({path: name, bytes: Buffer.byteLength(content), sha256: digest(content)}))}; + const packet = {rule: {enabled: false, action_parameters: {headers: {'content-security-policy': {value: "default-src 'none'"}}}}, rollout_approved: false, compatibility_accepted: false, source_preparation: null, input_artifact: inventory.input_artifact, inventory_sha256: digest(JSON.stringify(inventory, null, 2) + '\n')}; + return {root, inventory, packet}; +} + +function request(url, options = {}) { + return {url, method: 'GET', headers: {}, body: null, resourceType: 'script', ...options}; +} + +test('verifies every input file and refuses drift before a browser is needed', (t) => { + const value = fixture(t); + assert.equal(loadInputs(value.root, value.packet, value.inventory).files.size, 2); + fs.writeFileSync(path.join(value.root, 'index.html'), '

Changed

'); + assert.throws(() => loadInputs(value.root, value.packet, value.inventory), /Artifact file/); +}); + +test('refuses extra files, symlinks, duplicate inventory and changed approval flags', (t) => { + const value = fixture(t); + fs.writeFileSync(path.join(value.root, 'extra'), 'extra'); + assert.throws(() => loadInputs(value.root, value.packet, value.inventory), /file count/); + fs.unlinkSync(path.join(value.root, 'extra')); + fs.symlinkSync(path.join(value.root, 'index.html'), path.join(value.root, 'link')); + assert.throws(() => loadInputs(value.root, value.packet, value.inventory), /symlinks/); + fs.unlinkSync(path.join(value.root, 'link')); + for (const mutate of [(p) => { p.rule.enabled = true; }, (p) => { p.rollout_approved = true; }, (p) => { p.compatibility_accepted = true; }]) { + const packet = structuredClone(value.packet); mutate(packet); + assert.throws(() => loadInputs(value.root, packet, value.inventory)); + } + value.inventory.files[1] = value.inventory.files[0]; + value.packet.inventory_sha256 = digest(JSON.stringify(value.inventory, null, 2) + '\n'); + assert.throws(() => loadInputs(value.root, value.packet, value.inventory), /Duplicate inventory/); +}); + +test('serves only verified buffers and finite external fixtures', (t) => { + const value = fixture(t); + const input = loadInputs(value.root, value.packet, value.inventory); + assert.equal(localResponse(input, request(ORIGIN + '/')).body.toString(), '

Fixture

'); + assert.equal(localResponse(input, request(ORIGIN + '/%2e%2e/private')).status, 404); + assert.equal(localResponse(input, request(BEACON)).kind, 'beacon-fixture'); + assert.equal(localResponse(input, request(RUM, {method: 'POST', body: 'local-csp-probe'})).kind, 'rum-fixture'); + for (const url of ['https://unreviewed.invalid/x', 'file:///private', BEACON + '?extra=1', RUM + '/extra']) assert.throws(() => localResponse(input, request(url))); + assert.throws(() => localResponse(input, request(BEACON, {headers: {Cookie: 'fixture'}})), /cookies/); + assert.throws(() => localResponse(input, request(BEACON, {headers: {Authorization: 'fixture'}})), /authorization/); +}); + +test('promotion fixtures require exact method, event shape and credential absence', (t) => { + const value = fixture(t); + const input = loadInputs(value.root, value.packet, value.inventory); + const valid = request(PROMOTION, {method: 'POST', headers: {'content-type': 'text/plain'}, body: JSON.stringify({source: 'sdk-rust-reference', event: 'impression'})}); + assert.equal(localResponse(input, valid).kind, 'promotion-impression'); + for (const change of [ + {method: 'GET'}, {body: '{invalid'}, {body: JSON.stringify({source: 'other', event: 'click'})}, + {body: JSON.stringify({source: 'sdk-rust-reference', event: 'other'})}, + {body: JSON.stringify({source: 'sdk-rust-reference', event: 'click', extra: true})}, + ]) assert.throws(() => localResponse(input, {...valid, ...change})); +}); + +test('fake routing fulfills or aborts without a network continuation', async (t) => { + const value = fixture(t); + const input = loadInputs(value.root, value.packet, value.inventory); + let httpHandler; let websocketHandler; + const context = {route: async (_pattern, handler) => { httpHandler = handler; }, routeWebSocket: async (_pattern, handler) => { websocketHandler = handler; }}; + const audit = await installLocalRoutes(context, input); + async function dispatch(url) { + const req = request(url); + const route = {request: () => ({url: () => req.url, method: () => req.method, headers: () => req.headers, postData: () => req.body, resourceType: () => req.resourceType}), fulfill: async () => 'fulfilled', abort: async () => 'aborted'}; + await httpHandler(route); + } + await dispatch(ORIGIN + '/'); + await dispatch('https://unreviewed.invalid/x'); + let closed = false; + websocketHandler({close: () => { closed = true; }}); + assert.equal(closed, true); + assert.equal(audit.kinds.artifact, 1); + assert.deepEqual(audit.refused, ['http', 'websocket']); +}); + +test('review packet identifies the exact executable and data closure', () => { + const packet = JSON.parse(fs.readFileSync(path.join(__dirname, 'rust-local-browser-packet.json'))); + assert.equal(packet.run_status, 'not-run'); + for (const [name, hash] of Object.entries(packet.files)) assert.equal(digest(fs.readFileSync(path.join(__dirname, name))), hash, name); + const candidate = JSON.parse(fs.readFileSync(path.join(__dirname, 'rust-rule.candidate.json'))); + assert.deepEqual(packet.input_artifact, candidate.input_artifact); + assert.equal(packet.container.network, 'none'); +}); diff --git a/security/browser-headers/rust-browser.test.cjs b/security/browser-headers/rust-browser.test.cjs index 7f805eaec09..32ee55b0276 100644 --- a/security/browser-headers/rust-browser.test.cjs +++ b/security/browser-headers/rust-browser.test.cjs @@ -1,49 +1,26 @@ const assert = require('node:assert/strict'); const crypto = require('node:crypto'); const fs = require('node:fs'); -const http = require('node:http'); const path = require('node:path'); const test = require('node:test'); +const {ORIGIN, PROMOTION, verifyPacketFiles, loadInputs, installLocalRoutes} = require('./rust-browser-inputs.cjs'); +const reviewedPacket = verifyPacketFiles(__dirname); +assert.equal(require('playwright/package.json').version, reviewedPacket.playwright_version); const {chromium} = require('playwright'); test('Rust artifact CSP and navigation compatibility', {timeout: 150000}, async () => { const root = path.resolve(process.argv[2]); const packet = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); const inventory = JSON.parse(fs.readFileSync(process.argv[4], 'utf8')); - assert.equal(packet.rule.enabled, false); - assert.equal(packet.rollout_approved, false); - assert.equal(crypto.createHash('sha256').update(JSON.stringify(inventory, null, 2) + '\n').digest('hex'), packet.inventory_sha256); - const headers = Object.fromEntries(Object.entries(packet.rule.action_parameters.headers).map(([name, record]) => [name, record.value])); - const mime = {'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf'}; - const server = http.createServer((request, response) => { - const pathname = decodeURIComponent(new URL(request.url, 'http://localhost').pathname); - if (pathname === '/__frame_probe') { - response.writeHead(200, {'content-type': 'text/html'}); - response.end('Framing control'); - return; - } - let file = path.resolve(root, '.' + pathname); - if (file !== root && !file.startsWith(root + path.sep)) { response.writeHead(403); response.end(); return; } - if (fs.existsSync(file) && fs.statSync(file).isDirectory()) file = path.join(file, 'index.html'); - if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { response.writeHead(404); response.end(); return; } - response.writeHead(200, {...headers, 'content-type': mime[path.extname(file)] || 'application/octet-stream', 'cache-control': 'no-store'}); - fs.createReadStream(file).pipe(response); - }); - await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); - const origin = `http://127.0.0.1:${server.address().port}`; + const input = loadInputs(root, packet, inventory); + const headers = input.headers; + const origin = ORIGIN; const browser = await chromium.launch({headless: true}); try { - const context = await browser.newContext(); - const unexpected = []; - const allowed = new Set(['https://github.com', 'https://img.shields.io', 'https://sonarcloud.io', 'https://static.cloudflareinsights.com', 'https://cloudflareinsights.com', 'https://cloud.durable-workflow.com']); - await context.route('**/*', async (route) => { - const url = new URL(route.request().url()); - if (url.origin === origin) return route.continue(); - if (!allowed.has(url.origin)) { unexpected.push(url.origin + url.pathname); return route.abort(); } - // Public badges/telemetry are intercepted: this local check sends no production request. - if (route.request().resourceType() === 'image') return route.fulfill({contentType: 'image/svg+xml', body: ''}); - return route.fulfill({status: 200, contentType: 'text/javascript', body: ''}); - }); + assert.equal(browser.version(), reviewedPacket.expected_chromium_version); + const context = await browser.newContext({serviceWorkers: 'block'}); + context.setDefaultTimeout(10000); + const audit = await installLocalRoutes(context, input); await context.addInitScript(() => { window.__policyViolations = []; document.addEventListener('securitypolicyviolation', (event) => window.__policyViolations.push({directive: event.effectiveDirective, blocked: event.blockedURI})); @@ -104,7 +81,8 @@ test('Rust artifact CSP and navigation compatibility', {timeout: 150000}, async }); await page.waitForTimeout(500); assert.ok(frameMessages.length > 0, 'response frame-ancestors policy must block the framing control'); - assert.deepEqual(unexpected, []); + assert.deepEqual(audit.refused, []); + assert.equal(audit.kinds['beacon-fixture'], undefined); console.log(JSON.stringify({ outcome: 'pass', scope: 'Local artifact CSP compatibility only; no TLS/HSTS or public-rollout acceptance', browser: browser.version(), pages_checked: pages.size, inline_hash_representatives: representatives.size, @@ -113,10 +91,98 @@ test('Rust artifact CSP and navigation compatibility', {timeout: 150000}, async inventory_sha256: packet.inventory_sha256, csp_sha256: crypto.createHash('sha256').update(headers['content-security-policy']).digest('hex'), checks: ['all inventoried inline-script hash representatives', 'search', 'history back', 'direct-entry fallback', 'compact navigation', 'unapproved inline script refused', 'response framing refused'], - external_requests: 'intercepted locally; none sent to production', + external_requests: 'fulfilled or refused locally; container network must be disabled', + request_audit: audit, + vendor_and_production_telemetry: 'not tested', })); } finally { await browser.close(); - await new Promise((resolve) => server.close(resolve)); + } +}); + +async function instrumentTelemetry(context, nonWebdriver) { + // Harness-only instrumentation. Native fetch still applies CSP and CORS. + await context.addInitScript(({nonWebdriver, promotionUrl}) => { + if (nonWebdriver) Object.defineProperty(Navigator.prototype, 'webdriver', {get: () => false, configurable: true}); + window.__policyViolations = []; + window.__promotionFetchOptions = []; + document.addEventListener('securitypolicyviolation', (event) => window.__policyViolations.push(event.effectiveDirective)); + const nativeFetch = window.fetch; + window.fetch = function (resource, options) { + if (String(resource) === promotionUrl) { + window.__promotionFetchOptions.push({method: options.method, mode: options.mode, credentials: options.credentials, referrerPolicy: options.referrerPolicy, body: options.body}); + } + return nativeFetch.call(this, resource, options); + }; + }, {nonWebdriver, promotionUrl: PROMOTION}); +} + +async function checkPromotionFixture(page, audit) { + await page.locator('[data-promotion-source="sdk-rust-reference"]').scrollIntoViewIfNeeded(); + await page.waitForFunction(() => window.__promotionFetchOptions.length === 1 && window.__localBeaconFixture === true); + const action = page.locator('[data-promotion-action="early-access"]'); + // Exercise the real click listener without navigating to the production application. + await action.evaluate((element) => element.addEventListener('click', (event) => event.preventDefault())); + await action.click(); + await page.waitForFunction(() => window.__promotionFetchOptions.length === 2); + await page.waitForLoadState('networkidle'); + const calls = await page.evaluate(() => window.__promotionFetchOptions); + assert.deepEqual(calls.map((call) => JSON.parse(call.body).event), ['impression', 'click']); + for (const call of calls) { + assert.equal(call.method, 'POST'); + assert.equal(call.mode, 'cors'); + assert.equal(call.credentials, 'omit'); + assert.equal(call.referrerPolicy, 'no-referrer'); + } + for (const kind of ['promotion-impression', 'promotion-click']) assert.equal(audit.kinds[kind], 1, kind); +} + +async function checkTelemetryControl(browser, input, control) { + const context = await browser.newContext({serviceWorkers: 'block', viewport: {width: 1280, height: 900}}); + context.setDefaultTimeout(10000); + const audit = await installLocalRoutes(context, input, control.origin); + try { + await instrumentTelemetry(context, control.nonWebdriver); + const page = await context.newPage(); + const response = await page.goto(control.origin + control.route, {waitUntil: 'networkidle'}); + assert.equal(response.status(), 200); + assert.equal(await page.evaluate(() => navigator.webdriver), !control.nonWebdriver); + if (control.name === 'synthetic-promotion-dom') await checkPromotionFixture(page, audit); + else { + assert.deepEqual(await page.evaluate(() => window.__promotionFetchOptions), []); + for (const kind of ['promotion-impression', 'promotion-click']) assert.equal(audit.kinds[kind], undefined, kind); + } + for (const kind of ['beacon-fixture', 'rum-fixture']) assert.equal(audit.kinds[kind], control.beaconExpected ? 1 : undefined, kind); + assert.deepEqual(await page.evaluate(() => window.__policyViolations), []); + if (control.name === 'current-artifact') { + assert.equal(await page.locator('[data-promotion-source]').count(), 0); + await page.evaluate(async () => { try { await fetch('https://unreviewed.invalid/blocked'); } catch { /* Expected CSP refusal. */ } }); + await page.waitForFunction(() => window.__policyViolations.includes('connect-src')); + } + assert.deepEqual(audit.refused, []); + return {control: control.name, audit}; + } finally { + await context.close(); + } +} + +test('Rust analytics branches under local CSP with finite telemetry fixtures', {timeout: 90000}, async () => { + const root = path.resolve(process.argv[2]); + const packet = JSON.parse(fs.readFileSync(process.argv[3], 'utf8')); + const inventory = JSON.parse(fs.readFileSync(process.argv[4], 'utf8')); + const input = loadInputs(root, packet, inventory); + const browser = await chromium.launch({headless: true}); + const results = []; + try { + assert.equal(browser.version(), reviewedPacket.expected_chromium_version); + for (const control of [ + {name: 'current-artifact', origin: ORIGIN, route: '/durable_workflow/index.html', nonWebdriver: true, beaconExpected: true}, + {name: 'synthetic-promotion-dom', origin: ORIGIN, route: '/__telemetry_fixture', nonWebdriver: true, beaconExpected: true}, + {name: 'webdriver-refusal', origin: ORIGIN, route: '/__telemetry_fixture', nonWebdriver: false, beaconExpected: false}, + {name: 'other-host-refusal', origin: 'https://example.invalid', route: '/__telemetry_fixture', nonWebdriver: true, beaconExpected: false}, + ]) results.push(await checkTelemetryControl(browser, input, control)); + console.log(JSON.stringify({outcome: 'pass', scope: 'Local artifact loader and synthetic promotion/CSP branches only; vendor beacon and production delivery not tested', input_artifact: packet.input_artifact, inventory_sha256: packet.inventory_sha256, controls: results, artifact_bytes_modified: false, network_required: 'none', instrumentation: ['non-webdriver getter in controlled contexts', 'promotion fetch-option observation delegating to native fetch', 'synthetic promotion DOM with real unchanged analytics script', 'prevent default external click navigation', 'synthetic beacon and rum responses']})); + } finally { + await browser.close(); } }); diff --git a/security/browser-headers/rust-local-browser-packet.json b/security/browser-headers/rust-local-browser-packet.json new file mode 100644 index 00000000000..f9557dd2678 --- /dev/null +++ b/security/browser-headers/rust-local-browser-packet.json @@ -0,0 +1,36 @@ +{ + "schema": "durable-workflow.local-browser-review-packet/v1", + "run_status": "not-run", + "input_artifact": { + "repository": "durable-workflow/sdk-rust", + "run_id": 41939116087, + "artifact_id": 13375154247, + "tooling_revision": "1ea3bfc3965423534b5bbbed700371f126835fc3", + "zip_bytes": 4071555, + "zip_sha256": "ff902191bdb90f7f1d047ecc5d27e9b8c5215a0239e4f11fcbf574f7ddab83d1" + }, + "playwright_version": "1.64.0", + "expected_chromium_version": "156.0.8078.4", + "container": { + "image": "mcr.microsoft.com/playwright@sha256:bc72a8df40831ded821e3cbc79c318c21b44d6e51dff9fd89ace7a8249c5b462", + "config_sha256": "847f71bbb3f17fc1032d50d75bf6fb3fdcf188b16dff81f24667fe9ae9409e01", + "platform": "linux/amd64", + "cpus": 2, + "memory_bytes": 2147483648, + "pids": 256, + "network": "none", + "deadline_seconds": 300, + "kill_grace_seconds": 10, + "max_active_browsers": 1 + }, + "files": { + "rust-browser.test.cjs": "9a9854690a585d5589c17a99b6a92e83b7797289abe3aa3c776268d8612c07ae", + "rust-browser-inputs.cjs": "d3ed02b8e6d392b472e3e893a8464cb1ab4255be49abaf5ac262da8b71aee422", + "rust-browser-source.test.cjs": "42c86ac5eac34b71388ac4f0943ca55000fafa31243304a7b2f317a7f55be55c", + "rust-policy.json": "a357bcc5ea8ad96db5e601e4e1c1cb3c8c7eb3441caa9b331d0c2a906ffe84d8", + "rust-inventory.json": "8c4bc2eb1ef3ffeeb9788a9d0b44d7b718a2a56caff55b3d4531716a25b270d7", + "rust-rule.candidate.json": "bce0c66db4454bc16941a8f5dadb04706315e673d5e2c33f677e7728bafd0135", + "runtime/package.json": "a2cd697a8ed17ab196e4eac1bcb15ceec6da243a600a8f2584c8aabdadfc3c9d", + "runtime/package-lock.json": "959580e91aae43c61d0bbfcf11c21eb52ecafcfadb088f166b322fd3988a47ae" + } +} diff --git a/security/browser-headers/rust-local-browser-plan.md b/security/browser-headers/rust-local-browser-plan.md new file mode 100644 index 00000000000..73368385a56 --- /dev/null +++ b/security/browser-headers/rust-local-browser-plan.md @@ -0,0 +1,137 @@ +# Bounded Rust browser and telemetry check + +This is an **unrun source packet** for the next local step in +[organization issue 268](https://github.com/durable-workflow/.github/issues/268). +It follows [Docs #476's accepted main and Pages delivery](https://github.com/durable-workflow/durable-workflow.github.io/pull/476#issuecomment-6647323438). +General must separately admit the exact preparation and one browser attempt after +Security review. This file, its manifest and normal CI do not authorize that run. +No provider, credential, DNS, proxy, TLS, response-header, public telemetry or +production operation is proposed here. + +## Inputs and provenance + +`rust-local-browser-packet.json` binds the executable/data closure and exact +runtime. Its artifact tuple is the accepted SDK #209 input already recorded in +`rust-policy.json`: artifact 13375154247, ZIP SHA-256 +`ff902191bdb90f7f1d047ecc5d27e9b8c5215a0239e4f11fcbf574f7ddab83d1`, +484 files / 372 HTML pages / 49 inline-script hashes. Public Rust 4.12.0, crate +source and tooling identities are preserved. `rust-rule.candidate.json` and all +policy/approval/browser-result files remain unchanged and disabled/unrun. + +The proposed runtime is the existing Playwright 1.64.0 generation, pinned to the +official **linux/amd64** platform manifest in the packet. Registry metadata was +read without pulling or running that browser image. `runtime/package-lock.json` +records the two exact npm archives and integrity values. Only package-lock resolution, advisory metadata and inert source checks were +performed during this preparation. No +Playwright package, browser image or browser was executed for the source packet. + +After separate admission, prepare a dedicated public dependency directory with +`runtime/package.json` and its lock. Use `npm ci --ignore-scripts --no-audit +--no-fund` with `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`. Do not run lifecycle hooks, +`playwright install`, `npx` downloads or an image build. Verify the approved image +manifest/config, library version and lock integrity. Mount only its prepared +`node_modules` directory into the browser cell. No home directory, credentials, +Docker socket or unrelated repository is an input. An unavailable dependency, +different platform or runtime mismatch stops the attempt for review. + +Before execution, independently verify the ZIP digest and safe regular-file +membership, recheck the accepted protected-run/deployment identity and compare +live `__build.json` with the exact artifact. Those are finite read-only checks. +Stop if the live input has changed, the artifact has expired, or a required input +is unavailable. Do not dispatch a replacement build or silently retarget the +packet. The input API expires this ZIP on November 6, 2026 at 00:09:56 UTC. + +## What the proposed check exercises + +The existing `rust-browser.test.cjs` keeps its 54-page/hash-representative, +search, history, direct-entry, compact navigation, inline-script and framing +controls. Its local routing now fulfills verified in-memory artifact buffers +under the public hostname. It never forwards or fetches a request. Service +workers are blocked, WebSockets are closed, unknown requests are refused, and +each context has a finite request budget. `--network=none` is the independent +container boundary. Using a synthetic HTTPS URL does not exercise real TLS or +HSTS. Image responses are finite fixtures and do not validate badge providers. + +The current artifact has **no promotion DOM in any HTML page**. The real-page +non-webdriver control therefore requires no promotion request. A distinct, +labelled synthetic page supplies the expected promotion elements and loads the +unchanged artifact's `analytics/analytics.js`. Only that fixture exercises the +existing impression/click handlers. Separate controls retain the webdriver and +wrong-host refusals, and an unapproved connection must fail CSP. + +The controlled non-webdriver getter, native-fetch option observer and prevention +of external click navigation are harness instrumentation. They do not alter any +artifact file. Promotion requests must have the exact two-key source/event body, +POST/text/plain, omitted credentials and no-referrer policy. Authorization and +cookies are refused for every intercepted request. The Cloudflare beacon module +and RUM response are **synthetic fixtures**, used only to exercise the loader and +CSP connection paths. They establish neither vendor-script compatibility nor +production delivery. No request reaches Cloud or Cloudflare. + +Normal CI runs only `rust-browser-source.test.cjs` with fake routes and temporary +input files, plus the existing header-policy controls. It never imports +Playwright, launches a browser or runs the optional browser entrypoint. + +## Proposed execution and cleanup + +Documentation owns one attempt and cleanup. General names the admitted executor +and host after fresh capacity and competing-work checks. The proposal is one +native amd64 container, at most **2 CPUs / 2 GiB / 256 PIDs**, two sequential +tests and at most one active browser. Test deadlines are 150 and 90 seconds; +the external container deadline is 300 seconds with a ten-second kill grace. +There is no automatic retry, host IPC, privileged mode, extra capability or +published port. The writable tmpfs and private shared memory count within the +container's memory cap. This is a proposed budget, not a reservation. + +The following is the exact proposed launch shape after admission. Assign the +three absolute input paths from the reviewed public-input preparation; reject +commas/newlines in those paths. `packet_directory` contains only this public +packet. `runtime_modules` is the prepared public dependency tree. Do not point +either at a home or credential directory. The pinned image must already exist; +`--pull=never` prevents an implicit network preparation during the attempt. + +```sh +set -eu +container_id= +cleanup() { + if [ -n "$container_id" ]; then docker rm --force "$container_id"; fi +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +container_id=$(docker create --pull=never --platform linux/amd64 \ + --name durable-workflow-github-io-rust-csp-telemetry \ + --init --user pwuser --cpus=2 --memory=2g --pids-limit=256 \ + --network=none --read-only --cap-drop=ALL \ + --security-opt=no-new-privileges:true --shm-size=256m \ + --tmpfs /tmp:rw,size=536870912,mode=1777 \ + --mount "type=bind,source=$public_artifact_directory,target=/site,readonly" \ + --mount "type=bind,source=$packet_directory,target=/packet,readonly" \ + --mount "type=bind,source=$runtime_modules,target=/runtime/node_modules,readonly" \ + --env NODE_PATH=/runtime/node_modules \ + mcr.microsoft.com/playwright@sha256:bc72a8df40831ded821e3cbc79c318c21b44d6e51dff9fd89ace7a8249c5b462 \ + node /packet/rust-browser.test.cjs /site \ + /packet/rust-rule.candidate.json /packet/rust-inventory.json) +timeout --signal=TERM --kill-after=10s 300s docker start --attach "$container_id" +exit_code=$(docker inspect --format '{{.State.ExitCode}}' "$container_id") +exit "$exit_code" +``` + +The cleanup trap removes only the ID created by this invocation; a name collision +fails rather than deleting an existing container. The admitted executor must +independently verify absence afterward. A timeout, refused request, changed +input, browser error, budget violation, missing output or cleanup failure is +nonpassing evidence. Preserve the original result and stop for review. Do not +weaken CSP, increase permissions, fetch a new runtime or retry automatically. + +Record the exact source commit/tree, packet-file hashes, runtime identities, +artifact tuple, timestamps, test outputs, request audits, exit/timeout status +and independent cleanup observations on issue 268. Keep diagnostic logs bounded +and remove temporary material after review. Do not edit the accepted result +files to claim a pass before an actual admitted result and independent review. +Public telemetry, real TLS/HSTS and provider rollout acceptance stay separate. + +Runtime references: +[official image contents and package separation](https://playwright.dev/docs/docker), +[request interception](https://playwright.dev/docs/network), +[browser-context routes](https://playwright.dev/docs/api/class-browsercontext#browser-context-route). From 5c376e42370c03111f665644a367e2bfe5df2f1c Mon Sep 17 00:00:00 2001 From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:03:44 +0000 Subject: [PATCH 2/2] test: clarify exact local origins and browser packet controls --- .../browser-headers/rust-browser-inputs.cjs | 20 +++++++++++-------- .../rust-browser-source.test.cjs | 8 ++++++++ .../browser-headers/rust-browser.test.cjs | 2 +- .../rust-local-browser-packet.json | 6 +++--- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/security/browser-headers/rust-browser-inputs.cjs b/security/browser-headers/rust-browser-inputs.cjs index 9aebc75b840..8ebd526ae6c 100644 --- a/security/browser-headers/rust-browser-inputs.cjs +++ b/security/browser-headers/rust-browser-inputs.cjs @@ -71,26 +71,30 @@ function promotionEvent(request) { assert.match(headers['content-type'] || '', /^text\/plain(?:;|$)/); assert.ok(typeof request.body === 'string' && request.body.length < 256, 'Promotion body exceeds its bound'); const event = JSON.parse(request.body); - assert.deepEqual(Object.keys(event).sort(), ['event', 'source']); + assert.deepEqual(Object.keys(event).toSorted((left, right) => left.localeCompare(right, 'en')), ['event', 'source']); assert.equal(event.source, 'sdk-rust-reference'); assert.ok(['impression', 'click'].includes(event.event), 'Unknown promotion event'); return event.event; } +function artifactResponse(input, url) { + const relative = decodeURIComponent(url.pathname).replace(/^\//, ''); + if (relative === '__frame_probe') return {kind: 'frame-probe', status: 200, headers: {'content-type': 'text/html'}, body: 'Framing control'}; + if (relative === '__telemetry_fixture') return {kind: 'promotion-dom-fixture', status: 200, headers: {...input.headers, 'content-type': 'text/html'}, body: 'Synthetic promotion control'}; + const file = [relative, relative.replace(/\/$/, '') + '/index.html', relative ? null : 'index.html'].find((name) => name && input.files.has(name)); + if (!file) return {kind: 'missing', status: 404, body: ''}; + return {kind: 'artifact', status: 200, headers: {...input.headers, 'content-type': MIME[path.extname(file)] || 'application/octet-stream', 'cache-control': 'no-store'}, body: input.files.get(file)}; +} + function localResponse(input, request, origin = ORIGIN) { - assert.ok([ORIGIN, 'https://example.invalid'].includes(origin), 'Unexpected local origin'); + if (origin !== ORIGIN && origin !== 'https://example.invalid') throw new Error('Unexpected local origin'); const requestHeaders = Object.fromEntries(Object.entries(request.headers).map(([name, value]) => [name.toLowerCase(), value])); assert.ok(!Object.hasOwn(requestHeaders, 'authorization'), 'Local requests cannot carry authorization'); assert.ok(!Object.hasOwn(requestHeaders, 'cookie'), 'Local requests cannot carry cookies'); const url = new URL(request.url); if (url.origin === origin) { assert.equal(request.method, 'GET', 'Artifact requests must be GET'); - const relative = decodeURIComponent(url.pathname).replace(/^\//, ''); - if (relative === '__frame_probe') return {kind: 'frame-probe', status: 200, headers: {'content-type': 'text/html'}, body: 'Framing control'}; - if (relative === '__telemetry_fixture') return {kind: 'promotion-dom-fixture', status: 200, headers: {...input.headers, 'content-type': 'text/html'}, body: 'Synthetic promotion control'}; - const file = [relative, relative.replace(/\/$/, '') + '/index.html', relative ? null : 'index.html'].find((name) => name && input.files.has(name)); - if (!file) return {kind: 'missing', status: 404, body: ''}; - return {kind: 'artifact', status: 200, headers: {...input.headers, 'content-type': MIME[path.extname(file)] || 'application/octet-stream', 'cache-control': 'no-store'}, body: input.files.get(file)}; + return artifactResponse(input, url); } const cors = {'access-control-allow-origin': origin}; if (request.url === BEACON && request.method === 'GET') { diff --git a/security/browser-headers/rust-browser-source.test.cjs b/security/browser-headers/rust-browser-source.test.cjs index 8bc8133b815..f3ff6430fd9 100644 --- a/security/browser-headers/rust-browser-source.test.cjs +++ b/security/browser-headers/rust-browser-source.test.cjs @@ -68,6 +68,14 @@ test('promotion fixtures require exact method, event shape and credential absenc ]) assert.throws(() => localResponse(input, {...valid, ...change})); }); +test('local-origin selection refuses deceptive hosts and added URL components', (t) => { + const value = fixture(t); + const input = loadInputs(value.root, value.packet, value.inventory); + for (const origin of [ORIGIN + '.invalid', ORIGIN + '@unreviewed.invalid', 'https://unreviewed.invalid/' + ORIGIN, ORIGIN + ':444', ORIGIN + '/path', 'http://rust.durable-workflow.com']) { + assert.throws(() => localResponse(input, request(ORIGIN + '/'), origin), /Unexpected local origin/); + } +}); + test('fake routing fulfills or aborts without a network continuation', async (t) => { const value = fixture(t); const input = loadInputs(value.root, value.packet, value.inventory); diff --git a/security/browser-headers/rust-browser.test.cjs b/security/browser-headers/rust-browser.test.cjs index 32ee55b0276..76486a8efbe 100644 --- a/security/browser-headers/rust-browser.test.cjs +++ b/security/browser-headers/rust-browser.test.cjs @@ -5,8 +5,8 @@ const path = require('node:path'); const test = require('node:test'); const {ORIGIN, PROMOTION, verifyPacketFiles, loadInputs, installLocalRoutes} = require('./rust-browser-inputs.cjs'); const reviewedPacket = verifyPacketFiles(__dirname); -assert.equal(require('playwright/package.json').version, reviewedPacket.playwright_version); const {chromium} = require('playwright'); +test.before(() => assert.equal(require('playwright/package.json').version, reviewedPacket.playwright_version)); test('Rust artifact CSP and navigation compatibility', {timeout: 150000}, async () => { const root = path.resolve(process.argv[2]); diff --git a/security/browser-headers/rust-local-browser-packet.json b/security/browser-headers/rust-local-browser-packet.json index f9557dd2678..f7c0422d199 100644 --- a/security/browser-headers/rust-local-browser-packet.json +++ b/security/browser-headers/rust-local-browser-packet.json @@ -24,9 +24,9 @@ "max_active_browsers": 1 }, "files": { - "rust-browser.test.cjs": "9a9854690a585d5589c17a99b6a92e83b7797289abe3aa3c776268d8612c07ae", - "rust-browser-inputs.cjs": "d3ed02b8e6d392b472e3e893a8464cb1ab4255be49abaf5ac262da8b71aee422", - "rust-browser-source.test.cjs": "42c86ac5eac34b71388ac4f0943ca55000fafa31243304a7b2f317a7f55be55c", + "rust-browser.test.cjs": "0b2f731db13e4f095230673bfda3d97a36f71c5ef62b8f3702a7978740d17d61", + "rust-browser-inputs.cjs": "43bb050185f05187e7d94a40d73b4fae965276696fe7356e6609b319c6b120ee", + "rust-browser-source.test.cjs": "deafc5ce08806c148b189a48f11012cc0c03600122dd8fe34eb01e223f381952", "rust-policy.json": "a357bcc5ea8ad96db5e601e4e1c1cb3c8c7eb3441caa9b331d0c2a906ffe84d8", "rust-inventory.json": "8c4bc2eb1ef3ffeeb9788a9d0b44d7b718a2a56caff55b3d4531716a25b270d7", "rust-rule.candidate.json": "bce0c66db4454bc16941a8f5dadb04706315e673d5e2c33f677e7728bafd0135",