mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-15 14:21:53 +02:00
Confirm proxy use before the first web request (#657)
This commit is contained in:
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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<boolean>>(),
|
||||
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<string, string>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<HttpResponse |
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(await confirmWebProxy())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await flushAllModelWrites();
|
||||
|
||||
return rpc("cmd_send_http_request", {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import type { DialogInstance } from "../components/Dialogs";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
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}</code>`);
|
||||
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(<Actions hide={vi.fn()} />);
|
||||
expect(actions).toMatch(/<button[^>]*type="submit"/);
|
||||
expect(actions).toContain(">Send via Proxy<");
|
||||
expect(actions).toContain(">Cancel<");
|
||||
});
|
||||
});
|
||||
@@ -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<string>();
|
||||
const pendingConfirmations = new Map<string, Promise<boolean>>();
|
||||
|
||||
/** One decision for all pending sends; only acceptance is remembered. */
|
||||
export function confirmWebProxy(): Promise<boolean> {
|
||||
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: (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Yaak Web sends requests through a hosted proxy. Request and response data, including
|
||||
credentials, pass through the server shown below.
|
||||
</p>
|
||||
<p>
|
||||
<InlineCode className="break-all">{proxyUrl}</InlineCode>
|
||||
</p>
|
||||
<p>
|
||||
Learn more about{" "}
|
||||
<Link href="https://yaak.app/docs/getting-started/web-proxy">how the proxy works</Link>.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
.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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -124,6 +124,7 @@ export function createTauriPlatform(): Platform {
|
||||
|
||||
return {
|
||||
capabilities: ALL_CAPABILITIES,
|
||||
httpProxyUrl: null,
|
||||
window,
|
||||
|
||||
clipboard: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user