fix(browser): enforce SSRF policy on snapshot, screenshot, and tab routes [AI] (#66040)

* fix: address issue

* fix: address review feedback

* fix: finalize issue changes

* fix: address review-pr skill feedback

* fix: address PR review feedback

* fix: address PR review feedback

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-04-13 23:56:39 +05:30
committed by GitHub
parent 55a3c8ea07
commit b75ad800a5
8 changed files with 585 additions and 19 deletions

View File

@@ -1,5 +1,8 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createExistingSessionAgentSharedModule } from "./existing-session.test-support.js";
import {
createExistingSessionAgentSharedModule,
existingSessionRouteState,
} from "./existing-session.test-support.js";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
const chromeMcpMocks = vi.hoisted(() => ({
@@ -14,7 +17,9 @@ const chromeMcpMocks = vi.hoisted(() => ({
const navigationGuardMocks = vi.hoisted(() => ({
assertBrowserNavigationAllowed: vi.fn(async () => {}),
assertBrowserNavigationResultAllowed: vi.fn(async () => {}),
assertBrowserNavigationResultAllowed: vi.fn(
async (_opts?: { url: string; ssrfPolicy?: unknown }) => {},
),
withBrowserNavigationPolicy: vi.fn((ssrfPolicy?: unknown) => (ssrfPolicy ? { ssrfPolicy } : {})),
}));
@@ -37,6 +42,7 @@ vi.mock("./agent.shared.js", () => createExistingSessionAgentSharedModule());
const DEFAULT_SSRF_POLICY = { allowPrivateNetwork: false } as const;
const { registerBrowserAgentActRoutes } = await import("./agent.act.js");
const routeState = existingSessionRouteState;
function getActPostHandler(
ssrfPolicy: { allowPrivateNetwork: false } | null = DEFAULT_SSRF_POLICY,
@@ -65,6 +71,13 @@ describe("existing-session interaction navigation guard", () => {
fn.mockClear();
}
chromeMcpMocks.evaluateChromeMcpScript.mockResolvedValue("https://example.com");
routeState.profileCtx.listTabs.mockReset();
routeState.profileCtx.listTabs.mockResolvedValue([
{
targetId: "7",
url: "https://example.com",
},
]);
});
afterEach(() => {
@@ -144,6 +157,79 @@ describe("existing-session interaction navigation guard", () => {
]);
});
it("checks URLs for tabs opened during the interaction window", async () => {
routeState.profileCtx.listTabs
.mockResolvedValueOnce([
{
targetId: "7",
url: "https://example.com",
},
])
.mockResolvedValueOnce([
{
targetId: "7",
url: "https://example.com",
},
{
targetId: "9",
url: "http://169.254.169.254/latest/meta-data/",
},
]);
const response = await runAction({ kind: "click", ref: "btn-1" });
expect(response.statusCode).toBe(200);
expect(chromeMcpMocks.clickChromeMcpElement).toHaveBeenCalledOnce();
expectNavigationProbeUrls([
"https://example.com",
"https://example.com",
"https://example.com",
"http://169.254.169.254/latest/meta-data/",
]);
});
it("fails closed when a newly opened tab URL is blocked", async () => {
routeState.profileCtx.listTabs
.mockResolvedValueOnce([
{
targetId: "7",
url: "https://example.com",
},
])
.mockResolvedValueOnce([
{
targetId: "7",
url: "https://example.com",
},
{
targetId: "9",
url: "http://169.254.169.254/latest/meta-data/",
},
]);
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation(
async (opts?: { url: string }) => {
const url = opts?.url ?? "";
if (url.includes("169.254.169.254")) {
throw new Error("blocked new tab");
}
},
);
const handler = getActPostHandler();
const response = createBrowserRouteResponse();
const pending =
handler?.({ params: {}, query: {}, body: { kind: "click", ref: "btn-1" } }, response.res) ??
Promise.resolve();
void pending.catch(() => {});
const completion = (async () => {
await vi.runAllTimersAsync();
await pending;
})();
await expect(completion).rejects.toThrow("blocked new tab");
expect(chromeMcpMocks.clickChromeMcpElement).toHaveBeenCalledOnce();
});
it("fails closed when location probes never return a usable url", async () => {
chromeMcpMocks.evaluateChromeMcpScript
.mockResolvedValueOnce("result" as never)
@@ -243,6 +329,7 @@ describe("existing-session interaction navigation guard", () => {
expect(response.statusCode).toBe(200);
expect(chromeMcpMocks.pressChromeMcpKey).toHaveBeenCalledOnce();
expect(chromeMcpMocks.evaluateChromeMcpScript).not.toHaveBeenCalled();
expect(routeState.profileCtx.listTabs).not.toHaveBeenCalled();
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
});

View File

@@ -71,11 +71,28 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: {
userDataDir?: string;
targetId: string;
ssrfPolicy?: BrowserNavigationPolicyOptions["ssrfPolicy"];
listTabs: () => Promise<Array<{ targetId: string; url: string }>>;
initialTabTargetIds: ReadonlySet<string>;
}): Promise<void> {
const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy);
if (!ssrfPolicyOpts.ssrfPolicy) {
return;
}
const listTabs = params.listTabs;
const initialTabTargetIds = params.initialTabTargetIds;
const assertNewTabsAllowed = async () => {
const tabs = await listTabs();
for (const tab of tabs) {
if (initialTabTargetIds.has(tab.targetId)) {
continue;
}
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
};
let lastObservedUrl: string | undefined;
let sawStableAllowedUrl = false;
@@ -103,6 +120,7 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: {
}
if (sawStableAllowedUrl) {
await assertNewTabsAllowed();
return;
}
@@ -122,6 +140,7 @@ async function assertExistingSessionPostInteractionNavigationAllowed(params: {
...ssrfPolicyOpts,
});
if (followUpUrl === lastObservedUrl) {
await assertNewTabsAllowed();
return;
}
} catch {
@@ -368,11 +387,16 @@ export function registerBrowserAgentActRoutes(
const isExistingSession = getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp;
const profileName = profileCtx.profile.name;
if (isExistingSession) {
const initialTabTargetIds = withBrowserNavigationPolicy(ssrfPolicy).ssrfPolicy
? new Set((await profileCtx.listTabs()).map((currentTab) => currentTab.targetId))
: new Set<string>();
const existingSessionNavigationGuard = {
profileName,
userDataDir: profileCtx.profile.userDataDir,
targetId: tab.targetId,
ssrfPolicy,
listTabs: () => profileCtx.listTabs(),
initialTabTargetIds,
};
const unsupportedMessage = getExistingSessionUnsupportedMessage(action);
if (unsupportedMessage) {

View File

@@ -21,6 +21,12 @@ const chromeMcpMocks = vi.hoisted(() => ({
})),
}));
const navigationGuardMocks = vi.hoisted(() => ({
assertBrowserNavigationAllowed: vi.fn(async () => {}),
assertBrowserNavigationResultAllowed: vi.fn(async () => {}),
withBrowserNavigationPolicy: vi.fn((ssrfPolicy?: unknown) => (ssrfPolicy ? { ssrfPolicy } : {})),
}));
vi.mock("../chrome-mcp.js", () => ({
clickChromeMcpElement: vi.fn(async () => {}),
closeChromeMcpTab: vi.fn(async () => {}),
@@ -42,9 +48,9 @@ vi.mock("../cdp.js", () => ({
}));
vi.mock("../navigation-guard.js", () => ({
assertBrowserNavigationAllowed: vi.fn(async () => {}),
assertBrowserNavigationResultAllowed: vi.fn(async () => {}),
withBrowserNavigationPolicy: vi.fn(() => ({})),
assertBrowserNavigationAllowed: navigationGuardMocks.assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed: navigationGuardMocks.assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy: navigationGuardMocks.withBrowserNavigationPolicy,
}));
vi.mock("../screenshot.js", () => ({
@@ -66,20 +72,20 @@ vi.mock("./agent.shared.js", () => createExistingSessionAgentSharedModule());
const { registerBrowserAgentActRoutes } = await import("./agent.act.js");
const { registerBrowserAgentSnapshotRoutes } = await import("./agent.snapshot.js");
function getSnapshotGetHandler() {
function getSnapshotGetHandler(ssrfPolicy?: unknown) {
const { app, getHandlers } = createBrowserRouteApp();
registerBrowserAgentSnapshotRoutes(app, {
state: () => ({ resolved: { ssrfPolicy: undefined } }),
state: () => ({ resolved: { ssrfPolicy } }),
} as never);
const handler = getHandlers.get("/snapshot");
expect(handler).toBeTypeOf("function");
return handler;
}
function getSnapshotPostHandler() {
function getSnapshotPostHandler(ssrfPolicy?: unknown) {
const { app, postHandlers } = createBrowserRouteApp();
registerBrowserAgentSnapshotRoutes(app, {
state: () => ({ resolved: { ssrfPolicy: undefined } }),
state: () => ({ resolved: { ssrfPolicy } }),
} as never);
const handler = postHandlers.get("/screenshot");
expect(handler).toBeTypeOf("function");
@@ -99,10 +105,14 @@ function getActPostHandler() {
describe("existing-session browser routes", () => {
beforeEach(() => {
routeState.profileCtx.ensureTabAvailable.mockClear();
routeState.profileCtx.listTabs.mockClear();
chromeMcpMocks.evaluateChromeMcpScript.mockReset();
chromeMcpMocks.navigateChromeMcpPage.mockClear();
chromeMcpMocks.takeChromeMcpScreenshot.mockClear();
chromeMcpMocks.takeChromeMcpSnapshot.mockClear();
navigationGuardMocks.assertBrowserNavigationAllowed.mockClear();
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockClear();
navigationGuardMocks.withBrowserNavigationPolicy.mockClear();
chromeMcpMocks.evaluateChromeMcpScript
.mockResolvedValueOnce({ labels: 1, skipped: 0 } as never)
.mockResolvedValueOnce(true);
@@ -125,6 +135,7 @@ describe("existing-session browser routes", () => {
profileName: "chrome-live",
targetId: "7",
});
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
expect(chromeMcpMocks.takeChromeMcpScreenshot).toHaveBeenCalled();
});
@@ -153,6 +164,38 @@ describe("existing-session browser routes", () => {
fullPage: false,
format: "jpeg",
});
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
});
it("checks existing-session snapshot URL when SSRF policy is configured", async () => {
const handler = getSnapshotGetHandler({ allowPrivateNetwork: false });
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: { format: "ai" } }, response.res);
expect(response.statusCode).toBe(200);
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
url: "https://example.com",
ssrfPolicy: { allowPrivateNetwork: false },
});
});
it("checks existing-session screenshot URL when SSRF policy is configured", async () => {
const handler = getSnapshotPostHandler({ allowPrivateNetwork: false });
const response = createBrowserRouteResponse();
await handler?.(
{
params: {},
query: {},
body: { ref: "btn-1", type: "jpeg" },
},
response.res,
);
expect(response.statusCode).toBe(200);
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledWith({
url: "https://example.com",
ssrfPolicy: { allowPrivateNetwork: false },
});
});
it("rejects selector-based element screenshots for existing-session profiles", async () => {

View File

@@ -315,9 +315,16 @@ export function registerBrowserAgentSnapshotRoutes(
targetId,
run: async ({ profileCtx, tab, cdpUrl }) => {
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (element) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.screenshotElement);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
const buffer = await takeChromeMcpScreenshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,
@@ -396,9 +403,16 @@ export function registerBrowserAgentSnapshotRoutes(
return jsonError(res, 400, "labels/mode=efficient require format=ai");
}
if (getBrowserProfileCapabilities(profileCtx.profile).usesChromeMcp) {
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (plan.selectorValue || plan.frameSelectorValue) {
return jsonError(res, 400, EXISTING_SESSION_LIMITS.snapshot.snapshotSelector);
}
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
const snapshot = await takeChromeMcpSnapshot({
profileName: profileCtx.profile.name,
userDataDir: profileCtx.profile.userDataDir,

View File

@@ -7,6 +7,12 @@ export const existingSessionRouteState = {
driver: "existing-session" as const,
name: "chrome-live",
},
listTabs: vi.fn(async () => [
{
targetId: "7",
url: "https://example.com",
},
]),
ensureTabAvailable: vi.fn(async () => ({
targetId: "7",
url: "https://example.com",

View File

@@ -1,7 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { registerBrowserTabRoutes } from "./tabs.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createBrowserRouteApp, createBrowserRouteResponse } from "./test-helpers.js";
const navigationGuardMocks = vi.hoisted(() => ({
assertBrowserNavigationAllowed: vi.fn(async () => {}),
assertBrowserNavigationResultAllowed: vi.fn(
async (_opts?: { url: string; ssrfPolicy?: unknown }) => {},
),
withBrowserNavigationPolicy: vi.fn((ssrfPolicy?: unknown) => (ssrfPolicy ? { ssrfPolicy } : {})),
}));
vi.mock("../navigation-guard.js", () => navigationGuardMocks);
const { registerBrowserTabRoutes } = await import("./tabs.js");
function createProfileContext(overrides?: Partial<ReturnType<typeof baseProfileContext>>) {
return {
...baseProfileContext(),
@@ -44,12 +55,21 @@ function baseProfileContext() {
};
}
function createRouteContext(profileCtx: ReturnType<typeof createProfileContext>) {
function createRouteContext(
profileCtx: ReturnType<typeof createProfileContext>,
options?: { ssrfPolicy?: unknown },
) {
return {
state: () => ({ resolved: { ssrfPolicy: undefined } }),
state: () => ({ resolved: { ssrfPolicy: options?.ssrfPolicy } }),
forProfile: () => profileCtx,
listProfiles: vi.fn(async () => []),
mapTabError: vi.fn(() => null),
mapTabError: vi.fn((err: unknown) => {
if (!(err instanceof Error)) {
return null;
}
const status = "status" in err && typeof err.status === "number" ? err.status : 400;
return { status, message: err.message };
}),
ensureBrowserAvailable: profileCtx.ensureBrowserAvailable,
ensureTabAvailable: profileCtx.ensureTabAvailable,
isHttpReachable: profileCtx.isHttpReachable,
@@ -66,9 +86,13 @@ function createRouteContext(profileCtx: ReturnType<typeof createProfileContext>)
async function callTabsAction(params: {
body: Record<string, unknown>;
profileCtx: ReturnType<typeof createProfileContext>;
ssrfPolicy?: unknown;
}) {
const { app, postHandlers } = createBrowserRouteApp();
registerBrowserTabRoutes(app, createRouteContext(params.profileCtx) as never);
registerBrowserTabRoutes(
app,
createRouteContext(params.profileCtx, { ssrfPolicy: params.ssrfPolicy }) as never,
);
const handler = postHandlers.get("/tabs/action");
expect(handler).toBeTypeOf("function");
@@ -77,7 +101,51 @@ async function callTabsAction(params: {
return response;
}
async function callTabsList(params: {
profileCtx: ReturnType<typeof createProfileContext>;
ssrfPolicy?: unknown;
}) {
const { app, getHandlers } = createBrowserRouteApp();
registerBrowserTabRoutes(
app,
createRouteContext(params.profileCtx, { ssrfPolicy: params.ssrfPolicy }) as never,
);
const handler = getHandlers.get("/tabs");
expect(handler).toBeTypeOf("function");
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: {}, body: {} }, response.res);
return response;
}
async function callTabsFocus(params: {
profileCtx: ReturnType<typeof createProfileContext>;
body: Record<string, unknown>;
ssrfPolicy?: unknown;
}) {
const { app, postHandlers } = createBrowserRouteApp();
registerBrowserTabRoutes(
app,
createRouteContext(params.profileCtx, { ssrfPolicy: params.ssrfPolicy }) as never,
);
const handler = postHandlers.get("/tabs/focus");
expect(handler).toBeTypeOf("function");
const response = createBrowserRouteResponse();
await handler?.({ params: {}, query: {}, body: params.body }, response.res);
return response;
}
describe("browser tab routes", () => {
beforeEach(() => {
navigationGuardMocks.assertBrowserNavigationAllowed.mockReset();
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockReset();
navigationGuardMocks.withBrowserNavigationPolicy.mockReset();
navigationGuardMocks.withBrowserNavigationPolicy.mockImplementation((ssrfPolicy?: unknown) =>
ssrfPolicy ? { ssrfPolicy } : {},
);
});
it("returns browser-not-running for close when the browser is not reachable", async () => {
const profileCtx = createProfileContext({
isReachable: vi.fn(async () => false),
@@ -109,4 +177,261 @@ describe("browser tab routes", () => {
expect(profileCtx.listTabs).not.toHaveBeenCalled();
expect(profileCtx.focusTab).not.toHaveBeenCalled();
});
it("redacts blocked tab URLs from GET /tabs", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation(
async (opts?: { url: string }) => {
const url = opts?.url ?? "";
if (url.includes("169.254.169.254")) {
throw new Error("blocked");
}
},
);
const profileCtx = createProfileContext({
listTabs: vi.fn(async () => [
{
targetId: "T1",
title: "Public",
url: "https://example.com",
type: "page",
},
{
targetId: "T2",
title: "Internal",
url: "http://169.254.169.254/latest/meta-data/",
type: "page",
},
]),
});
const response = await callTabsList({
profileCtx,
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
running: true,
tabs: [
{
targetId: "T1",
title: "Public",
url: "https://example.com",
type: "page",
},
{
targetId: "T2",
title: "Internal",
url: "",
type: "page",
},
],
});
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).toHaveBeenCalledTimes(2);
});
it("blocks /tabs/focus when target tab URL fails SSRF checks", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockRejectedValueOnce(
new Error("blocked"),
);
const profileCtx = createProfileContext({
listTabs: vi.fn(async () => [
{
targetId: "T2",
title: "Internal",
url: "http://169.254.169.254/latest/meta-data/",
type: "page",
},
]),
});
const response = await callTabsFocus({
profileCtx,
body: { targetId: "T2" },
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(response.statusCode).toBe(400);
expect(profileCtx.focusTab).not.toHaveBeenCalled();
});
it("does not create a tab for /tabs/focus when target is missing", async () => {
const profileCtx = createProfileContext({
listTabs: vi.fn(async () => []),
});
const response = await callTabsFocus({
profileCtx,
body: { targetId: "T404" },
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(response.statusCode).toBe(404);
expect(profileCtx.ensureTabAvailable).not.toHaveBeenCalled();
expect(profileCtx.focusTab).not.toHaveBeenCalled();
});
it("returns conflict for ambiguous target-id prefixes in /tabs/focus", async () => {
const profileCtx = createProfileContext({
listTabs: vi.fn(async () => [
{
targetId: "T1abc",
title: "Tab 1",
url: "https://example.com",
type: "page",
},
{
targetId: "T1def",
title: "Tab 2",
url: "https://example.org",
type: "page",
},
]),
});
const response = await callTabsFocus({
profileCtx,
body: { targetId: "T1" },
});
expect(response.statusCode).toBe(409);
expect(profileCtx.focusTab).not.toHaveBeenCalled();
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
});
it("blocks /tabs/action select when target tab URL fails SSRF checks", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockRejectedValueOnce(
new Error("blocked"),
);
const profileCtx = createProfileContext({
listTabs: vi.fn(async () => [
{
targetId: "T1",
title: "Public",
url: "https://example.com",
type: "page",
},
{
targetId: "T2",
title: "Internal",
url: "http://169.254.169.254/latest/meta-data/",
type: "page",
},
]),
});
const response = await callTabsAction({
body: { action: "select", index: 1 },
profileCtx,
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(response.statusCode).toBe(400);
expect(profileCtx.focusTab).not.toHaveBeenCalled();
});
it("does not run SSRF result validation for /tabs/focus when policy is not configured", async () => {
const profileCtx = createProfileContext({
listTabs: vi.fn(async () => [
{
targetId: "T2",
title: "Internal",
url: "http://169.254.169.254/latest/meta-data/",
type: "page",
},
]),
});
const response = await callTabsFocus({
profileCtx,
body: { targetId: "T2" },
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ ok: true });
expect(profileCtx.focusTab).toHaveBeenCalledWith("T2");
expect(profileCtx.ensureTabAvailable).not.toHaveBeenCalled();
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
});
it("does not run SSRF result validation for /tabs/action select when policy is not configured", async () => {
const profileCtx = createProfileContext({
listTabs: vi.fn(async () => [
{
targetId: "T1",
title: "Public",
url: "https://example.com",
type: "page",
},
{
targetId: "T2",
title: "Internal",
url: "http://169.254.169.254/latest/meta-data/",
type: "page",
},
]),
});
const response = await callTabsAction({
body: { action: "select", index: 1 },
profileCtx,
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({ ok: true, targetId: "T2" });
expect(profileCtx.focusTab).toHaveBeenCalledWith("T2");
expect(navigationGuardMocks.assertBrowserNavigationResultAllowed).not.toHaveBeenCalled();
});
it("redacts blocked tab URLs for /tabs/action list", async () => {
navigationGuardMocks.assertBrowserNavigationResultAllowed.mockImplementation(
async (opts?: { url: string }) => {
const url = opts?.url ?? "";
if (url.includes("10.0.0.5")) {
throw new Error("blocked");
}
},
);
const profileCtx = createProfileContext({
listTabs: vi.fn(async () => [
{
targetId: "T1",
title: "Public",
url: "https://example.com",
type: "page",
},
{
targetId: "T2",
title: "Private Admin",
url: "http://10.0.0.5/admin",
type: "page",
},
]),
});
const response = await callTabsAction({
body: { action: "list" },
profileCtx,
ssrfPolicy: { allowPrivateNetwork: false },
});
expect(response.statusCode).toBe(200);
expect(response.body).toEqual({
ok: true,
tabs: [
{
targetId: "T1",
title: "Public",
url: "https://example.com",
type: "page",
},
{
targetId: "T2",
title: "Private Admin",
url: "",
type: "page",
},
],
});
});
});

View File

@@ -1,9 +1,15 @@
import { BrowserProfileUnavailableError, BrowserTabNotFoundError } from "../errors.js";
import {
BrowserProfileUnavailableError,
BrowserTabNotFoundError,
BrowserTargetAmbiguousError,
} from "../errors.js";
import {
assertBrowserNavigationAllowed,
assertBrowserNavigationResultAllowed,
withBrowserNavigationPolicy,
} from "../navigation-guard.js";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { resolveTargetIdFromTabs } from "../target-id.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
@@ -65,6 +71,34 @@ async function ensureBrowserRunning(profileCtx: ProfileContext, res: BrowserResp
return true;
}
async function redactBlockedTabUrls(params: {
tabs: Awaited<ReturnType<ProfileContext["listTabs"]>>;
ssrfPolicy: ReturnType<BrowserRouteContext["state"]>["resolved"]["ssrfPolicy"];
}): Promise<Awaited<ReturnType<ProfileContext["listTabs"]>>> {
const ssrfPolicyOpts = withBrowserNavigationPolicy(params.ssrfPolicy);
if (!ssrfPolicyOpts.ssrfPolicy) {
return params.tabs;
}
const redactedTabs: Awaited<ReturnType<ProfileContext["listTabs"]>> = [];
for (const tab of params.tabs) {
try {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
redactedTabs.push(tab);
} catch {
// Hide blocked URLs while preserving tab identity for safe operations.
redactedTabs.push({
...tab,
url: "",
});
}
}
return redactedTabs;
}
function resolveIndexedTab(
tabs: Awaited<ReturnType<ProfileContext["listTabs"]>>,
index: number | undefined,
@@ -114,7 +148,10 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
if (!reachable) {
return res.json({ running: false, tabs: [] as unknown[] });
}
const tabs = await profileCtx.listTabs();
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
res.json({ running: true, tabs });
},
});
@@ -154,7 +191,26 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
ctx,
targetId,
mutate: async (profileCtx, id) => {
await profileCtx.focusTab(id);
const tabs = await profileCtx.listTabs();
const resolved = resolveTargetIdFromTabs(id, tabs);
if (!resolved.ok) {
if (resolved.reason === "ambiguous") {
throw new BrowserTargetAmbiguousError();
}
throw new BrowserTabNotFoundError();
}
const tab = tabs.find((currentTab) => currentTab.targetId === resolved.targetId);
if (!tab) {
throw new BrowserTabNotFoundError();
}
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: tab.url,
...ssrfPolicyOpts,
});
}
await profileCtx.focusTab(resolved.targetId);
},
});
});
@@ -190,7 +246,10 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
if (!reachable) {
return res.json({ ok: true, tabs: [] as unknown[] });
}
const tabs = await profileCtx.listTabs();
const tabs = await redactBlockedTabUrls({
tabs: await profileCtx.listTabs(),
ssrfPolicy: ctx.state().resolved.ssrfPolicy,
});
return res.json({ ok: true, tabs });
}
@@ -225,6 +284,13 @@ export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: Browse
if (!target) {
throw new BrowserTabNotFoundError();
}
const ssrfPolicyOpts = withBrowserNavigationPolicy(ctx.state().resolved.ssrfPolicy);
if (ssrfPolicyOpts.ssrfPolicy) {
await assertBrowserNavigationResultAllowed({
url: target.url,
...ssrfPolicyOpts,
});
}
await profileCtx.focusTab(target.targetId);
return res.json({ ok: true, targetId: target.targetId });
}