Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions src/connection/window-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export interface WindowPolicy {
export function isNavigationAllowed(targetUrl: string, allowedOrigin: string): boolean {
try {
const parsed = new URL(targetUrl);
if (hasEmbeddedCredentials(parsed)) {
return false;
}

return parsed.origin === allowedOrigin;
} catch {
return false;
Expand All @@ -20,6 +24,10 @@ export function isNavigationAllowed(targetUrl: string, allowedOrigin: string): b
export function shouldOpenExternally(targetUrl: string, allowedOrigin: string): boolean {
try {
const parsed = new URL(targetUrl);
if (hasEmbeddedCredentials(parsed)) {
return false;
}

if (parsed.origin === allowedOrigin) {
return false;
}
Expand All @@ -30,6 +38,33 @@ export function shouldOpenExternally(targetUrl: string, allowedOrigin: string):
}
}

export type NewWindowPolicyAction = "navigate-in-app" | "open-externally" | "deny";

export function newWindowPolicyAction(targetUrl: string, allowedOrigin: string): NewWindowPolicyAction {
try {
const parsed = new URL(targetUrl);
if (hasEmbeddedCredentials(parsed)) {
return "deny";
}

if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return "deny";
}

if (parsed.origin === allowedOrigin) {
return "navigate-in-app";
}

return "open-externally";
} catch {
return "deny";
}
}

function hasEmbeddedCredentials(parsed: URL): boolean {
return parsed.username !== "" || parsed.password !== "";
}

export function remotePartitionForProfile(profileId: string): string {
return `persist:paperclip-remote-${profileId}`;
}
Expand Down
12 changes: 8 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
import {
isNavigationAllowed,
localPartition,
newWindowPolicyAction,
remotePartitionForProfile,
shouldOpenExternally,
} from "./connection/window-policy";
Expand Down Expand Up @@ -876,7 +877,7 @@ function applyWindowPolicy(win: BrowserWindow, allowedOrigin: string): void {

event.preventDefault();
if (shouldOpenExternally(targetUrl, allowedOrigin)) {
void shell.openExternal(targetUrl);
void shell.openExternal(targetUrl).catch(() => undefined);
}
});

Expand All @@ -887,13 +888,16 @@ function applyWindowPolicy(win: BrowserWindow, allowedOrigin: string): void {

event.preventDefault();
if (shouldOpenExternally(targetUrl, allowedOrigin)) {
void shell.openExternal(targetUrl);
void shell.openExternal(targetUrl).catch(() => undefined);
}
});

win.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternally(url, allowedOrigin)) {
void shell.openExternal(url);
const action = newWindowPolicyAction(url, allowedOrigin);
if (action === "navigate-in-app") {
void win.webContents.loadURL(url).catch(() => undefined);
} else if (action === "open-externally") {
void shell.openExternal(url).catch(() => undefined);
}
return { action: "deny" };
});
Expand Down
38 changes: 38 additions & 0 deletions test/window-policy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const {
isNavigationAllowed,
newWindowPolicyAction,
shouldOpenExternally,
remotePartitionForProfile,
} = require("../dist/connection/window-policy.js");
Expand All @@ -13,15 +14,52 @@ test("window policy only allows same-origin navigation", () => {
const allowedOrigin = "https://paperclip-host.tailnet.ts.net";
assert.equal(isNavigationAllowed("https://paperclip-host.tailnet.ts.net/dashboard", allowedOrigin), true);
assert.equal(isNavigationAllowed("https://example.com", allowedOrigin), false);
assert.equal(
isNavigationAllowed("https://user:pass@paperclip-host.tailnet.ts.net/dashboard", allowedOrigin),
false,
);
});

test("window policy opens external http links outside the allowed origin", () => {
const allowedOrigin = "http://localhost:3100";
assert.equal(shouldOpenExternally("https://docs.paperclip.ing", allowedOrigin), true);
assert.equal(shouldOpenExternally("http://localhost:3100/settings", allowedOrigin), false);
assert.equal(shouldOpenExternally("https://user:pass@docs.paperclip.ing", allowedOrigin), false);
assert.equal(shouldOpenExternally("mailto:test@example.com", allowedOrigin), false);
});

test("new-window policy navigates same-origin http links in the app", () => {
const allowedOrigin = "http://localhost:3100";
assert.equal(
newWindowPolicyAction(
"http://localhost:3100/agents/agent_123/runs/run_456",
allowedOrigin,
),
"navigate-in-app",
);
});

test("new-window policy opens different-origin http links externally", () => {
const allowedOrigin = "https://paperclip-host.tailnet.ts.net";
assert.equal(
newWindowPolicyAction("https://docs.paperclip.ing/reference", allowedOrigin),
"open-externally",
);
assert.equal(
newWindowPolicyAction("http://example.com", allowedOrigin),
"open-externally",
);
});

test("new-window policy denies unsupported and malformed urls", () => {
const allowedOrigin = "http://localhost:3100";
assert.equal(newWindowPolicyAction("http://user:pass@localhost:3100/settings", allowedOrigin), "deny");
assert.equal(newWindowPolicyAction("https://user:pass@example.com", allowedOrigin), "deny");
assert.equal(newWindowPolicyAction("mailto:test@example.com", allowedOrigin), "deny");
assert.equal(newWindowPolicyAction("file:///tmp/index.html", allowedOrigin), "deny");
assert.equal(newWindowPolicyAction("not a url", allowedOrigin), "deny");
});

test("remote partitions are isolated per profile", () => {
assert.equal(remotePartitionForProfile("abc123"), "persist:paperclip-remote-abc123");
});