test(access): add privacy acceptance gate [skip ci]
Local privacy gate and focused evidence.
This commit is contained in:
1 parent
120f3c0306
commit
f3d60cc779
9 files changed
+287
-28
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# ADR-0002: Persistent Rooms And Scoped Viewer Access
|
||||
|
||||
- Status: Accepted; room access deployed, site-access ordering source-only
|
||||
- Status: Accepted; access boundary deployed, local privacy gate source-only
|
||||
- Date: 2026-08-19
|
||||
|
||||
## Context
|
||||
@@ -237,11 +237,23 @@ SQLite or participates in authorization, routing, or quality decisions.
|
||||
|
||||
## Implementation Status
|
||||
|
||||
The deployed 2026-08-20 boundary implements the single-version v2 runtime,
|
||||
private/public room policy, fragment consumption, room passwords and
|
||||
commit-first grant teardown on SQLite v3. The current source-only candidate adds
|
||||
the ordered site-access check for every code-only Viewer and atomically renames
|
||||
the environment, HTTP endpoint, cookie, Web/Native consumers and nginx limiter.
|
||||
Focused tests cover valid grant direct entry, public/password denial without
|
||||
site access, public/password entry with site access, config fail-fast and the
|
||||
Native wire contract. Deployment and production request/log inspection remain.
|
||||
The deployed boundary implements the single-version v2 runtime, ordered site
|
||||
access for code-only Viewers, private/public room policy, fragment consumption,
|
||||
room passwords, and commit-first grant teardown on SQLite v3.
|
||||
|
||||
The source-only `npm run gate:access-privacy` gate builds the current Web client,
|
||||
uses an ephemeral real-Chrome context against a loopback persistent room, and
|
||||
reports only fixed booleans and counts. It proves that the fragment is consumed
|
||||
through `replaceState` before `DOMContentLoaded`, the grant appears only under
|
||||
that room's `sessionStorage` key, and the run's site password plus grant do not
|
||||
appear in observed request/referrer records, the tracked nginx combined-log
|
||||
model, or captured application diagnostics. None of those values or the room
|
||||
password appears raw in temporary SQLite files. The room password enters at the
|
||||
RoomStore boundary, so this gate makes no claim about its browser/WebSocket/log
|
||||
ingress. It also runs the complete signaling test file containing the strong
|
||||
rotate/revoke media-edge teardown case.
|
||||
|
||||
This local headless gate neither reads production credentials nor inspects a
|
||||
production request log, nginx log, service journal, database, or headful browser.
|
||||
Those exact-environment checks and a production rotation/revoke remain separate
|
||||
acceptance work.
|
||||
@@ -310,6 +310,17 @@ Only `POST /api/site-access` accepts the site access secret in an
|
||||
`__Host-` name. The cookie contains no account or server-side session ID. There
|
||||
is no account database, JWT, session map, or logout endpoint.
|
||||
|
||||
Before an access-boundary release, set `CHROME_PATH` to a local Chrome or
|
||||
Chromium executable and run `npm run gate:access-privacy`. The gate creates only
|
||||
a loopback room and temporary SQLite/profile state, navigates the fragment
|
||||
through CDP rather than a process argument, disposes its ephemeral browser
|
||||
context, and emits only fixed booleans and counts. It also checks the tracked
|
||||
nginx logging shape and runs the complete signaling suite containing the strong
|
||||
rotate/revoke teardown case. The room password enters only at the RoomStore
|
||||
boundary, so this run proves its raw SQLite non-persistence, not its WebSocket or
|
||||
log ingress. It never connects to production or accepts a production credential;
|
||||
exact production request/nginx/journal/database inspection remains separate.
|
||||
|
||||
nginx limits this exact endpoint per source at `5r/m` with `burst=5 nodelay` and
|
||||
returns 429 when exhausted. The limiter uses nginx shared memory; do not add the
|
||||
secret, Authorization header, request body, or a new source-address field to
|
||||
|
||||
@@ -48,7 +48,7 @@ Last updated: 2026-08-21
|
||||
- Web relay is cap2 with one upstream; child3 rejects and no UA/visibility split exists.
|
||||
- Four services are active/running at `NRestarts=0`; local/public health are 200. SQLite v3 has five rooms, SHA-256 `aef724ae52c4107be8ec8791e72f8dcaa11b0ec849fb432d022e2cfb9cfe0974`, owner/mode `screener:screener`/0600.
|
||||
- Production keeps ordinary ICE STUN-only, SFU roots <=2, selected-edge UDP TTL 120, and one pending/answered `peer-selected` attempt per room excluding Host ingress. This rollout ran no browser/media/SFU/TURN/performance canary; earlier exact-`16f6eab` evidence proves active Host ingress only.
|
||||
- Production requires the independent site-access secret through `SITE_ACCESS_PASSWORD`. Its stateless cookie authorizes creation/Host and permits code-only Viewer attempts; valid private fragment grants remain direct. Public-watch accepts site access plus code, while private code-only entry additionally requires the room password. The env, endpoint, cookie, and nginx limiter naming migrated atomically; anonymous failures stay neutral, and there are no accounts/JWT/session rows.
|
||||
- Production requires independent `SITE_ACCESS_PASSWORD`: its stateless cookie authorizes creation/Host and code-only Viewer attempts; private grants stay direct, while public/private code entry needs site access alone/plus room password. Env/endpoint/cookie/nginx naming migrated atomically; failures remain neutral; there are no accounts/JWT/session rows. Local `gate:access-privacy` proves fragment isolation, site/grant transport-log absence, and raw room-password SQLite absence; room-password WebSocket/log ingress and live/headful evidence remain open.
|
||||
- Production uses nginx, Node.js 24.19.0, coturn 4.17.2, and LiveKit 1.13.5 on UDP 7882 (TCP fallback off). Ordinary peer ICE receives no TURN; the selected-edge tuple is configured only for the current controller edge, with no credential pre-advertised to ordinary peers. Coturn retains the old authenticated-relay ports. Services are healthy with zero restarts.
|
||||
- ADR-0005 rejects `PEER_ASSISTED_ROOM_IDS`; `PEER_ASSISTED_MEDIA=true` enables every room and ordinary peers stay STUN-only. Initial or active Host ingress may consume one bound relay-only grant; ready/abort clears it and failure restores peer baseline. Stale room-ID config fails startup; `screener-v2` is the only deployed wire.
|
||||
- Web SFU uses LiveKit 2.22.0 `q,h`; local SFU/UDP and selected-TURN ingress work, while BWE remains open. P2P now reapplies its selected profile after each answer on the same sender. A local A/B supports the lifecycle candidate, but natural ramp, real capture, production, and SFU validation remain open.
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ Parallel flagship work; minimum relevant checks and rollback proof. Research plu
|
||||
## Current Snapshot
|
||||
|
||||
- Capture precedes room creation; source/quality changes and AV pause preserve peers. Recommended profiles and advanced defaults use `balanced`; clarity/fluid remain explicit. P2P reapplies the selected profile after each answer on the same sender; the browser still owns degradation and stats/readback expose it.
|
||||
- Production access is `screener-v2`: a valid private fragment grant enters directly; every code-only Viewer must first hold site access, after which public-watch accepts the code and private rooms still require their room password. `SITE_ACCESS_PASSWORD`, `/api/site-access`, and the site-access cookie are deployed atomically; raw credentials are never stored.
|
||||
- Production `screener-v2` admits private fragment grants directly. Code-only Viewers need site access, then public rooms accept the code while private rooms also require their password. `SITE_ACCESS_PASSWORD`, `/api/site-access`, and its cookie are deployed atomically; raw credentials are never stored.
|
||||
- Source UI uses adjacent nickname editing, distinct site/room prompts, and a shared room-code form. Status and details stay neutral before media proof; current evidence labels green P2P/yellow `SFU fallback`, while relay evidence adds `TURN` plus only a locally observed protocol.
|
||||
- Native v2 is source-only (memory rooms, 300s reclaim); it follows authenticated direct-child assignments up to two and fails unsupported SFU/selected ingress boundedly.
|
||||
- Production is roots <=2/Web relay <=2; every Web Viewer keeps one upstream and bounded sticky recovery, while child3 is rejected. Room `1` is historical; HTTPS/WSS is TLS/TCP.
|
||||
@@ -32,7 +32,7 @@ Parallel flagship work; minimum relevant checks and rollback proof. Research plu
|
||||
- Chrome 151 loopback proves H.264 negotiation/decode interoperability, not performance. Production orders H.264 first and keeps browser repair/fallback codecs.
|
||||
- Native WGC/MF used no browser capture/encoder. One Chrome Viewer rendered 203 1280x720 frames and received 500 Opus packets; process/LUID-correlated `VideoEncode` was nonzero, with no fatal/encoder errors.
|
||||
- Native Win11 audio is source-only/default-off: target isolation was 4018x and one Viewer got 495 Opus packets. Download, game sync, Win10, and other routes remain open.
|
||||
- Access protocol/config/HTTP/storage/SQLite/signaling focused tests pass, including commit-first teardown and v1 rollback.
|
||||
- Local gate proves pre-DOM fragment clearing, room-scoped session isolation, no site/grant transport-log hits, and no raw room-password SQLite sentinel; rotate/revoke tests pass. Headless loopback only.
|
||||
- Native wire/session tests enforce at most two authoritative children, no presence-created edge, stale-revision ignore, and one bounded unsupported-route failure per revision.
|
||||
- Production caps pending/answered `peer-selected` at one per room; Host ingress is independent. Focused admission/release/rollback/STUN-only tests pass; earlier Chrome proves active Host-ingress function only.
|
||||
- Exact `1331fbdd` passed fresh typecheck, builds, hygiene and 32 files/473 tests. Bounded runtime, UTF-8 artifact, inode and deploy gates preserved config/nft/DB hashes and four zero-restart services.
|
||||
@@ -46,7 +46,7 @@ Parallel flagship work; minimum relevant checks and rollback proof. Research plu
|
||||
- ADR-0006 has one Viewer proof; two-edge/FIFO, hardware, endurance, public/downloaded-package, and browser-diversity proof remain open.
|
||||
- Production fanout is Host2/Viewer2 and rejects child3. Relay resource behavior remains open.
|
||||
- Active SFU already publishes `q,h`; only BWE/resource/zero-child/root gates are unverified. No local per-leaf UDP shaper; resume with Linux `tc` or public canary, never CDP. Web P2P/SVC shortcuts remain no-go.
|
||||
- Headful fragment-to-sessionStorage consumption, invitation rotation after migration, and request/log leak inspection remain production UX gates. Accounts stay out.
|
||||
- Access remains unverified headfully and in production: fragment consumption, invitation rotation/revoke, and exact request/nginx/journal/SQLite leak inspection. The source gate is loopback-only; accounts stay out.
|
||||
- Structure debt: split `HybridMediaRouter`, `HostPage`, `SignalingServer`, and `ViewerPage` only at proven consumer boundaries, never by file length.
|
||||
|
||||
## Next Milestone
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"build:server": "tsc -p tsconfig.server.json",
|
||||
"start": "node dist/server/server/index.js",
|
||||
"benchmark:peer-assisted": "tsx scripts/peer-assisted-benchmark.ts",
|
||||
"gate:access-privacy": "npm run build:client && vitest run tests/server-signal.test.ts && tsx scripts/access-privacy-gate.ts",
|
||||
"gate:turn-allocation": "tsx scripts/turn-allocation-gate.ts",
|
||||
"gate:native-one-viewer": "tsx scripts/native-one-viewer-gate.ts",
|
||||
"typecheck": "tsc -b --pretty false",
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { access, mkdtemp, readFile, readdir } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { createRoomResponseSchema } from "../src/shared/protocol";
|
||||
import { createScreenerServer, type ScreenerServer } from "../src/server/app";
|
||||
import type { ServerConfig } from "../src/server/config";
|
||||
import { CdpConnection, cleanupRun, createPage, evaluate, reservePort, waitForVersion } from "./native-one-viewer-gate";
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
const nginxConfigPath = resolve(root, "deploy/nginx/share.bonfire.icu.conf.example");
|
||||
const teardownTest = "strongly rotates and revokes every Viewer generation and media edge";
|
||||
const fragmentProbe = `(() => {
|
||||
let domContentLoaded = false;
|
||||
const state = { fragmentAtStart: location.hash.length > 0, replaced: false, beforeDomContentLoaded: false };
|
||||
document.addEventListener("DOMContentLoaded", () => { domContentLoaded = true; }, { once: true });
|
||||
const replaceState = history.replaceState;
|
||||
history.replaceState = function(...args) {
|
||||
if (location.hash) { state.replaced = true; state.beforeDomContentLoaded = !domContentLoaded; }
|
||||
return replaceState.apply(this, args);
|
||||
};
|
||||
Object.defineProperty(globalThis, "__SCREENER_ACCESS_GATE__", { value: state });
|
||||
})();`;
|
||||
interface GateReport {
|
||||
passed: boolean; serverReady: boolean; roomPrepared: boolean;
|
||||
chromeConnected: boolean; pageLoaded: boolean; browserAudited: boolean;
|
||||
fragmentSeenAtDocumentStart: boolean; fragmentReplaceObserved: boolean;
|
||||
fragmentClearedBeforeDomContentLoaded: boolean; fragmentCleared: boolean;
|
||||
canonicalRoomPath: boolean; roomSessionGrantMatches: number;
|
||||
otherSessionGrantMatches: number; localGrantMatches: number;
|
||||
cookieGrantMatches: number; resourceGrantMatches: number;
|
||||
requestLeakCount: number; nginxLogLeakCount: number; nginxConfigSafe: boolean;
|
||||
appLogLeakCount: number; sqliteLeakCount: number; leakAuditCompleted: boolean;
|
||||
rotateRevokeCasePresent: boolean; cleanupPassed: boolean;
|
||||
}
|
||||
interface BrowserAudit {
|
||||
fragmentSeenAtDocumentStart: boolean; fragmentReplaceObserved: boolean;
|
||||
fragmentClearedBeforeDomContentLoaded: boolean; fragmentCleared: boolean;
|
||||
canonicalRoomPath: boolean; roomSessionGrantMatches: number;
|
||||
otherSessionGrantMatches: number; localGrantMatches: number;
|
||||
cookieGrantMatches: number; resourceGrantMatches: number;
|
||||
}
|
||||
function secret(): string { return randomBytes(24).toString("base64url"); }
|
||||
function leakCount(values: readonly (string | Buffer)[], secrets: readonly string[]): number {
|
||||
return values.filter((value) => secrets.some((item) => value.includes(item))).length;
|
||||
}
|
||||
function diagnosticText(values: readonly unknown[]): string {
|
||||
return values.map((value) => {
|
||||
if (typeof value === "string") return value;
|
||||
if (value instanceof Error) return `${value.name}:${value.message}`;
|
||||
try { return JSON.stringify(value) ?? ""; } catch { return ""; }
|
||||
}).join(" ");
|
||||
}
|
||||
async function sqliteFiles(profile: string): Promise<Buffer[]> {
|
||||
const names = (await readdir(profile)).filter((name) => name.startsWith("rooms.sqlite"));
|
||||
return Promise.all(names.map((name) => readFile(join(profile, name))));
|
||||
}
|
||||
async function main(): Promise<void> {
|
||||
const report: GateReport = {
|
||||
passed: false, serverReady: false, roomPrepared: false,
|
||||
chromeConnected: false, pageLoaded: false, browserAudited: false,
|
||||
fragmentSeenAtDocumentStart: false, fragmentReplaceObserved: false,
|
||||
fragmentClearedBeforeDomContentLoaded: false, fragmentCleared: false,
|
||||
canonicalRoomPath: false, roomSessionGrantMatches: 0,
|
||||
otherSessionGrantMatches: 0, localGrantMatches: 0, cookieGrantMatches: 0,
|
||||
resourceGrantMatches: 0, requestLeakCount: 0, nginxLogLeakCount: 0,
|
||||
nginxConfigSafe: false, appLogLeakCount: 0, sqliteLeakCount: 0,
|
||||
leakAuditCompleted: false, rotateRevokeCasePresent: false,
|
||||
cleanupPassed: false,
|
||||
};
|
||||
const chromePath = process.env.CHROME_PATH?.trim() ?? "";
|
||||
const sitePassword = secret();
|
||||
const roomPassword = secret();
|
||||
const requestRecords: string[] = [];
|
||||
const nginxLines: string[] = [];
|
||||
const appLogs: string[] = [];
|
||||
const originalConsoleError = console.error;
|
||||
let profile: string | null = null;
|
||||
let server: ScreenerServer | null = null;
|
||||
let chrome: ChildProcessWithoutNullStreams | null = null;
|
||||
let cdp: CdpConnection | null = null;
|
||||
let appPort = 0;
|
||||
let debugPort = 0;
|
||||
|
||||
console.error = (...values: unknown[]) => { appLogs.push(diagnosticText(values)); };
|
||||
try {
|
||||
report.rotateRevokeCasePresent = (await readFile(
|
||||
resolve(root, "tests/server-signal.test.ts"), "utf8",
|
||||
)).includes(teardownTest);
|
||||
await access(chromePath);
|
||||
appPort = await reservePort();
|
||||
do { debugPort = await reservePort(); } while (debugPort === appPort);
|
||||
profile = await mkdtemp(join(tmpdir(), "screener-access-privacy-"));
|
||||
const baseUrl = `http://127.0.0.1:${appPort}`;
|
||||
const config: ServerConfig = {
|
||||
nodeEnv: "production", port: appPort, listenHost: "127.0.0.1",
|
||||
publicBaseUrl: new URL(baseUrl), allowedOrigins: new Set([baseUrl]),
|
||||
siteAccessPassword: sitePassword, roomDatabasePath: join(profile, "rooms.sqlite"),
|
||||
roomTtlMs: 14_400_000, maxRooms: 4, maxViewersPerRoom: 2,
|
||||
peerAssistedMedia: false, stunUrls: [],
|
||||
};
|
||||
server = await createScreenerServer({ config, staticDirectory: resolve(root, "dist/client") });
|
||||
server.httpServer.prependListener("request", (request, response) => {
|
||||
const target = request.url ?? "";
|
||||
const record = `${request.method ?? ""} ${target} ${request.headers.referer ?? ""}`;
|
||||
requestRecords.push(record);
|
||||
response.once("finish", () => nginxLines.push(`${record} ${response.statusCode}`));
|
||||
});
|
||||
await server.listen(appPort, "127.0.0.1");
|
||||
report.serverReady = true;
|
||||
const login = await fetch(`${baseUrl}/api/site-access`, {
|
||||
method: "POST", headers: { Authorization: `Bearer ${sitePassword}`, Origin: baseUrl },
|
||||
});
|
||||
const cookie = login.headers.get("set-cookie")?.split(";", 1)[0] ?? "";
|
||||
const createdResponse = await fetch(`${baseUrl}/api/rooms`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie, Origin: baseUrl },
|
||||
body: JSON.stringify({ viewerPolicy: "private-link" }),
|
||||
});
|
||||
const room = createRoomResponseSchema.parse(await createdResponse.json());
|
||||
const invite = new URL(room.inviteUrl);
|
||||
const grant = new URLSearchParams(invite.hash.slice(1)).get("v") ?? "";
|
||||
if (!grant) throw new Error("Private room did not return a Viewer grant");
|
||||
server.roomStore.connectParticipant({
|
||||
roomId: room.roomId, role: "host", token: room.hostToken,
|
||||
clientId: "privacy-gate-host", sessionId: "privacy-gate-host-session",
|
||||
});
|
||||
await server.roomStore.setViewerPassword(room.roomId, roomPassword, "privacy-gate-host-session");
|
||||
report.roomPrepared = true;
|
||||
chrome = spawn(chromePath, [
|
||||
`--remote-debugging-port=${debugPort}`, `--user-data-dir=${profile}`,
|
||||
"--headless=new", "--no-first-run", "--disable-extensions",
|
||||
"--disable-logging", "about:blank",
|
||||
], { cwd: root, stdio: "pipe", windowsHide: true });
|
||||
chrome.stdout.resume();
|
||||
chrome.stderr.resume();
|
||||
const version = await waitForVersion(debugPort, chrome);
|
||||
cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, Date.now() + 10_000);
|
||||
report.chromeConnected = true;
|
||||
const context = await cdp.call<{ browserContextId: string }>(
|
||||
"Target.createBrowserContext", { disposeOnDetach: true }, undefined, Date.now() + 5_000,
|
||||
);
|
||||
const page = await createPage(cdp, room.inviteUrl, fragmentProbe, true, context.browserContextId);
|
||||
report.pageLoaded = true;
|
||||
const expected = JSON.stringify({ roomId: room.roomId, grant });
|
||||
const audit = await evaluate<BrowserAudit>(cdp, page, `(() => {
|
||||
const expected = ${expected};
|
||||
const fragment = globalThis.__SCREENER_ACCESS_GATE__;
|
||||
const entries = (storage) => Object.keys(storage).map((key) => [key, storage.getItem(key)]);
|
||||
const session = entries(sessionStorage); const local = entries(localStorage);
|
||||
return {
|
||||
fragmentSeenAtDocumentStart: fragment?.fragmentAtStart === true,
|
||||
fragmentReplaceObserved: fragment?.replaced === true,
|
||||
fragmentClearedBeforeDomContentLoaded: fragment?.beforeDomContentLoaded === true,
|
||||
fragmentCleared: location.hash === "",
|
||||
canonicalRoomPath: location.pathname === "/r/" + expected.roomId && location.search === "",
|
||||
roomSessionGrantMatches: session.filter(([key, value]) => key === "screener:viewer-grant:" + expected.roomId && value === expected.grant).length,
|
||||
otherSessionGrantMatches: session.filter(([key, value]) => value?.includes(expected.grant) && key !== "screener:viewer-grant:" + expected.roomId).length,
|
||||
localGrantMatches: local.filter(([, value]) => value && value.includes(expected.grant)).length,
|
||||
resourceGrantMatches: performance.getEntriesByType("resource").filter((entry) => entry.name.includes(expected.grant)).length,
|
||||
};
|
||||
})()`, Date.now() + 5_000);
|
||||
const cookies = await cdp.call<{ cookies: { name: string; value: string }[] }>(
|
||||
"Storage.getCookies", { browserContextId: context.browserContextId }, undefined, Date.now() + 5_000);
|
||||
audit.cookieGrantMatches = leakCount(cookies.cookies.map(({ name, value }) => `${name}=${value}`), [grant]);
|
||||
Object.assign(report, audit, { browserAudited: true });
|
||||
await cdp.call("Target.disposeBrowserContext", { browserContextId: context.browserContextId },
|
||||
undefined, Date.now() + 5_000);
|
||||
|
||||
await server.close();
|
||||
server = null;
|
||||
const transportSecrets = [sitePassword, grant];
|
||||
const nginxConfig = await readFile(nginxConfigPath, "utf8");
|
||||
report.requestLeakCount = leakCount(requestRecords, transportSecrets);
|
||||
report.nginxLogLeakCount = leakCount(nginxLines, transportSecrets);
|
||||
report.nginxConfigSafe = !/\$(?:http_authorization|request_body)(?:\b|_)/.test(nginxConfig);
|
||||
report.appLogLeakCount = leakCount(appLogs, transportSecrets);
|
||||
report.sqliteLeakCount = leakCount(await sqliteFiles(profile), [...transportSecrets, roomPassword]);
|
||||
report.leakAuditCompleted = true;
|
||||
} catch {
|
||||
// The report intentionally exposes no exception text or secret-bearing diagnostics.
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
const cleanup = await cleanupRun({
|
||||
cdp, native: null, chrome, server, profile,
|
||||
ports: [appPort, debugPort].filter((port) => port > 0),
|
||||
});
|
||||
report.cleanupPassed = Object.values(cleanup).every(Boolean);
|
||||
}
|
||||
|
||||
report.passed = report.browserAudited && report.fragmentSeenAtDocumentStart &&
|
||||
report.fragmentReplaceObserved && report.fragmentClearedBeforeDomContentLoaded &&
|
||||
report.fragmentCleared &&
|
||||
report.canonicalRoomPath && report.roomSessionGrantMatches === 1 &&
|
||||
report.otherSessionGrantMatches === 0 && report.localGrantMatches === 0 &&
|
||||
report.cookieGrantMatches === 0 && report.resourceGrantMatches === 0 &&
|
||||
report.leakAuditCompleted && report.requestLeakCount === 0 &&
|
||||
report.nginxLogLeakCount === 0 && report.nginxConfigSafe &&
|
||||
report.appLogLeakCount === 0 && report.sqliteLeakCount === 0 &&
|
||||
report.rotateRevokeCasePresent && report.cleanupPassed;
|
||||
process.stdout.write(`${JSON.stringify(report)}\n`);
|
||||
process.exitCode = report.passed ? 0 : 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
await main();
|
||||
}
|
||||
@@ -215,5 +215,5 @@ export function isExactGateProfile(profile: string, systemTemp: string): boolean
|
||||
const same = process.platform === "win32"
|
||||
? dirname(candidate).toLowerCase() === temp.toLowerCase()
|
||||
: dirname(candidate) === temp;
|
||||
return same && /^screener-native-one-viewer-[A-Za-z0-9_-]{6}$/.test(basename(candidate));
|
||||
return same && /^screener-(?:native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$/.test(basename(candidate));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, fsyncSync, openSync, writeFileSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { lstat, mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import { createConnection, createServer } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
|
||||
type Stage = "preflight" | "sender-start" | "node-host" | "viewer-signal" | "viewer-media" | "cleanup";
|
||||
|
||||
interface PageHandle {
|
||||
export interface PageHandle {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ interface ViewerSnapshot {
|
||||
audioBytesReceived: number;
|
||||
}
|
||||
|
||||
class CdpConnection {
|
||||
export class CdpConnection {
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, {
|
||||
resolve: (value: unknown) => void;
|
||||
@@ -941,15 +941,19 @@ function viewerProbe(): string {
|
||||
})();`;
|
||||
}
|
||||
|
||||
async function createPage(
|
||||
export async function createPage(
|
||||
cdp: CdpConnection,
|
||||
url: string,
|
||||
probe?: string,
|
||||
newWindow = false,
|
||||
browserContextId?: string,
|
||||
): Promise<PageHandle> {
|
||||
const deadline = Date.now() + 15_000;
|
||||
const created = await cdp.call<{ targetId: string }>(
|
||||
"Target.createTarget", { url: "about:blank", background: false, newWindow }, undefined, deadline,
|
||||
"Target.createTarget", {
|
||||
url: "about:blank", background: false, newWindow,
|
||||
...(browserContextId ? { browserContextId } : {}),
|
||||
}, undefined, deadline,
|
||||
);
|
||||
const attached = await cdp.call<{ sessionId: string }>("Target.attachToTarget", { targetId: created.targetId, flatten: true }, undefined, deadline);
|
||||
const page = { sessionId: attached.sessionId };
|
||||
@@ -967,7 +971,7 @@ async function createPage(
|
||||
return page;
|
||||
}
|
||||
|
||||
async function evaluate<T>(cdp: CdpConnection, page: PageHandle, expression: string, deadline: number): Promise<T> {
|
||||
export async function evaluate<T>(cdp: CdpConnection, page: PageHandle, expression: string, deadline: number): Promise<T> {
|
||||
const value = await cdp.call<CdpResult<T>>("Runtime.evaluate", {
|
||||
expression, awaitPromise: true, returnByValue: true, userGesture: true,
|
||||
}, page.sessionId, deadline);
|
||||
@@ -998,7 +1002,7 @@ function safeRoute(value: string | undefined): string {
|
||||
.has(value ?? "") ? value! : "unknown";
|
||||
}
|
||||
|
||||
async function reservePort(): Promise<number> {
|
||||
export async function reservePort(): Promise<number> {
|
||||
const server = createServer();
|
||||
return new Promise((resolvePort, rejectPort) => {
|
||||
server.once("error", rejectPort);
|
||||
@@ -1034,7 +1038,7 @@ async function readLaunchUrl(child: ChildProcessWithoutNullStreams): Promise<str
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForVersion(port: number, child: ChildProcessWithoutNullStreams): Promise<{ Browser: string; webSocketDebuggerUrl: string }> {
|
||||
export async function waitForVersion(port: number, child: ChildProcessWithoutNullStreams): Promise<{ Browser: string; webSocketDebuggerUrl: string }> {
|
||||
const deadline = Date.now() + 20_000;
|
||||
while (Date.now() < deadline && child.exitCode === null) {
|
||||
try {
|
||||
@@ -1047,7 +1051,7 @@ async function waitForVersion(port: number, child: ChildProcessWithoutNullStream
|
||||
throw new Error("Chrome startup timed out");
|
||||
}
|
||||
|
||||
interface CleanupResources {
|
||||
export interface CleanupResources {
|
||||
cdp: CdpConnection | null;
|
||||
native: ChildProcessWithoutNullStreams | null;
|
||||
chrome: ChildProcessWithoutNullStreams | null;
|
||||
@@ -1056,7 +1060,7 @@ interface CleanupResources {
|
||||
ports: number[];
|
||||
}
|
||||
|
||||
async function cleanupRun(resources: CleanupResources): Promise<CleanupResult> {
|
||||
export async function cleanupRun(resources: CleanupResources): Promise<CleanupResult> {
|
||||
if (resources.cdp) {
|
||||
try { await resources.cdp.call("Browser.close", {}, undefined, Date.now() + 2_000); } catch {}
|
||||
resources.cdp.close();
|
||||
@@ -1082,8 +1086,13 @@ function processRunning(child: ChildProcessWithoutNullStreams): boolean {
|
||||
async function stopProcessTree(child: ChildProcessWithoutNullStreams): Promise<boolean> {
|
||||
if (!processRunning(child)) return true;
|
||||
if (await waitForExit(child, 2_000)) return true;
|
||||
if (process.platform !== "win32" || child.pid === undefined) return false;
|
||||
await runBounded("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], 5_000);
|
||||
if (process.platform === "win32" && child.pid !== undefined) {
|
||||
await runBounded("taskkill.exe", ["/PID", String(child.pid), "/T", "/F"], 5_000);
|
||||
} else {
|
||||
child.kill("SIGTERM");
|
||||
if (await waitForExit(child, 2_000)) return true;
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
return waitForExit(child, 3_000);
|
||||
}
|
||||
|
||||
@@ -1145,7 +1154,7 @@ $root = [IO.Path]::GetFullPath($env:SCREENER_GATE_PROFILE_TO_REMOVE)
|
||||
$temp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd([IO.Path]::DirectorySeparatorChar)
|
||||
$parent = [IO.Path]::GetDirectoryName($root).TrimEnd([IO.Path]::DirectorySeparatorChar)
|
||||
$name = [IO.Path]::GetFileName($root)
|
||||
if ($parent -ine $temp -or $name -notmatch '^screener-native-one-viewer-[A-Za-z0-9_-]{6}$') { exit 31 }
|
||||
if ($parent -ine $temp -or $name -notmatch '^screener-(native-one-viewer|access-privacy)-[A-Za-z0-9_-]{6}$') { exit 31 }
|
||||
$rootEntries = @([IO.Directory]::EnumerateFileSystemEntries($parent, $name, [IO.SearchOption]::TopDirectoryOnly))
|
||||
if ($rootEntries.Count -eq 0) { exit 0 }
|
||||
if ($rootEntries.Count -ne 1) { exit 34 }
|
||||
@@ -1171,7 +1180,24 @@ if ($rootEntries.Count -ne 0) { exit 33 }
|
||||
`;
|
||||
|
||||
async function removeGateProfile(profile: string): Promise<boolean> {
|
||||
if (process.platform !== "win32" || !isExactGateProfile(profile, tmpdir())) return false;
|
||||
if (!isExactGateProfile(profile, tmpdir())) return false;
|
||||
if (process.platform !== "win32") {
|
||||
const pending = [profile];
|
||||
try {
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop()!;
|
||||
const stat = await lstat(current);
|
||||
if (stat.isSymbolicLink()) return false;
|
||||
if (stat.isDirectory()) {
|
||||
pending.push(...(await readdir(current)).map((name) => join(current, name)));
|
||||
}
|
||||
}
|
||||
await rm(profile, { recursive: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return runBounded("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", profileCleanupScript],
|
||||
10_000, { ...process.env, SCREENER_GATE_PROFILE_TO_REMOVE: profile });
|
||||
}
|
||||
|
||||
@@ -287,6 +287,7 @@ describe("native one-viewer gate invariants", () => {
|
||||
it("accepts only the exact task profile directly under system Temp", () => {
|
||||
const temp = join("C:", "Temp");
|
||||
expect(isExactGateProfile(join(temp, "screener-native-one-viewer-a1B_2-"), temp)).toBe(true);
|
||||
expect(isExactGateProfile(join(temp, "screener-access-privacy-a1B_2-"), temp)).toBe(true);
|
||||
expect(isExactGateProfile(join(temp, "nested", "screener-native-one-viewer-a1B_2-"), temp)).toBe(false);
|
||||
expect(isExactGateProfile(join(temp, "other-a1B_2-"), temp)).toBe(false);
|
||||
expect(profileCleanupScript).toContain("EnumerateFileSystemEntries($parent, $name");
|
||||
|
||||
Reference in new issue
Block a user