fix(browser): preserve unknown worker target ownership

Parse optional CDP worker relationships once for browser and Electron collectors. Keep absent ownership unavailable, malformed relationships invalid, and proven foreign targets outside the selected scope; retain existing origin and file authorization.

Add public CLI protocol fixtures and documentation for coverage and withheld details.

Fixes #6475
This commit is contained in:
morluto committed 2026-10-24 05:05:22 +08:00
1 parent d13a036e34
commit 1081f81484
9 files changed
+315 -29

No files matched your search

+5
View File
@@ -412,6 +412,11 @@ Frames, resources, scripts, events, and workers outside the selected origin scop
Retained workers include validated opener-target and parent-frame IDs when CDP
provides them; these relationships do not expand the exact-origin boundary or
prove static module ownership.
Workers without either relationship are withheld with an `unattributed_target`
exclusion and `workers` in `unavailable_sections`: a same-origin URL does not
establish which page owns a worker. A supplied relationship to another target or
frame remains `out_of_target_scope`. Missing ownership also remains unknown when
the worker URL is empty; withheld target details are not returned.
## Completeness and limits
+5
View File
@@ -87,6 +87,11 @@ target, frame, script, and resource, a worker URL must resolve to a local
file before it is retained. Relationship IDs improve
attribution but do not prove which static module started a worker or that its
work completed.
Missing opener and parent relationships leave worker ownership unknown. Such
targets are withheld with an `unattributed_target` exclusion and unavailable
`workers` coverage, even when their URL names a local file. Explicit foreign
relationships remain `out_of_target_scope`; malformed supplied relationship IDs
are `invalid_protocol_value`.
Inspection does not retain DOM values, execute renderer code, navigate, click,
invoke Electron IPC, close a target, or terminate the application.
+1
View File
@@ -62,6 +62,7 @@ export class CdpCaptureCompleteness {
if (POLICY_REASONS.has(reason)) this.#policyFiltered.add(section);
if (
reason === "provider_unavailable" ||
reason === "unattributed_target" ||
reason === "invalid_protocol_value"
)
this.#unavailable.add(section);
+19 -9
View File
@@ -11,6 +11,7 @@ import { optionalCdpCommand } from "./CdpOptionalCommand.js";
import { authorizedElectronFile } from "./ElectronFileScope.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { workerTargetOwnership } from "./CdpWorkerTargetOwnership.js";
/** Inventory local worker targets without attaching to them. */
export const captureElectronWorkers = async (input: {
@@ -53,13 +54,22 @@ export const captureElectronWorkers = async (input: {
}
const type = cdpStringValue(target.type) ?? "";
if (!type.includes("worker")) continue;
const openerTargetId = boundedTargetField(target.openerId);
const parentFrameId = boundedTargetField(target.parentFrameId);
if (
openerTargetId !== input.target.id &&
(parentFrameId === null || !input.frameIds.has(parentFrameId))
) {
input.completeness.exclude("workers", "out_of_target_scope");
const ownership = workerTargetOwnership(
target,
input.target.id,
input.frameIds,
);
if (!ownership.ok) {
input.completeness.exclude("workers", "invalid_protocol_value");
continue;
}
if (ownership.value.scope !== "selected") {
input.completeness.exclude(
"workers",
ownership.value.scope === "unknown"
? "unattributed_target"
: "out_of_target_scope",
);
continue;
}
const path = await authorizedElectronFile(
@@ -86,8 +96,8 @@ export const captureElectronWorkers = async (input: {
type,
file_path: path,
attached: target.attached,
opener_target_id: openerTargetId,
parent_frame_id: parentFrameId,
opener_target_id: ownership.value.openerTargetId,
parent_frame_id: ownership.value.parentFrameId,
});
}
return workers.sort((left, right) =>
+46
View File
@@ -125,3 +125,49 @@ describe("web worker target inventory", () => {
);
});
});
it.each([`${origin}/worker.js`, ""])(
"keeps an unattributed worker unavailable rather than policy filtered: %s",
async (url) => {
const result = await capture({
targetInfos: [
{ targetId: "worker", type: "worker", url, attached: false },
],
});
expect(result.workers).toEqual([]);
expect(result.completeness).toMatchObject({
status: "attach_limited",
conditions: ["attach_limited"],
unavailable_sections: ["workers"],
policy_filtered_sections: [],
excluded: [
{ section: "workers", reason: "unattributed_target", count: 1 },
],
});
},
);
it.each([{ openerId: "" }, { parentFrameId: null }])(
"retains invalid supplied relationship coverage: %j",
async (relationship) => {
const result = await capture({
targetInfos: [
{
targetId: "worker",
type: "worker",
url: `${origin}/worker.js`,
attached: false,
...relationship,
},
],
});
expect(result.workers).toEqual([]);
expect(result.completeness).toMatchObject({
unavailable_sections: ["workers"],
policy_filtered_sections: [],
excluded: [
{ section: "workers", reason: "invalid_protocol_value", count: 1 },
],
});
},
);
+17 -20
View File
@@ -9,6 +9,7 @@ import {
} from "./CdpCaptureValues.js";
import type { CaptureContext } from "./CdpPageCapture.js";
import { optionalCdpCommand } from "./CdpOptionalCommand.js";
import { workerTargetOwnership } from "./CdpWorkerTargetOwnership.js";
interface CaptureWorkersInput {
readonly context: CaptureContext;
@@ -59,29 +60,25 @@ export const captureWorkers = async (
malformedEntries += 1;
continue;
}
const openerId =
target.openerId === undefined
? undefined
: cdpStringValue(target.openerId);
const parentFrameId =
target.parentFrameId === undefined
? undefined
: cdpStringValue(target.parentFrameId);
if (
(target.openerId !== undefined && openerId === undefined) ||
(target.parentFrameId !== undefined && parentFrameId === undefined)
) {
const ownership = workerTargetOwnership(
target,
context.target.id,
frameIds,
);
if (!ownership.ok) {
malformedEntries += 1;
continue;
}
const url = allowedSanitizedUrl(target.url, allowedOrigins);
const relatedToPage =
openerId === context.target.id ||
(parentFrameId !== undefined && frameIds.has(parentFrameId));
if (!relatedToPage) {
completeness.exclude("workers", "out_of_target_scope");
if (ownership.value.scope !== "selected") {
completeness.exclude(
"workers",
ownership.value.scope === "unknown"
? "unattributed_target"
: "out_of_target_scope",
);
continue;
}
const url = allowedSanitizedUrl(target.url, allowedOrigins);
if (url === undefined) {
const rawUrl = cdpStringValue(target.url);
completeness.exclude(
@@ -100,8 +97,8 @@ export const captureWorkers = async (
url: url.url,
origin: url.origin,
attached: target.attached,
opener_target_id: openerId ?? null,
parent_frame_id: parentFrameId ?? null,
opener_target_id: ownership.value.openerTargetId,
parent_frame_id: ownership.value.parentFrameId,
});
}
if (malformedEntries > 0)
+38
View File
@@ -0,0 +1,38 @@
import { err, ok, type Result } from "../domain/result.js";
import { cdpStringValue } from "./CdpCaptureValues.js";
/** Optional CDP relationships establish ownership only when supplied. */
export const workerTargetOwnership = (
target: Readonly<Record<string, unknown>>,
selectedTargetId: string,
frameIds: ReadonlySet<string>,
): Result<
{
readonly scope: "selected" | "foreign" | "unknown";
readonly openerTargetId: string | null;
readonly parentFrameId: string | null;
},
{ readonly _tag: "InvalidWorkerRelationship" }
> => {
const openerTargetId = cdpStringValue(target.openerId);
const parentFrameId = cdpStringValue(target.parentFrameId);
if (
(target.openerId !== undefined &&
(openerTargetId === undefined || openerTargetId === "")) ||
(target.parentFrameId !== undefined &&
(parentFrameId === undefined || parentFrameId === ""))
)
return err({ _tag: "InvalidWorkerRelationship" });
const selected =
openerTargetId === selectedTargetId ||
(parentFrameId !== undefined && frameIds.has(parentFrameId));
return ok({
scope: selected
? "selected"
: openerTargetId === undefined && parentFrameId === undefined
? "unknown"
: "foreign",
openerTargetId: openerTargetId ?? null,
parentFrameId: parentFrameId ?? null,
});
};
+1
View File
@@ -36,6 +36,7 @@ const browserExclusionReasonSchema = z.enum([
"unattributed_origin",
"not_approved",
"out_of_target_scope",
"unattributed_target",
"provider_unavailable",
"invalid_protocol_value",
"resource_budget_exhausted",
@@ -0,0 +1,183 @@
import { rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, expect, it } from "vitest";
import { webPageInspectionSchema } from "../../../src/domain/browserObservationSchemas.js";
import { electronPageInspectionSchema } from "../../../src/domain/javascript/electronObservation.js";
import { runCliJson } from "../../fixtures/cliJsonProcess.js";
import {
startFakeCdpBrowser,
type FakeCdpBrowser,
} from "../../fixtures/fakeCdpBrowser.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const browsers: FakeCdpBrowser[] = [];
const temporary: string[] = [];
afterEach(async () => {
await Promise.all(browsers.splice(0).map(async (browser) => browser.close()));
await Promise.all(
temporary
.splice(0)
.map(async (root) => rm(root, { recursive: true, force: true })),
);
});
const normalizedResult = (value: unknown): unknown => {
if (
typeof value !== "object" ||
value === null ||
!("normalized_result" in value)
)
throw new Error("Expected CLI Evidence");
return value.normalized_result;
};
it("keeps missing worker ownership unknown through inspect-web-page", async () => {
const browser = await startFakeCdpBrowser({
commandResult: ({ method }, origin) => {
if (method !== "Target.getTargets") return undefined;
const worker = {
type: "worker",
title: "Fixture",
attached: false,
canAccessOpener: false,
url: `${origin}/worker.js`,
};
return {
targetInfos: [
{ ...worker, targetId: "related-opener", openerId: "allowed-page" },
{
...worker,
targetId: "related-parent",
parentFrameId: "frame-main",
},
{ ...worker, targetId: "withheld-unknown" },
{ ...worker, targetId: "withheld-empty-url", url: "" },
{ ...worker, targetId: "withheld-foreign", openerId: "other-page" },
{
...worker,
targetId: "withheld-foreign-parent",
parentFrameId: "other-frame",
},
{ ...worker, targetId: "withheld-invalid", openerId: null },
{
...worker,
targetId: "withheld-disallowed",
openerId: "allowed-page",
url: "https://foreign.test/worker.js",
},
],
};
},
});
browsers.push(browser);
const evidence = await runCliJson([
"inspect-web-page",
browser.endpoint,
"allowed-page",
"--observation-ms",
"0",
"--json",
]);
expect(evidence).toMatchObject({
operation: "inspect_web_page",
provider: { id: "rea-cdp-browser" },
});
const inspection = webPageInspectionSchema.parse(normalizedResult(evidence));
expect(inspection.workers).toEqual([
expect.objectContaining({
target_id: "related-opener",
opener_target_id: "allowed-page",
parent_frame_id: null,
}),
expect.objectContaining({
target_id: "related-parent",
opener_target_id: null,
parent_frame_id: "frame-main",
}),
]);
expect(inspection.completeness.unavailable_sections).toContain("workers");
expect(
inspection.completeness.excluded.filter(
({ section }) => section === "workers",
),
).toEqual([
{ section: "workers", reason: "disallowed_origin", count: 1 },
{ section: "workers", reason: "invalid_protocol_value", count: 1 },
{ section: "workers", reason: "out_of_target_scope", count: 2 },
{ section: "workers", reason: "unattributed_target", count: 2 },
]);
expect(JSON.stringify(evidence)).not.toContain("withheld-");
expect(
browser.commands.some(({ method }) => method === "Target.detachFromTarget"),
).toBe(true);
}, 20_000);
it("uses the same ownership facts for passive Electron worker inspection", async () => {
const root = await createTestTempDirectory("rea-worker-ownership-");
temporary.push(root);
await writeFile(join(root, "index.html"), "<html></html>");
await writeFile(join(root, "worker.js"), "void 0;");
const browser = await startFakeCdpBrowser({
electronFileUrl: pathToFileURL(join(root, "index.html")).href,
commandResult: ({ method }) => {
if (method !== "Target.getTargets") return undefined;
const worker = {
type: "worker",
title: "Fixture",
attached: false,
canAccessOpener: false,
url: pathToFileURL(join(root, "worker.js")).href,
};
return {
targetInfos: [
{ ...worker, targetId: "related", openerId: "electron-page" },
{ ...worker, targetId: "withheld-unknown" },
{ ...worker, targetId: "withheld-empty-url", url: "" },
{ ...worker, targetId: "withheld-foreign", openerId: "other-page" },
{ ...worker, targetId: "withheld-invalid", parentFrameId: 42 },
],
};
},
});
browsers.push(browser);
const evidence = await runCliJson([
"inspect-electron-page",
browser.endpoint,
"electron-page",
"--observation-ms",
"0",
"--json",
]);
expect(evidence).toMatchObject({
operation: "inspect_electron_page",
provider: { id: "rea-cdp-electron" },
});
const inspection = electronPageInspectionSchema.parse(
normalizedResult(evidence),
);
expect(inspection.workers).toEqual([
expect.objectContaining({
target_id: "related",
opener_target_id: "electron-page",
parent_frame_id: null,
}),
]);
expect(inspection.completeness.unavailable_sections).toContain("workers");
expect(
inspection.completeness.excluded.filter(
({ section }) => section === "workers",
),
).toEqual([
{ section: "workers", reason: "invalid_protocol_value", count: 1 },
{ section: "workers", reason: "out_of_target_scope", count: 1 },
{ section: "workers", reason: "unattributed_target", count: 2 },
]);
expect(JSON.stringify(evidence)).not.toContain("withheld-");
expect(
browser.commands.some(({ method }) => method === "Target.detachFromTarget"),
).toBe(true);
}, 20_000);