diff --git a/apps/yaak-client/components/core/Link.tsx b/apps/yaak-client/components/core/Link.tsx index f421b063..297e205d 100644 --- a/apps/yaak-client/components/core/Link.tsx +++ b/apps/yaak-client/components/core/Link.tsx @@ -1,4 +1,5 @@ import { Link as RouterLink } from "@tanstack/react-router"; +import { platform } from "@yaakapp-internal/platform"; import classNames from "classnames"; import type { HTMLAttributes } from "react"; import { appInfo } from "../../lib/appInfo"; @@ -33,7 +34,10 @@ export function Link({ href, children, noUnderline, className, ...other }: Props href={finalHref} target="_blank" rel={isYaakLink ? undefined : "noopener noreferrer"} - onClick={(e) => e.preventDefault()} + onClick={(e) => { + e.preventDefault(); + void platform.openUrl(finalHref); + }} className={className} {...other} > diff --git a/apps/yaak-client/hooks/useSendAnyHttpRequest.test.ts b/apps/yaak-client/hooks/useSendAnyHttpRequest.test.ts new file mode 100644 index 00000000..fac2d234 --- /dev/null +++ b/apps/yaak-client/hooks/useSendAnyHttpRequest.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + platform: { httpProxyUrl: "https://web.yaak.test" as string | null }, + showConfirm: vi.fn<() => Promise>(), + flushAllModelWrites: vi.fn(), + rpc: vi.fn(), +})); + +vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform })); +vi.mock("@yaakapp-internal/models", () => ({ flushAllModelWrites: mocks.flushAllModelWrites })); +vi.mock("../lib/confirm", () => ({ showConfirm: mocks.showConfirm })); +vi.mock("../lib/appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } })); +vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc })); +vi.mock("../lib/toast", () => ({ showToast: vi.fn() })); +vi.mock("./useActiveCookieJar", () => ({ getActiveCookieJar: () => ({ id: "cj_test" }) })); +vi.mock("./useActiveEnvironment", () => ({ getActiveEnvironment: () => ({ id: "ev_test" }) })); + +async function sender() { + return (await import("./useSendAnyHttpRequest")).sendAnyHttpRequest; +} + +describe("web proxy confirmation before sending", () => { + let stored: Map; + + beforeEach(() => { + vi.resetModules(); + vi.resetAllMocks(); + mocks.platform.httpProxyUrl = "https://web.yaak.test"; + mocks.rpc.mockResolvedValue({ id: "rs_test" }); + stored = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value), + }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + test("blocks all pending sends until one shared confirmation is accepted", async () => { + let decide!: (accepted: boolean) => void; + mocks.showConfirm.mockReturnValue( + new Promise((resolve) => { + decide = resolve; + }), + ); + const send = await sender(); + const first = send.mutateAsync("rq_one"); + const second = send.mutateAsync("rq_two"); + + expect(mocks.showConfirm).toHaveBeenCalledTimes(1); + expect(mocks.flushAllModelWrites).not.toHaveBeenCalled(); + expect(mocks.rpc).not.toHaveBeenCalled(); + decide(true); + await Promise.all([first, second]); + + expect(mocks.rpc).toHaveBeenCalledTimes(2); + expect(mocks.rpc).toHaveBeenCalledWith("cmd_send_http_request", { + requestId: "rq_one", + environmentId: "ev_test", + cookieJarId: "cj_test", + }); + expect(stored.size).toBe(1); + }); + + test("cancel sends nothing, remembers nothing, and asks again next time", async () => { + mocks.showConfirm.mockResolvedValue(false); + const send = await sender(); + expect(await send.mutateAsync("rq_one")).toBeNull(); + expect(await send.mutateAsync("rq_one")).toBeNull(); + expect(mocks.showConfirm).toHaveBeenCalledTimes(2); + expect(mocks.flushAllModelWrites).not.toHaveBeenCalled(); + expect(mocks.rpc).not.toHaveBeenCalled(); + expect(stored.size).toBe(0); + }); + + test("cancel applies to every send waiting on the dialog", async () => { + mocks.showConfirm.mockResolvedValue(false); + const send = await sender(); + expect(await Promise.all([send.mutateAsync("rq_one"), send.mutateAsync("rq_two")])).toEqual([ + null, + null, + ]); + expect(mocks.showConfirm).toHaveBeenCalledTimes(1); + expect(mocks.rpc).not.toHaveBeenCalled(); + }); + + test("remembers acceptance across module reloads for the same proxy", async () => { + mocks.showConfirm.mockResolvedValue(true); + await (await sender()).mutateAsync("rq_one"); + vi.resetModules(); + await (await sender()).mutateAsync("rq_two"); + expect(mocks.showConfirm).toHaveBeenCalledTimes(1); + expect(mocks.rpc).toHaveBeenCalledTimes(2); + }); + + test("requires a new decision when the proxy changes", async () => { + mocks.showConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false); + const send = await sender(); + await send.mutateAsync("rq_one"); + mocks.platform.httpProxyUrl = "https://another-proxy.test"; + expect(await send.mutateAsync("rq_two")).toBeNull(); + expect(mocks.showConfirm).toHaveBeenCalledTimes(2); + expect(mocks.rpc).toHaveBeenCalledTimes(1); + }); + + test("still asks and remembers in this tab if browser storage is unavailable", async () => { + const unavailable = () => { + throw new Error("Storage unavailable"); + }; + vi.stubGlobal("localStorage", { getItem: unavailable, setItem: unavailable }); + mocks.showConfirm.mockResolvedValue(true); + const send = await sender(); + await send.mutateAsync("rq_one"); + await send.mutateAsync("rq_two"); + expect(mocks.showConfirm).toHaveBeenCalledTimes(1); + expect(mocks.rpc).toHaveBeenCalledTimes(2); + }); + + test("desktop sends directly without confirmation", async () => { + mocks.platform.httpProxyUrl = null; + await (await sender()).mutateAsync("rq_one"); + expect(mocks.showConfirm).not.toHaveBeenCalled(); + expect(mocks.rpc).toHaveBeenCalledTimes(1); + expect(stored.size).toBe(0); + }); + + test("an empty selection does not prompt or send", async () => { + expect(await (await sender()).mutateAsync(null)).toBeNull(); + expect(mocks.showConfirm).not.toHaveBeenCalled(); + expect(mocks.rpc).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/yaak-client/hooks/useSendAnyHttpRequest.ts b/apps/yaak-client/hooks/useSendAnyHttpRequest.ts index c2d4d497..f980276d 100644 --- a/apps/yaak-client/hooks/useSendAnyHttpRequest.ts +++ b/apps/yaak-client/hooks/useSendAnyHttpRequest.ts @@ -1,5 +1,6 @@ import type { HttpResponse } from "@yaakapp-internal/models"; import { flushAllModelWrites } from "@yaakapp-internal/models"; +import { confirmWebProxy } from "../lib/confirmWebProxy"; import { rpc } from "../lib/rpc"; import { getActiveCookieJar } from "./useActiveCookieJar"; import { getActiveEnvironment } from "./useActiveEnvironment"; @@ -10,6 +11,10 @@ async function sendAnyHttpRequestById(id: string | null): Promise ({ + platform: { httpProxyUrl: "https://web.yaak.app" }, + showDialog: vi.fn<(dialog: DialogInstance) => void>(), +})); + +vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform })); +vi.mock("./appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } })); +vi.mock("./dialog", () => ({ showDialog: mocks.showDialog })); +vi.mock("../hooks/useHotKey", () => ({ + useHotKey: vi.fn(), + useFormattedHotkey: () => null, +})); + +describe("proxy consent disclosure", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubGlobal("localStorage", { getItem: () => null }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + test.each([ + ["Yaak-hosted", "https://web.yaak.app"], + ["self-hosted", "https://yaak.example.com"], + ["local", "http://localhost:8080"], + ["split deployment", "https://send.example.com/relay"], + ])("identifies the %s proxy and requires an explicit decision", async (_, proxyUrl) => { + mocks.platform.httpProxyUrl = proxyUrl; + const { confirmWebProxy } = await import("./confirmWebProxy"); + void confirmWebProxy(); + + const dialog = mocks.showDialog.mock.calls[0]?.[0]; + expect(dialog).toMatchObject({ + id: "web-proxy-consent", + title: "Requests in Yaak Web use a proxy", + size: "sm", + disableClose: true, + }); + if (dialog == null) throw new Error("Expected the proxy consent dialog"); + + const disclosure = renderToStaticMarkup(<>{dialog.description}); + expect(disclosure).toContain(`>${proxyUrl}`); + expect(disclosure).toMatch(/request and response data/i); + expect(disclosure).toContain("credentials"); + expect(disclosure).toContain("server shown below"); + expect(disclosure).not.toContain("Yaak’s servers"); + expect(disclosure).toContain( + 'href="https://yaak.app/docs/getting-started/web-proxy?ref=app.yaak.web"', + ); + expect(disclosure).toContain('target="_blank"'); + expect(disclosure).toContain("how the proxy works"); + + const Actions = dialog.render; + const actions = renderToStaticMarkup(); + expect(actions).toMatch(/]*type="submit"/); + expect(actions).toContain(">Send via Proxy<"); + expect(actions).toContain(">Cancel<"); + }); +}); diff --git a/apps/yaak-client/lib/confirmWebProxy.tsx b/apps/yaak-client/lib/confirmWebProxy.tsx new file mode 100644 index 00000000..796e8fac --- /dev/null +++ b/apps/yaak-client/lib/confirmWebProxy.tsx @@ -0,0 +1,60 @@ +import { platform } from "@yaakapp-internal/platform"; +import { InlineCode } from "@yaakapp-internal/ui"; +import { Link } from "../components/core/Link"; +import { showConfirm } from "./confirm"; + +const acceptedProxies = new Set(); +const pendingConfirmations = new Map>(); + +/** One decision for all pending sends; only acceptance is remembered. */ +export function confirmWebProxy(): Promise { + const proxyUrl = platform.httpProxyUrl; + if (proxyUrl == null) return Promise.resolve(true); + + const key = `yaak.webProxyConsent.v1:${proxyUrl}`; + try { + if (localStorage.getItem(key) === "accepted") return Promise.resolve(true); + } catch { + // Storage can be unavailable in private or restricted browsing. + } + if (acceptedProxies.has(proxyUrl)) return Promise.resolve(true); + + const pending = pendingConfirmations.get(proxyUrl); + if (pending != null) return pending; + + const confirmation = showConfirm({ + id: "web-proxy-consent", + title: "Requests in Yaak Web use a proxy", + confirmText: "Send via Proxy", + description: ( +
+

+ Yaak Web sends requests through a hosted proxy. Request and response data, including + credentials, pass through the server shown below. +

+

+ {proxyUrl} +

+

+ Learn more about{" "} + how the proxy works. +

+
+ ), + }) + .then((accepted) => { + if (accepted) { + acceptedProxies.add(proxyUrl); + try { + localStorage.setItem(key, "accepted"); + } catch { + // Keep acceptance for this tab if it cannot be persisted. + } + } + return accepted; + }) + .finally(() => pendingConfirmations.delete(proxyUrl)); + + pendingConfirmations.set(proxyUrl, confirmation); + return confirmation; +} diff --git a/packages/platform/src/registry.ts b/packages/platform/src/registry.ts index 191e572f..30ee742a 100644 --- a/packages/platform/src/registry.ts +++ b/packages/platform/src/registry.ts @@ -34,6 +34,9 @@ function host(): Platform { * and cannot be made to wait. */ export const platform: Platform = { + get httpProxyUrl() { + return host().httpProxyUrl; + }, get capabilities() { return host().capabilities; }, diff --git a/packages/platform/src/tauri/index.ts b/packages/platform/src/tauri/index.ts index 9a1cf2f9..b2483838 100644 --- a/packages/platform/src/tauri/index.ts +++ b/packages/platform/src/tauri/index.ts @@ -124,6 +124,7 @@ export function createTauriPlatform(): Platform { return { capabilities: ALL_CAPABILITIES, + httpProxyUrl: null, window, clipboard: { diff --git a/packages/platform/src/types.ts b/packages/platform/src/types.ts index a064f7c9..4cf3da1a 100644 --- a/packages/platform/src/types.ts +++ b/packages/platform/src/types.ts @@ -186,6 +186,8 @@ export interface PlatformBlobs { export interface Platform { readonly capabilities: PlatformCapabilities; + /** Web request relay, or null when this host executes requests itself. */ + readonly httpProxyUrl: string | null; readonly window: PlatformWindow; readonly clipboard: PlatformClipboard; readonly dialog: PlatformDialog; diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts index 4835afaa..eb661ca2 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -28,6 +28,7 @@ import type { import { commandSupport, runCommand } from "./commands"; import { WorkerConnection } from "./connection"; import { unsupported } from "./errors"; +import { serverBaseUrl } from "./server"; import { requestPersistence } from "./storage"; /** What this host can do, reported honestly. */ @@ -177,6 +178,7 @@ export function createWebPlatform(): Platform { return { capabilities, + httpProxyUrl: serverBaseUrl() || window.location.origin, window: createWindow(db), clipboard: {