Skip to content

Commit 8af4397

Browse files
committed
fix: complete browser cleanup security boundaries
1 parent 22c3fa3 commit 8af4397

6 files changed

Lines changed: 262 additions & 57 deletions

File tree

packages/runtime-playground/src/browser-preview-routing.ts

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -350,13 +350,25 @@ export async function drainBrowserPreviewRouteTracker(tracker: BrowserPreviewRou
350350
}
351351
}
352352

353-
export async function closeBrowserAndDrainPreviewRoutes(browser: Pick<import("playwright").Browser, "close">, tracker: BrowserPreviewRouteTracker): Promise<Error[]> {
353+
export async function closeBrowserAndDrainPreviewRoutes(browser: Pick<import("playwright").Browser, "close">, tracker: BrowserPreviewRouteTracker, closeTimeoutMs = 1_000): Promise<Error[]> {
354354
const errors: Error[] = []
355+
let closeTimer: ReturnType<typeof setTimeout> | undefined
355356
try {
356-
await browser.close()
357-
} catch (error) {
358-
errors.push(browserPreviewLifecycleError("browser-close", error))
357+
const closeResult = await Promise.race([
358+
browser.close().then(() => ({ status: "closed" as const }), (error: unknown) => ({ status: "failed" as const, error })),
359+
new Promise<{ status: "timeout" }>((resolve) => {
360+
closeTimer = setTimeout(() => resolve({ status: "timeout" }), closeTimeoutMs)
361+
}),
362+
])
363+
if (closeResult.status === "failed") {
364+
errors.push(browserPreviewLifecycleError("browser-close", closeResult.error))
365+
} else if (closeResult.status === "timeout") {
366+
errors.push(browserPreviewLifecycleError("browser-close-timeout", new Error(`Browser close exceeded ${closeTimeoutMs}ms`)))
367+
}
359368
} finally {
369+
if (closeTimer) {
370+
clearTimeout(closeTimer)
371+
}
360372
try {
361373
await drainBrowserPreviewRouteTracker(tracker)
362374
} catch (error) {
@@ -568,7 +580,7 @@ async function fetchBrowserPreviewRoutedHost(route: Route, requestUrl: URL, poli
568580
}
569581

570582
if (resourceType !== "document" || !retryable) {
571-
await route.abort("failed").catch(() => undefined)
583+
await abortBrowserPreviewRoute(route, "abort-recoverable-fetch")
572584
return undefined
573585
}
574586
throw browserPreviewRouteFetchExhaustedError(route, currentUrl, attempt, error)
@@ -597,15 +609,15 @@ async function fetchBrowserPreviewRoutedHost(route: Route, requestUrl: URL, poli
597609
stat.external = true
598610
stat.blocked += 1
599611
policy.routedRedirectEscapes.push({ rawOrigin: redirectedUrl.origin, effectiveOrigin: origin.origin, reason: "redirect-host-not-routed-to-preview" })
600-
await route.abort("blockedbyclient")
612+
await abortBrowserPreviewRoute(route, "abort-redirect-escape", "blockedbyclient")
601613
return undefined
602614
}
603615

604616
currentUrl = redirectedUrl
605617
}
606618

607619
if (route.request().resourceType() !== "document") {
608-
await route.abort("failed").catch(() => undefined)
620+
await abortBrowserPreviewRoute(route, "abort-redirect-limit")
609621
return undefined
610622
}
611623

@@ -664,6 +676,20 @@ function browserPreviewLifecycleError(operation: string, error: unknown): Error
664676
return diagnostic
665677
}
666678

679+
async function abortBrowserPreviewRoute(route: Route, operation: string, errorCode: Parameters<Route["abort"]>[0] = "failed"): Promise<void> {
680+
try {
681+
await route.abort(errorCode)
682+
} catch (error) {
683+
if (isBrowserPreviewRouteClosedError(error)) {
684+
return
685+
}
686+
const cause = sanitizeBrowserPreviewRouteError(error).message.replace(/[\r\n]+/g, " ")
687+
const diagnostic = new Error(`wordpress.browser-probe route operation failed: operation=${operation} cause=${cause}`)
688+
diagnostic.name = "BrowserPreviewRouteOperationError"
689+
throw diagnostic
690+
}
691+
}
692+
667693
function browserPreviewRouteFetchExhaustedError(route: Route, requestUrl: URL, attempts: number, error: unknown): Error {
668694
const method = route.request().method()
669695
const resourceType = route.request().resourceType()

packages/runtime-playground/src/browser-probe-runner.ts

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,6 @@ export async function runSingleBrowserProbeCommand({
292292
pendingError = pendingError ?? new Error("Browser command aborted during runtime cleanup")
293293
void page?.close().catch(() => undefined)
294294
void context?.close().catch(() => undefined)
295-
void browser.close().catch(() => undefined)
296295
}
297296
abortSignal?.addEventListener("abort", abortHandler, { once: true })
298297

@@ -451,26 +450,38 @@ export async function runSingleBrowserProbeCommand({
451450
errors.push(serializeBrowserError("probe-error", error))
452451
} finally {
453452
if (abortSignal?.aborted) {
454-
await closeBrowserBestEffort(browser)
455-
await drainBrowserPreviewRouteTracker(routeTracker).catch(() => undefined)
456-
abortSignal.removeEventListener("abort", abortHandler)
457-
throw pendingError ?? new Error("Browser command aborted during runtime cleanup")
453+
pendingError ??= new Error("Browser command aborted during runtime cleanup")
454+
progress.fail("probe-error", pendingError)
458455
}
459456
if (page) {
460-
finalUrl = page.url()
457+
try {
458+
finalUrl = page.url()
459+
} catch (error) {
460+
errors.push(serializeBrowserError("probe-error", error))
461+
}
461462
windowLocationOrigin = windowLocationOrigin ?? await page.evaluate(() => window.location.origin).catch(() => undefined)
462463
if (captureSelection.metrics) {
463-
checkpoints.push(await browserProbeCheckpoint(page, "final"))
464-
if (capture.has("memory")) {
465-
memoryArtifact = browserProbeMemoryArtifact(checkpoints)
466-
}
467-
if (capture.has("performance")) {
468-
performanceArtifact = browserProbePerformanceArtifact(checkpoints, { consoleMessages, errors, network, startedAt })
464+
try {
465+
checkpoints.push(await browserProbeCheckpoint(page, "final"))
466+
if (capture.has("memory")) {
467+
memoryArtifact = browserProbeMemoryArtifact(checkpoints)
468+
}
469+
if (capture.has("performance")) {
470+
performanceArtifact = browserProbePerformanceArtifact(checkpoints, { consoleMessages, errors, network, startedAt })
471+
}
472+
} catch (error) {
473+
pendingError ??= redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
474+
errors.push(serializeBrowserError("probe-error", error))
469475
}
470476
}
471-
const lifecycle = lifecycleSelectors.length > 0 ? await collectBrowserProbeLifecycle(page) : undefined
472-
if (lifecycle) {
473-
lifecycleArtifact = browserProbeLifecycleArtifact(lifecycle)
477+
try {
478+
const lifecycle = lifecycleSelectors.length > 0 ? await collectBrowserProbeLifecycle(page) : undefined
479+
if (lifecycle) {
480+
lifecycleArtifact = browserProbeLifecycleArtifact(lifecycle)
481+
}
482+
} catch (error) {
483+
pendingError ??= redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
484+
errors.push(serializeBrowserError("probe-error", error))
474485
}
475486

476487
if (capture.has("html")) {
@@ -642,16 +653,6 @@ export async function runSingleBrowserProbeCommand({
642653
}
643654
}
644655

645-
async function closeBrowserBestEffort(browser: import("playwright").Browser): Promise<void> {
646-
await Promise.race([
647-
browser.close().catch(() => undefined),
648-
new Promise<void>((resolve) => {
649-
const timeout = setTimeout(resolve, 1_000)
650-
timeout.unref()
651-
}),
652-
])
653-
}
654-
655656
export type BoundedBrowserDiagnosticResult<T> = { ok: true; value: T } | { ok: false; error: Error }
656657

657658
/**

packages/runtime-playground/src/browser-result-sanitization.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ export function sanitizeBrowserResultValue<T>(value: T, key = ""): T {
2525
if (DIAGNOSTIC_KEY_PATTERN.test(key)) {
2626
return redactString(value, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true }) as T
2727
}
28+
return sanitizePersistedBrowserText(value) as T
29+
}
30+
31+
function sanitizePersistedBrowserText(value: string): string {
2832
return value
33+
.replace(/(?:https?|wss?):\/\/[^\s"'<>]+|\/[A-Za-z0-9._~!$&'()*+,;=:@%/-]*\?[^\s"'<>]+/gi, (url) => sanitizeBrowserResultUrl(url))
34+
.replace(/\b((?:access[_-]?token|api[_-]?key|authorization|cookie|nonce|password|secret|session[_-]?token)\s*[=:]\s*)[^\s,;]+/gi, "$1[redacted]")
2935
}
3036

3137
export function sanitizeBrowserArtifact(artifact: BrowserArtifact): BrowserArtifact {

packages/runtime-playground/src/editor-command-runners.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ export async function runEditorCanvasProbeCommand({
6767
const startedAt = now()
6868
const startedAtMs = Date.now()
6969
const browser = await launchChromiumBrowser()
70+
const routeTracker = createBrowserPreviewRouteTracker()
7071
const errors: BrowserProbeErrorRecord[] = []
7172
let artifact: BrowserArtifact | undefined
7273
let finalUrl = targetUrl
@@ -83,7 +84,7 @@ export async function runEditorCanvasProbeCommand({
8384

8485
const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext(topology.contextOptions()) : null
8586
if (context) {
86-
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin)
87+
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin, routeTracker)
8788
}
8889
const page = context ? await context.newPage() : await browser.newPage()
8990
viewport = await browserProbeViewport(page)
@@ -232,9 +233,36 @@ export async function runEditorCanvasProbeCommand({
232233
})
233234
}
234235
} finally {
235-
await browser.close()
236+
for (const routeError of await closeBrowserAndDrainPreviewRoutes(browser, routeTracker)) {
237+
errors.push(serializeBrowserError("probe-error", routeError))
238+
pendingError ??= routeError
239+
}
240+
if (artifact) {
241+
artifact.summary.errors = errors.length
242+
await artifactSession.writeJson("summary", "editor-canvas-summary.json", {
243+
schema: "wp-codebox/editor-canvas-probe/v1",
244+
requestedUrl: targetUrl,
245+
preview,
246+
...previewOrigins,
247+
finalUrl,
248+
...(windowLocationOrigin ? { windowLocationOrigin } : {}),
249+
startedAt,
250+
finishedAt: now(),
251+
timeoutMs,
252+
files: artifact.files,
253+
hashes: {
254+
...(screenshotSha256 ? { screenshot: { algorithm: "sha256", value: screenshotSha256 } } : {}),
255+
},
256+
viewport,
257+
errors,
258+
summary: artifact.summary.editorCanvas,
259+
})
260+
}
236261
}
237262

263+
if (!artifact) {
264+
throw pendingError ?? new Error("wordpress.editor-canvas-probe did not produce an artifact")
265+
}
238266
if (pendingError) {
239267
throw new BrowserCommandArtifactError(pendingError.message, artifact)
240268
}

tests/browser-preview-routing.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,15 @@ test("shared browser cleanup drains late registrations even when browser close f
131131
assertNoSentinels(lifecycleErrors[0]!.message, "cleanup diagnostic")
132132
})
133133

134+
test("shared browser cleanup bounds a close operation that never settles", async () => {
135+
const tracker = createBrowserPreviewRouteTracker()
136+
const startedAt = Date.now()
137+
const lifecycleErrors = await closeBrowserAndDrainPreviewRoutes({ close: () => new Promise<void>(() => {}) }, tracker, 10)
138+
assert(Date.now() - startedAt < 250)
139+
assert.equal(lifecycleErrors.length, 1)
140+
assert.match(lifecycleErrors[0]!.message, /operation=browser-close-timeout/)
141+
})
142+
134143
test("the complete route callback contains continue and policy abort failures", async () => {
135144
const cases = [
136145
{ name: "invalid URL continue", options: { url: "not-a-url", continueErrors: [routeFetchError("continue failed")] }, operation: "continue-invalid-url" },
@@ -153,6 +162,26 @@ test("closed-context failures across the route callback are swallowed during cle
153162
assert.equal(fixture.tracker.errors.length, 0)
154163
})
155164

165+
test("unexpected recoverable and redirect abort failures are operation-classified", async () => {
166+
const cases = [
167+
{ outcomes: [routeFetchError("failed to decompress 'gzip' encoding")], operation: "abort-recoverable-fetch" },
168+
{ outcomes: [routedResponse(302, { location: "https://outside.test/escape" })], operation: "abort-redirect-escape" },
169+
{ outcomes: Array.from({ length: 10 }, () => routedResponse(302, { location: "http://routed.test/loop" })), operation: "abort-redirect-limit" },
170+
]
171+
for (const item of cases) {
172+
const fixture = await routedFixture("script", item.outcomes, undefined, { abortErrors: [routeFetchError("abort transport failed")] })
173+
await assert.doesNotReject(fixture.run())
174+
await assert.rejects(drainBrowserPreviewRouteTracker(fixture.tracker), new RegExp(`operation=${item.operation}`))
175+
assertNoSentinels(JSON.stringify(fixture.tracker.errors), item.operation)
176+
}
177+
178+
const closedAbort = await routedFixture("script", [routeFetchError("failed to decompress 'gzip' encoding")], undefined, {
179+
abortErrors: [new Error("route.abort: Target page, context or browser has been closed")],
180+
})
181+
await assert.doesNotReject(closedAbort.run())
182+
await assert.doesNotReject(drainBrowserPreviewRouteTracker(closedAbort.tracker))
183+
})
184+
156185
test("disposed contexts and decompression failures abort without retrying or tracking errors", async () => {
157186
for (const message of ["Request context disposed.", "failed to decompress 'gzip' encoding"]) {
158187
const fixture = await routedFixture("document", [routeFetchError(message)])

0 commit comments

Comments
 (0)