Skip to content

Commit fc41d30

Browse files
committed
fix: enforce canonical preview boundaries
1 parent ef291ef commit fc41d30

7 files changed

Lines changed: 98 additions & 29 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@
178178
"test:native-agent-task-interruption": "node tests/execute-native-agent-task-interruption.test.mjs",
179179
"test:native-agent-task-playground-e2e": "tsx tests/execute-native-agent-task-playground-e2e.test.ts",
180180
"test:bench-command-step-behavior": "tsx tests/bench-command-step-behavior.test.ts",
181-
"test:generic-primitives": "npm run test:artifact-path-primitives && npm run test:browser-callback-materialization-contracts && npm run test:source-package-compiler-primitives && npm run test:bench-command-step-behavior && npm run test:generic-ability-runtime-run",
181+
"test:generic-primitives": "npm run test:artifact-path-primitives && npm run test:browser-callback-materialization-contracts && npm run test:browser-canonical-preview-origin && npm run test:source-package-compiler-primitives && npm run test:bench-command-step-behavior && npm run test:generic-ability-runtime-run",
182182
"test:browser-artifact-session": "tsx tests/browser-artifact-session.test.ts",
183183
"test:browser-environment-matrix": "tsx --test tests/browser-environment-matrix.test.ts tests/browser-environment-matrix.browser.test.ts",
184184
"test:browser-diagnostic-providers": "tsx tests/browser-diagnostic-providers.test.ts",

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { browserAssertionsSummary, browserStepRecord, executeBrowserInteractionS
1212
import { browserCommandLivenessPolicy, isBrowserCommandLivenessError, withBrowserCommandLiveness } from "./browser-liveness.js"
1313
import { serializeBrowserError } from "./browser-metrics.js"
1414
import { executeBrowserObservationAssertion } from "./browser-observation-assertions.js"
15-
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
15+
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
1616
import { BROWSER_PROBE_STATE_INIT_SCRIPT, browserProbeReplayability, browserProbeViewport } from "./browser-probe.js"
1717
import { runBrowserProbeCommand, type BrowserProbeRunPlan } from "./browser-probe-runner.js"
1818
import { browserActionTargetUrls, browserAuthRequest, browserProbeWaterfallArtifact, browserProbeWebSocketArtifact, browserProbeWebSocketSummary, browserRedirectDiagnosticsArtifact, browserRequestCoverageArtifact, browserStorageStateAuthSummary, browserStorageStateImportFromArgs, browserWordPressDiagnosticsArtifact, createBrowserProbeProgressTracker, fileSha256, installBrowserWordPressDiagnostics, installWordPressAdminAuthCookies, livenessRemainingWallTimeMs, normalizeBrowserProbeScriptCheckpoint, type BrowserCommandProgressEvent, type BrowserStorageStateImport } from "./browser-probe-support.js"
@@ -144,6 +144,10 @@ export async function runBrowserActionsCommand({
144144
let adaptiveExplorationSummary: BrowserArtifact["summary"]["adaptiveExploration"] | undefined
145145

146146
try {
147+
const previewReadinessError = browserPreviewReadinessError(preview)
148+
if (previewReadinessError) {
149+
throw previewReadinessError
150+
}
147151
const context = browserPreviewNeedsContextRouting(networkPolicy) || !!storageStateImport ? await browser.newContext({
148152
...topology.contextOptions(),
149153
...(storageStateImport ? { storageState: storageStateImport.storageState } : {}),
@@ -410,6 +414,9 @@ export async function runBrowserActionsCommand({
410414
}
411415
}
412416
}
417+
} catch (error) {
418+
pendingError = error instanceof Error ? error : new Error(String(error))
419+
errors.push(serializeBrowserError("probe-error", error))
413420
} finally {
414421
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
415422
await browser.close()

packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { BrowserCommandArtifactError } from "./browser-command-artifact-error.js
66
import { attachBrowserCaptureListeners, launchChromiumBrowser, settleBrowserNetworkTasks } from "./browser-capture-session.js"
77
import { executeBrowserInteractionStep } from "./browser-interactions.js"
88
import { browserProbeReplayability } from "./browser-probe.js"
9-
import { browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
9+
import { browserPreviewReadinessError, browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
1010
import { installWordPressAdminAuthCookies } from "./browser-probe-support.js"
1111
import { bootstrapPhpCode } from "./php-bootstrap.js"
1212
import { assertPlaygroundResponseOk, type PlaygroundRunResponse } from "./playground-command-errors.js"
@@ -36,6 +36,10 @@ export async function runBrowserMultiActorScenarioCommand(input: {
3636
let failure: Error | undefined
3737

3838
try {
39+
const previewReadinessError = browserPreviewReadinessError(topology.preview)
40+
if (previewReadinessError) {
41+
throw previewReadinessError
42+
}
3943
const clientEntries: Array<[string, BrowserMultiActorClient]> = []
4044
const actorPages: Array<{ actor: string; page: Pick<Page, "goto"> }> = []
4145
// Playground PHP commands share one runtime endpoint, so provision identities

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,10 @@ export async function runEditorOpenCommand({
550550
let artifact: BrowserArtifact | undefined
551551

552552
try {
553+
const previewReadinessError = browserPreviewReadinessError(preview)
554+
if (previewReadinessError) {
555+
throw previewReadinessError
556+
}
553557
const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext(topology.contextOptions()) : null
554558
if (context) {
555559
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin)
@@ -626,6 +630,9 @@ export async function runEditorOpenCommand({
626630
})
627631
screenshotSha256 = await fileSha256(screenshotPath)
628632
}
633+
} catch (error) {
634+
pendingError = error instanceof Error ? error : new Error(String(error))
635+
errors.push(serializeBrowserError("probe-error", error))
629636
} finally {
630637
await browser.close()
631638
if (capture.has("steps")) {
@@ -833,6 +840,10 @@ export async function runEditorActionsCommand({
833840
let artifact: BrowserArtifact | undefined
834841

835842
try {
843+
const previewReadinessError = browserPreviewReadinessError(preview)
844+
if (previewReadinessError) {
845+
throw previewReadinessError
846+
}
836847
const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext(topology.contextOptions()) : null
837848
if (context) {
838849
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin)
@@ -938,6 +949,9 @@ export async function runEditorActionsCommand({
938949
await artifactSession.writeGenerated("screenshot", "editor-action-screenshot.png", (path) => page.screenshot({ path, fullPage: true }).then(() => undefined))
939950
screenshotSha256 = await fileSha256(screenshotPath)
940951
}
952+
} catch (error) {
953+
pendingError = error instanceof Error ? error : new Error(String(error))
954+
errors.push(serializeBrowserError("probe-error", error))
941955
} finally {
942956
await browser.close()
943957
if (capture.has("steps")) {
@@ -1931,6 +1945,10 @@ export async function runEditorValidateBlocksCommand({
19311945
let artifact: BrowserArtifact | undefined
19321946

19331947
try {
1948+
const previewReadinessError = browserPreviewReadinessError(preview)
1949+
if (previewReadinessError) {
1950+
throw previewReadinessError
1951+
}
19341952
const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext(topology.contextOptions()) : null
19351953
if (context) {
19361954
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin)

packages/runtime-playground/src/preview-server.ts

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ function createPreviewRouteRegistry(): InternalPreviewRouteRegistry {
161161

162162
function proxyPreviewRequest(target: URL, incoming: IncomingMessage, outgoing: ServerResponse): Promise<void> {
163163
return new Promise((resolve) => {
164+
const requestTarget = previewProxyRequestTarget(incoming)
164165
let settled = false
165166
let targetResponse: IncomingMessage | undefined
166167
const settle = () => {
@@ -182,12 +183,12 @@ function proxyPreviewRequest(target: URL, incoming: IncomingMessage, outgoing: S
182183
hostname: target.hostname,
183184
port: target.port,
184185
method: incoming.method,
185-
path: previewProxyRequestPath(incoming.url),
186-
headers: proxyRequestHeaders(incoming.headers),
186+
path: requestTarget.path,
187+
headers: proxyRequestHeaders(incoming.headers, requestTarget),
187188
},
188189
(response) => {
189190
targetResponse = response
190-
outgoing.writeHead(response.statusCode ?? 502, response.statusMessage, proxyResponseHeaders(response.headers, incoming, target))
191+
outgoing.writeHead(response.statusCode ?? 502, response.statusMessage, proxyResponseHeaders(response.headers, requestTarget, target))
191192
response.on("error", (error) => {
192193
outgoing.destroy(error)
193194
settle()
@@ -211,16 +212,31 @@ function proxyPreviewRequest(target: URL, incoming: IncomingMessage, outgoing: S
211212
})
212213
}
213214

214-
function previewProxyRequestPath(rawUrl: string | undefined): string {
215-
if (!rawUrl) {
216-
return "/"
217-
}
215+
interface PreviewProxyRequestTarget {
216+
host: string
217+
path: string
218+
port: string
219+
protocol: "http:" | "https:"
220+
}
221+
222+
function previewProxyRequestTarget(incoming: IncomingMessage): PreviewProxyRequestTarget {
223+
const rawUrl = incoming.url ?? "/"
218224
try {
219225
const url = new URL(rawUrl)
220-
return `${url.pathname}${url.search}`
226+
if (url.protocol === "http:" || url.protocol === "https:") {
227+
return {
228+
host: url.host,
229+
path: `${url.pathname}${url.search}`,
230+
port: url.port || (url.protocol === "https:" ? "443" : "80"),
231+
protocol: url.protocol,
232+
}
233+
}
221234
} catch {
222-
return rawUrl
235+
// Origin-form requests use the proxy listener's HTTP authority.
223236
}
237+
const host = incoming.headers.host ?? "localhost"
238+
const authority = new URL(`http://${host}`)
239+
return { host: authority.host, path: rawUrl, port: authority.port || "80", protocol: "http:" }
224240
}
225241

226242
function createPreviewProxyQueue(): (task: () => Promise<void>) => Promise<void> {
@@ -279,28 +295,36 @@ function formatPreviewHost(host: string): string {
279295
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host
280296
}
281297

282-
function proxyRequestHeaders(headers: IncomingHttpHeaders): IncomingHttpHeaders {
298+
function proxyRequestHeaders(headers: IncomingHttpHeaders, requestTarget: PreviewProxyRequestTarget): IncomingHttpHeaders {
283299
const forwarded = { ...headers }
284300
delete forwarded.connection
285301
delete forwarded["transfer-encoding"]
302+
delete forwarded.forwarded
303+
delete forwarded["x-forwarded-for"]
304+
delete forwarded["x-forwarded-host"]
305+
delete forwarded["x-forwarded-port"]
306+
delete forwarded["x-forwarded-proto"]
286307

287308
return {
288309
...forwarded,
310+
host: requestTarget.host,
311+
"x-forwarded-host": requestTarget.host,
312+
"x-forwarded-port": requestTarget.port,
313+
"x-forwarded-proto": requestTarget.protocol.slice(0, -1),
289314
}
290315
}
291316

292-
function proxyResponseHeaders(headers: IncomingHttpHeaders, incoming: IncomingMessage, target: URL): IncomingHttpHeaders {
317+
function proxyResponseHeaders(headers: IncomingHttpHeaders, requestTarget: PreviewProxyRequestTarget, target: URL): IncomingHttpHeaders {
293318
const forwarded = { ...headers }
294319
delete forwarded.connection
295320
delete forwarded["transfer-encoding"]
296321

297322
if (typeof forwarded.location === "string") {
298323
try {
299324
const location = new URL(forwarded.location, target)
300-
if (location.origin === target.origin && incoming.headers.host) {
301-
const protocol = firstHeaderValue(incoming.headers["x-forwarded-proto"]) ?? "http"
302-
location.protocol = `${protocol.replace(/:$/, "")}:`
303-
const visible = new URL(`${location.protocol}//${incoming.headers.host}`)
325+
if (location.origin === target.origin) {
326+
location.protocol = requestTarget.protocol
327+
const visible = new URL(`${requestTarget.protocol}//${requestTarget.host}`)
304328
location.hostname = visible.hostname
305329
location.port = visible.port
306330
forwarded.location = location.toString()
@@ -313,10 +337,6 @@ function proxyResponseHeaders(headers: IncomingHttpHeaders, incoming: IncomingMe
313337
return forwarded
314338
}
315339

316-
function firstHeaderValue(value: string | string[] | undefined): string | undefined {
317-
return Array.isArray(value) ? value[0] : value
318-
}
319-
320340
function writeProxyError(outgoing: ServerResponse, error: Error): void {
321341
if (outgoing.headersSent) {
322342
outgoing.destroy(error)

tests/browser-callback-materialization-contracts.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
trustedBrowserSessionOrigins,
2222
verifyBrowserCallbackSignature,
2323
} from "../packages/runtime-core/src/index.js"
24-
import { browserPreviewAuthCookieUrls, browserPreviewTopology } from "../packages/runtime-playground/src/browser-preview-routing.js"
24+
import { browserPreviewAuthCookieUrls, browserPreviewReadinessError, browserPreviewTopology } from "../packages/runtime-playground/src/browser-preview-routing.js"
2525
import { browserReviewSummary, type BrowserArtifact } from "../packages/runtime-playground/src/browser-artifacts.js"
2626
import { closeHttpServer, listenLocalHttpServer, withPreviewProxy, type PlaygroundCliServer } from "../packages/runtime-playground/src/preview-server.js"
2727

@@ -185,6 +185,7 @@ assert.deepEqual(unsupportedCanonicalTopology.preview.diagnostics[0], {
185185
message: "The local Playground provider cannot preserve this declared canonical preview protocol.",
186186
details: { status: "inconclusive", canonicalOrigin: "https://secure.example.test", supportedProtocols: ["http:"] },
187187
})
188+
assert.match(browserPreviewReadinessError(unsupportedCanonicalTopology.preview)?.message ?? "", /cannot preserve/)
188189

189190
let activeUpstreamRequests = 0
190191
let maxActiveUpstreamRequests = 0

tests/browser-canonical-preview-origin.test.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,23 @@
11
import assert from "node:assert/strict"
22
import { createServer } from "node:http"
33
import { launchChromiumBrowser } from "../packages/runtime-playground/src/browser-capture-session.js"
4-
import { browserPreviewTopology, routeBrowserPreviewContextNetwork } from "../packages/runtime-playground/src/browser-preview-routing.js"
4+
import { browserPreviewNetworkPolicySummary, browserPreviewTopology, routeBrowserPreviewContextNetwork } from "../packages/runtime-playground/src/browser-preview-routing.js"
55
import { closeHttpServer, listenLocalHttpServer, withPreviewProxy, type PlaygroundCliServer } from "../packages/runtime-playground/src/preview-server.js"
66

7-
const requests: Array<{ host?: string; method?: string; url?: string; body: string }> = []
7+
const requests: Array<{ host?: string; method?: string; url?: string; body: string; forwardedHost?: string; forwardedPort?: string; forwardedProto?: string }> = []
88
const upstream = createServer((request, response) => {
99
let body = ""
1010
request.on("data", (chunk) => { body += chunk.toString() })
1111
request.on("end", () => {
12-
requests.push({ host: request.headers.host, method: request.method, url: request.url, body })
12+
requests.push({
13+
host: request.headers.host,
14+
method: request.method,
15+
url: request.url,
16+
body,
17+
forwardedHost: request.headers["x-forwarded-host"] as string | undefined,
18+
forwardedPort: request.headers["x-forwarded-port"] as string | undefined,
19+
forwardedProto: request.headers["x-forwarded-proto"] as string | undefined,
20+
})
1321
if (request.url === "/events/redirect/") {
1422
response.writeHead(302, { location: "http://localhost/events/final/" })
1523
response.end()
@@ -21,7 +29,7 @@ const upstream = createServer((request, response) => {
2129
return
2230
}
2331
if (request.url === "/events/external-redirect/") {
24-
response.writeHead(302, { location: "https://undeclared.example/escape/" })
32+
response.writeHead(302, { location: "http://undeclared.example/escape/" })
2533
response.end()
2634
return
2735
}
@@ -52,12 +60,20 @@ const topology = browserPreviewTopology(
5260
const browser = await launchChromiumBrowser()
5361

5462
try {
55-
const context = await browser.newContext(topology.contextOptions())
63+
const context = await browser.newContext({
64+
...topology.contextOptions(),
65+
extraHTTPHeaders: {
66+
"x-forwarded-host": "spoofed.example",
67+
"x-forwarded-port": "443",
68+
"x-forwarded-proto": "https",
69+
},
70+
})
5671
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.origins.localProxyOrigin)
5772
const page = await context.newPage()
5873

5974
await page.goto(topology.resolveUrl("/events/"), { waitUntil: "load" })
6075
assert.equal(new URL(page.url()).origin, "http://localhost")
76+
assert(requests.some((request) => request.url === "/events/" && request.host === "localhost" && request.forwardedHost === "localhost" && request.forwardedPort === "80" && request.forwardedProto === "http"))
6177
assert.deepEqual(await page.evaluate(() => {
6278
history.pushState({}, "", "http://localhost/events/pushed/")
6379
history.replaceState({}, "", "http://localhost/events/replaced/")
@@ -87,8 +103,11 @@ try {
87103
await Promise.all([page.waitForURL("http://localhost/events/subsite/"), page.click("#subsite")])
88104
assert.equal(page.url(), "http://localhost/events/subsite/")
89105

90-
await assert.rejects(page.goto("https://undeclared.example/escape/"))
106+
await assert.rejects(page.goto("http://undeclared.example/escape/"))
91107
await assert.rejects(page.goto("http://localhost/events/external-redirect/"))
108+
const policyEvidence = browserPreviewNetworkPolicySummary(topology.networkPolicy)
109+
assert((policyEvidence.hosts["undeclared.example"]?.blocked ?? 0) >= 1, JSON.stringify(policyEvidence))
110+
assert(policyEvidence.blockedRequests >= 1)
92111

93112
assert.deepEqual(topology.origins, {
94113
localPreviewOrigin: proxy.serverUrl,

0 commit comments

Comments
 (0)