mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(dsh-plugin-browserskill): address review findings (leaks, trust fence, queue race)
Blocking: - B1: delete screenshot scratch PNGs — observation frames reuse one fixed per-session path and unlink after every read (finally); browser_screenshot unlinks once the bytes are in the attachment store (kept only when the file itself is the model-facing artifact). - B2: replicate dsh's browser-trust fence on /bsk-observation/*: loopback Host only, Origin must match Host, sec-fetch-site: cross-site refused, POST requires application/json; README documents the loopback trust premise and the 0.0.0.0 warning. Major: - M1: KeyedExecutor tail chains previous+task (allSettled) so a task aborted while queued cannot release the next one into the running session. - M2: client tracks the live frame per session — replacing/removing/reset revokes the old blob URL at once; loads settling after replacement never resurrect it. Minor: drop replaced thumbRefs (+on remove); beginAction fires inside the queue (no label overwrite while queued, no idle flash); half-initialized session cleanup stops through the queue with one retry; SSE (re)open refetches /state (+ snapshot flag renamed subscribed); install probe uses --version (no daemon spawn); emulate mobile documents the width+height requirement (the daemon refuses it alone — verified). Nit: single BskRunOptions declaration; tails map entry dropped on drain; SSE cleanup on res close; popOut catches requestWindow rejections; resize handle gains keyboard control (arrows, 16px steps, aria value attrs). Tests: +14 (fence rules, scratch lifecycle, queue race, blob revocation, instrumentation timing, SSE resync). 113/113 green.
This commit is contained in:
@@ -35,6 +35,56 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser-trust fence, mirroring the one dsh applies to its /api routes (ours
|
||||
* live outside that prefix, so the checks are replicated here):
|
||||
* - Host must be a loopback authority (localhost / 127.0.0.0/8 / [::1]) — the
|
||||
* observation channel exposes live screenshots and must never answer a LAN
|
||||
* or DNS-rebound name;
|
||||
* - a present Origin must be same-host with the Host header (blocks cross-site
|
||||
* reads), and `sec-fetch-site: cross-site` is refused outright;
|
||||
* - POST must be `application/json` — anything a cross-site *simple request*
|
||||
* can send (form/plain) never reaches the handler, which kills CSRF.
|
||||
*/
|
||||
function fenceViolation(req: IncomingMessage): string | undefined {
|
||||
const host = req.headers.host ?? "";
|
||||
const hostname = /^\[.*\](?::\d+)?$/.test(host)
|
||||
? host.slice(1, host.indexOf("]"))
|
||||
: host.split(":")[0];
|
||||
const isLoopback =
|
||||
hostname === "localhost" ||
|
||||
hostname.endsWith(".localhost") ||
|
||||
hostname === "::1" ||
|
||||
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
|
||||
if (!isLoopback) return "host is not a loopback authority";
|
||||
const origin = req.headers.origin;
|
||||
if (origin !== undefined && origin !== "null") {
|
||||
let originHost: string | undefined;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
return "unparseable Origin header";
|
||||
}
|
||||
if (originHost !== host) return "Origin does not match Host";
|
||||
}
|
||||
if (req.headers["sec-fetch-site"] === "cross-site") return "sec-fetch-site: cross-site";
|
||||
if (req.method === "POST") {
|
||||
const contentType = req.headers["content-type"] ?? "";
|
||||
if (!/^\s*application\/json\s*(;|$)/.test(contentType)) {
|
||||
return "POST requires an application/json body";
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Run the fence; returns true when the request was rejected (handled). */
|
||||
function fenceRejected(req: IncomingMessage, res: ServerResponse): boolean {
|
||||
const violation = fenceViolation(req);
|
||||
if (violation === undefined) return false;
|
||||
sendJson(res, 403, { error: `forbidden: ${violation}` });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the observation routes. No-op (with a console note) when the
|
||||
* composition has no web server.
|
||||
@@ -57,6 +107,7 @@ export function registerObservationRoutes(
|
||||
sendJson(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
if (fenceRejected(req, res)) return;
|
||||
sendJson(res, 200, {
|
||||
sessions: observation.getState(),
|
||||
available: observation.isAvailable(),
|
||||
@@ -71,6 +122,7 @@ export function registerObservationRoutes(
|
||||
sendJson(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
if (fenceRejected(req, res)) return;
|
||||
res.writeHead(200, {
|
||||
"content-type": "text/event-stream",
|
||||
"cache-control": "no-cache",
|
||||
@@ -80,7 +132,7 @@ export function registerObservationRoutes(
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
});
|
||||
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
|
||||
req.on("close", () => {
|
||||
res.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
});
|
||||
@@ -94,6 +146,7 @@ export function registerObservationRoutes(
|
||||
sendJson(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
if (fenceRejected(req, res)) return;
|
||||
let body = "";
|
||||
req.on("data", (chunk: Buffer | string) => {
|
||||
body += chunk;
|
||||
@@ -121,6 +174,7 @@ export function registerObservationRoutes(
|
||||
sendJson(res, 405, { error: "method not allowed" });
|
||||
return;
|
||||
}
|
||||
if (fenceRejected(req, res)) return;
|
||||
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
|
||||
const attachmentId = pathname.slice(`${ROUTE_BASE}/thumbnail/`.length);
|
||||
const frame = await observation.readThumbnail(attachmentId);
|
||||
|
||||
Reference in New Issue
Block a user