mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-15 22:32:04 +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 { Link as RouterLink } from "@tanstack/react-router";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { HTMLAttributes } from "react";
|
import type { HTMLAttributes } from "react";
|
||||||
import { appInfo } from "../../lib/appInfo";
|
import { appInfo } from "../../lib/appInfo";
|
||||||
@@ -33,7 +34,10 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
|
|||||||
href={finalHref}
|
href={finalHref}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel={isYaakLink ? undefined : "noopener noreferrer"}
|
rel={isYaakLink ? undefined : "noopener noreferrer"}
|
||||||
onClick={(e) => e.preventDefault()}
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void platform.openUrl(finalHref);
|
||||||
|
}}
|
||||||
className={className}
|
className={className}
|
||||||
{...other}
|
{...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 type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||||
|
import { confirmWebProxy } from "../lib/confirmWebProxy";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
import { getActiveCookieJar } from "./useActiveCookieJar";
|
import { getActiveCookieJar } from "./useActiveCookieJar";
|
||||||
import { getActiveEnvironment } from "./useActiveEnvironment";
|
import { getActiveEnvironment } from "./useActiveEnvironment";
|
||||||
@@ -10,6 +11,10 @@ async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse |
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(await confirmWebProxy())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
await flushAllModelWrites();
|
await flushAllModelWrites();
|
||||||
|
|
||||||
return rpc("cmd_send_http_request", {
|
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.
|
* and cannot be made to wait.
|
||||||
*/
|
*/
|
||||||
export const platform: Platform = {
|
export const platform: Platform = {
|
||||||
|
get httpProxyUrl() {
|
||||||
|
return host().httpProxyUrl;
|
||||||
|
},
|
||||||
get capabilities() {
|
get capabilities() {
|
||||||
return host().capabilities;
|
return host().capabilities;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export function createTauriPlatform(): Platform {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
capabilities: ALL_CAPABILITIES,
|
capabilities: ALL_CAPABILITIES,
|
||||||
|
httpProxyUrl: null,
|
||||||
window,
|
window,
|
||||||
|
|
||||||
clipboard: {
|
clipboard: {
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ export interface PlatformBlobs {
|
|||||||
|
|
||||||
export interface Platform {
|
export interface Platform {
|
||||||
readonly capabilities: PlatformCapabilities;
|
readonly capabilities: PlatformCapabilities;
|
||||||
|
/** Web request relay, or null when this host executes requests itself. */
|
||||||
|
readonly httpProxyUrl: string | null;
|
||||||
readonly window: PlatformWindow;
|
readonly window: PlatformWindow;
|
||||||
readonly clipboard: PlatformClipboard;
|
readonly clipboard: PlatformClipboard;
|
||||||
readonly dialog: PlatformDialog;
|
readonly dialog: PlatformDialog;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
import { commandSupport, runCommand } from "./commands";
|
import { commandSupport, runCommand } from "./commands";
|
||||||
import { WorkerConnection } from "./connection";
|
import { WorkerConnection } from "./connection";
|
||||||
import { unsupported } from "./errors";
|
import { unsupported } from "./errors";
|
||||||
|
import { serverBaseUrl } from "./server";
|
||||||
import { requestPersistence } from "./storage";
|
import { requestPersistence } from "./storage";
|
||||||
|
|
||||||
/** What this host can do, reported honestly. */
|
/** What this host can do, reported honestly. */
|
||||||
@@ -177,6 +178,7 @@ export function createWebPlatform(): Platform {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
capabilities,
|
capabilities,
|
||||||
|
httpProxyUrl: serverBaseUrl() || window.location.origin,
|
||||||
window: createWindow(db),
|
window: createWindow(db),
|
||||||
|
|
||||||
clipboard: {
|
clipboard: {
|
||||||
|
|||||||
Reference in New Issue
Block a user