diff --git a/projects/browsie/docs/stagehand-harness.md b/projects/browsie/docs/stagehand-harness.md index 09df805e..89cfd20c 100644 --- a/projects/browsie/docs/stagehand-harness.md +++ b/projects/browsie/docs/stagehand-harness.md @@ -57,6 +57,10 @@ Browsie supports these typed actions: It does not run model-written JavaScript in the Eve process. Snapshot IDs expire after page state changes, and the next action must use a new snapshot. +Navigation accepts only HTTP and HTTPS URLs without embedded credentials. A destination cannot +change the browser provider. Local fixtures require the operator to set `STAGEHAND_BROWSER=local`; +a localhost URL never switches a hosted session to the application server's local browser. + ## Hosted session settings The Browserbase adapter requests `proxies: true`, `keepAlive: true`, and diff --git a/projects/browsie/docs/vault-and-otp.md b/projects/browsie/docs/vault-and-otp.md index 95e0c083..4b150b8d 100644 --- a/projects/browsie/docs/vault-and-otp.md +++ b/projects/browsie/docs/vault-and-otp.md @@ -8,4 +8,6 @@ For automated 1Password access, create a least-privilege service account, grant The model can list only labels, host mappings, field availability, and opaque item/field references. `vault_login` resolves values server-side immediately before filling. Stagehand v4.0.2 does not expose the older `act(..., { variables })` API; Browsie therefore uses a browser-session-bound `experimentalBatch` adapter with Stagehand logging disabled and redacted results. A success selector is mandatory because filling and clicking alone do not prove authentication. +Vault login requires HTTPS on an allowed hostname. The runtime rechecks the destination before each credential fill and submit, including an OTP step after navigation. Hosted login also requires `BROWSIE_LOG_SESSION=false` before the browser session starts. Changing this setting requires a new session; it cannot disable recording retroactively. + TOTP codes are generated server-side using RFC 6238 and are never stored. Challenges without a mapped TOTP secret must park the Eve task in `waiting_for_user`; the interactive Browserbase Live View remains the primary safe handoff. diff --git a/projects/browsie/package.json b/projects/browsie/package.json index 465aa4e3..27b023b0 100644 --- a/projects/browsie/package.json +++ b/projects/browsie/package.json @@ -81,5 +81,10 @@ "app/handoff", "LICENSE", "dist/shared" - ] + ], + "pnpm": { + "overrides": { + "eve>undici": "8.10.2" + } + } } diff --git a/projects/browsie/pnpm-lock.yaml b/projects/browsie/pnpm-lock.yaml index 99528202..bd874649 100644 --- a/projects/browsie/pnpm-lock.yaml +++ b/projects/browsie/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + eve>undici: 8.10.2 + importers: .: dependencies: @@ -4485,10 +4488,10 @@ packages: } engines: { node: ">=22.19.0" } - undici@8.9.0: + undici@8.10.2: resolution: { - integrity: sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==, + integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==, } engines: { node: ">=22.19.0" } @@ -6409,7 +6412,7 @@ snapshots: dependencies: ai: 7.0.82(zod@4.6.5) nitro: 3.0.260610-beta(dotenv@17.4.2)(lru-cache@11.5.3)(vite@8.3.1(@types/node@24.19.0)(esbuild@0.28.2)) - undici: 8.9.0 + undici: 8.10.2 optionalDependencies: "@opentelemetry/api": 1.9.1 microsandbox: 0.5.10 @@ -7602,7 +7605,7 @@ snapshots: undici@8.11.2: {} - undici@8.9.0: {} + undici@8.10.2: {} unenv@2.0.0-rc.24: dependencies: diff --git a/projects/browsie/server/browser-session.ts b/projects/browsie/server/browser-session.ts index 10bf779d..0ea7e153 100644 --- a/projects/browsie/server/browser-session.ts +++ b/projects/browsie/server/browser-session.ts @@ -68,11 +68,11 @@ export class BrowsieBrowserSession { } = {}, ) {} - async start(forceLocal = false): Promise { + async start(): Promise { if (this.closeRequested) throw new DOMException("The browser session was closed.", "AbortError"); if (this.stagehand && this.browser && !this.browser.closed) return; - const startPromise = this.startPromise ?? this.startOnce(forceLocal); + const startPromise = this.startPromise ?? this.startOnce(); this.startPromise = startPromise; try { await startPromise; @@ -83,10 +83,10 @@ export class BrowsieBrowserSession { } } - private async startOnce(forceLocal: boolean): Promise { + private async startOnce(): Promise { this.state.browser.status = "starting"; const started = performance.now(); - const requested = browserProvider(forceLocal); + const requested = browserProvider(); let contextStatus: "draft" | "saved" | undefined; try { @@ -257,13 +257,19 @@ export class BrowsieBrowserSession { } async run(actions: RunAction[]): Promise<{ completed: number; url: string }> { + for (const item of actions) { + if (item.action !== "goto") continue; + const destination = new URL(item.url); + if ( + !["https:", "http:"].includes(destination.protocol) || + destination.username || + destination.password + ) + throw new Error("Browser navigation requires an HTTP(S) URL without embedded credentials."); + } const release = await this.acquireOperation(); try { - const localFixture = actions.some( - (item) => - item.action === "goto" && /^http:\/\/(?:127\.0\.0\.1|localhost)(?::|\/)/.test(item.url), - ); - await this.start(localFixture); + await this.start(); await this.gateCaptcha(); const started = performance.now(); const resolvedActions = actions.map((item) => @@ -348,21 +354,39 @@ export class BrowsieBrowserSession { try { await this.start(); await this.gateCaptcha(); + if (this.browserbase && !this.sessionLogsDisabled) + throw new Error("Hosted credential operations require session logging to be disabled."); const started = performance.now(); const result = await this.requireStagehand().experimentalBatch( async ({ page }, values) => { - const before = new URL(await page.url()); - if (!values.allowedHosts.includes(before.hostname.toLowerCase())) - throw new Error("host mismatch"); + const checkDestination = async () => { + const destination = new URL(await page.url()); + if ( + destination.protocol !== "https:" || + destination.username || + destination.password || + !values.allowedHosts.includes(destination.hostname.toLowerCase()) + ) + throw new Error("Unsafe credential destination."); + }; + await checkDestination(); await page.locator(values.usernameTarget).fill(values.username); + await checkDestination(); await page.locator(values.passwordTarget).fill(values.password); - if (values.totpTarget && values.totp && !values.totpAfterSubmit) + if (values.totpTarget && values.totp && !values.totpAfterSubmit) { + await checkDestination(); await page.locator(values.totpTarget).fill(values.totp); + } + await checkDestination(); await page.locator(values.submitTarget).click(); await page.waitForTimeout(1_000); if (values.totpTarget && values.totp && values.totpAfterSubmit) { + await checkDestination(); await page.locator(values.totpTarget).fill(values.totp); - if (values.otpSubmitTarget) await page.locator(values.otpSubmitTarget).click(); + if (values.otpSubmitTarget) { + await checkDestination(); + await page.locator(values.otpSubmitTarget).click(); + } await page.waitForTimeout(1_000); } const url = await page.url(), @@ -840,8 +864,8 @@ function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function browserProvider(forceLocal = false): "local" | "browserbase" { - if (forceLocal || process.env.STAGEHAND_BROWSER === "local") return "local"; +export function browserProvider(): "local" | "browserbase" { + if (process.env.STAGEHAND_BROWSER === "local") return "local"; if (process.env.STAGEHAND_BROWSER === "browserbase") return "browserbase"; return process.env.BROWSERBASE_API_KEY ? "browserbase" : "local"; } diff --git a/projects/browsie/tests/browser-security.test.ts b/projects/browsie/tests/browser-security.test.ts new file mode 100644 index 00000000..e58535bf --- /dev/null +++ b/projects/browsie/tests/browser-security.test.ts @@ -0,0 +1,150 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { BrowsieBrowserSession } from "../server/browser-session"; +import type { ConversationState } from "../server/types"; + +const login = { + allowedHosts: ["shop.example"], + username: "synthetic-user", + password: "synthetic-password", + totp: "synthetic-otp", + usernameTarget: "#username", + passwordTarget: "#password", + totpTarget: "#otp", + submitTarget: "#submit", + otpSubmitTarget: "#otp-submit", + successTarget: "#success", +}; + +function fixture() { + const state: ConversationState = { + id: "browser-security-test", + traces: [], + browser: { provider: "not-started", status: "idle" }, + }; + const session = new BrowsieBrowserSession(state); + let url = "https://shop.example/login"; + const fill = vi.fn<(target: string, value: string) => Promise>().mockResolvedValue(); + const click = vi.fn<(target: string) => Promise>().mockResolvedValue(); + const page = { + url: async () => url, + title: async () => "Test shop", + goto: vi.fn(async (next: string) => { url = next; }), + waitForLoadState: vi.fn(async () => {}), + waitForTimeout: vi.fn(async () => {}), + locator: (target: string) => ({ + fill: (value: string) => fill(target, value), + click: () => click(target), + count: async () => 1, + }), + }; + const batch = vi.fn(async ( + callback: (context: { page: typeof page }, args: A) => Promise, + args: A, + ) => callback({ page }, args)); + const start = vi.spyOn(session, "start").mockResolvedValue(); + vi.spyOn(session as unknown as { gateCaptcha(): Promise }, "gateCaptcha") + .mockResolvedValue(); + Object.assign(session, { + stagehand: { experimentalBatch: batch }, + browserbase: {}, + sessionLogsDisabled: true, + }); + return { session, state, start, batch, page, fill, click, setUrl: (next: string) => { url = next; } }; +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe("vault login credential boundary", () => { + it.each([ + "http://shop.example/login", + "https://other.example/login", + "https://user:password@shop.example/login", + ])("does not fill credentials at an unsafe destination: %s", async (url) => { + const { session, setUrl, fill } = fixture(); + setUrl(url); + await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed"); + expect(fill).not.toHaveBeenCalled(); + }); + + it("refuses hosted login when session logging is enabled", async () => { + const { session, batch, fill } = fixture(); + Object.assign(session, { sessionLogsDisabled: false }); + await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed"); + expect(batch).not.toHaveBeenCalled(); + expect(fill).not.toHaveBeenCalled(); + }); + + it("rechecks the destination before filling the password", async () => { + const { session, setUrl, fill } = fixture(); + fill.mockImplementationOnce(async () => { setUrl("https://other.example/login"); }); + await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed"); + expect(fill.mock.calls).toEqual([[login.usernameTarget, login.username]]); + }); + + it.each([false, true])("rechecks the destination before OTP, afterSubmit=%s", async (afterSubmit) => { + const { session, setUrl, fill, click } = fixture(); + if (afterSubmit) { + click.mockImplementationOnce(async () => { setUrl("https://other.example/otp"); }); + } else { + fill.mockImplementation(async (target) => { + if (target === login.passwordTarget) setUrl("http://shop.example/otp"); + }); + } + await expect(session.secureLogin({ ...login, totpAfterSubmit: afterSubmit })) + .rejects.toThrow("Secure login failed"); + expect(fill.mock.calls.some(([, value]) => value === login.totp)).toBe(false); + }); + + it("allows HTTPS login without exposing secrets in its result or trace", async () => { + const { session, state, fill } = fixture(); + const result = await session.secureLogin(login); + expect(result.status).toBe("complete"); + expect(fill).toHaveBeenCalledTimes(3); + for (const value of [login.username, login.password, login.totp]) { + expect(JSON.stringify({ state, result })).not.toContain(value); + } + }); + + it("redacts failures and releases the operation lock for the next attempt", async () => { + const { session, state, fill } = fixture(); + fill.mockRejectedValueOnce(new Error(login.password)); + await expect(session.secureLogin(login)).rejects.toThrow("Secure login failed"); + expect(JSON.stringify(state)).not.toContain(login.password); + await expect(session.secureLogin(login)).resolves.toMatchObject({ status: "complete" }); + }); +}); + +describe("browser navigation boundary", () => { + it.each([ + "file:///tmp/bell-synthetic-fixture", + "javascript:alert(1)", + "data:text/html,example", + "https://user:password@shop.example/", + ])("rejects unsafe navigation before starting a browser: %s", async (url) => { + const { session, start, batch } = fixture(); + await expect(session.run([{ action: "goto", url }])).rejects.toThrow(); + expect(start).not.toHaveBeenCalled(); + expect(batch).not.toHaveBeenCalled(); + }); + + it("validates the whole batch before executing an earlier action", async () => { + const { session, batch } = fixture(); + await expect(session.run([ + { action: "goto", url: "https://shop.example/" }, + { action: "goto", url: "file:///tmp/bell-synthetic-fixture" }, + ])).rejects.toThrow(); + expect(batch).not.toHaveBeenCalled(); + }); + + it("does not let a loopback URL override the configured browser provider", async () => { + vi.stubEnv("STAGEHAND_BROWSER", "browserbase"); + const { session, start, page } = fixture(); + await session.run([{ action: "goto", url: "http://127.0.0.1:4318/" }]); + expect(start).toHaveBeenCalledWith(); + expect(page.goto).toHaveBeenCalledWith("http://127.0.0.1:4318/"); + }); +});