mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:20:42 +00:00
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:
committed by
GitHub
parent
55a3c8ea07
commit
b75ad800a5
@@ -10,6 +10,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- fix(browser): enforce SSRF policy on snapshot, screenshot, and tab routes [AI]. (#66040) Thanks @pgondhi987.
|
||||
- fix(msteams): enforce sender allowlist checks on SSO signin invokes [AI]. (#66033) Thanks @pgondhi987.
|
||||
- fix(config): redact sourceConfig and runtimeConfig alias fields in redactConfigSnapshot [AI]. (#66030) Thanks @pgondhi987.
|
||||
- Agents/context engines: run opt-in turn maintenance as idle-aware background work so the next foreground turn no longer waits on proactive maintenance. (#65233) thanks @100yenadmin
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user