Skip to content
Merged
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
124 changes: 124 additions & 0 deletions packages/extension/src/background/chrome-proxy-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,130 @@ describe("ChromeProxyManager", () => {
expect(route("http://172.32.0.1", "172.32.0.1")).toBe("DIRECT");
});
});

describe("split tunneling rules", () => {
const withExit = (overrides: Partial<TailscaleState> = {}) =>
baseState({
exitNode: {
id: "exit1",
hostname: "exit",
location: null,
online: true,
},
...overrides,
});

it("bypass mode: listed domain goes DIRECT, others use exit node", () => {
const route = evalPAC(
pm,
withExit({
domainSplit: { mode: "bypass", domains: ["teams.microsoft.com"] },
}),
);
expect(route("https://teams.microsoft.com/", "teams.microsoft.com")).toBe(
"DIRECT",
);
expect(
route("https://x.teams.microsoft.com/", "x.teams.microsoft.com"),
).toBe("DIRECT");
expect(route("https://example.com/", "example.com")).toBe(
"SOCKS5 127.0.0.1:1055",
);
});

it("only mode: listed domain uses exit node, others go DIRECT", () => {
const route = evalPAC(
pm,
withExit({
domainSplit: { mode: "only", domains: ["work.example.com"] },
}),
);
expect(route("https://work.example.com/", "work.example.com")).toBe(
"SOCKS5 127.0.0.1:1055",
);
expect(route("https://google.com/", "google.com")).toBe("DIRECT");
});

it("only mode: Tailscale-mandatory traffic still routes through proxy", () => {
const route = evalPAC(
pm,
withExit({
domainSplit: { mode: "only", domains: ["work.example.com"] },
}),
);
expect(route("http://100.100.100.100", "100.100.100.100")).toBe(
"SOCKS5 127.0.0.1:1055",
);
expect(
route("http://srv.example.ts.net", "srv.example.ts.net"),
).toBe("SOCKS5 127.0.0.1:1055");
});

it("only mode with empty list: catch-all is DIRECT", () => {
const route = evalPAC(
pm,
withExit({ domainSplit: { mode: "only", domains: [] } }),
);
expect(route("https://example.com/", "example.com")).toBe("DIRECT");
expect(route("https://google.com/", "google.com")).toBe("DIRECT");
// Tailscale-mandatory traffic still proxies.
expect(route("http://100.100.100.100", "100.100.100.100")).toBe(
"SOCKS5 127.0.0.1:1055",
);
expect(
route("http://srv.example.ts.net", "srv.example.ts.net"),
).toBe("SOCKS5 127.0.0.1:1055");
});

it("bypass mode with empty list: catch-all is full proxy (no rules to apply)", () => {
const route = evalPAC(
pm,
withExit({ domainSplit: { mode: "bypass", domains: [] } }),
);
expect(route("https://example.com/", "example.com")).toBe(
"SOCKS5 127.0.0.1:1055",
);
});

it("rules are inert when no exit node is active", () => {
const route = evalPAC(
pm,
baseState({
domainSplit: { mode: "bypass", domains: ["teams.microsoft.com"] },
}),
);
expect(route("https://teams.microsoft.com/", "teams.microsoft.com")).toBe(
"DIRECT",
);
expect(route("https://example.com/", "example.com")).toBe("DIRECT");
});

it("regenerates PAC when domainSplit changes", () => {
const spy = vi.spyOn(chrome.proxy.settings, "set");
pm.apply(withExit());
const callsBefore = spy.mock.calls.length;
pm.apply(
withExit({
domainSplit: { mode: "bypass", domains: ["teams.microsoft.com"] },
}),
);
expect(spy.mock.calls.length).toBeGreaterThan(callsBefore);
});

it("drops invalid domains before embedding in PAC", () => {
const pac = capturePAC(
pm,
withExit({
domainSplit: {
mode: "bypass",
domains: ['evil"); alert("xss', "ok.example.com"],
},
}),
)!;
expect(pac).not.toContain("evil");
expect(pac).toContain('"ok.example.com"');
});
});
});

function evalPAC(
Expand Down
42 changes: 39 additions & 3 deletions packages/extension/src/background/chrome-proxy-manager.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { TailscaleState } from "@tailchrome/shared/types";
import type { DomainSplitConfig, TailscaleState } from "@tailchrome/shared/types";
import { TAILSCALE_SERVICE_IP } from "@tailchrome/shared/constants";
import {
parseCIDR,
sanitizeMagicDNSSuffix,
sanitizeDomain,
collectSubnetCIDRs,
shouldProxyState,
} from "@tailchrome/shared/background/proxy-utils";
Expand All @@ -23,9 +24,11 @@ export class ChromeProxyManager {
const magicDNSSuffix = state.magicDNSSuffix;
const exitNodeActive = state.exitNode !== null;
const subnets = collectSubnetCIDRs(state.peers);
const splitDomains = sanitizeSplitDomains(state.domainSplit);
const splitMode = state.domainSplit.mode;

// Skip regeneration if proxy-relevant fields haven't changed
const proxyKey = `${port}:${magicDNSSuffix ?? ""}:${exitNodeActive}:${[...subnets].sort().join(",")}`;
const proxyKey = `${port}:${magicDNSSuffix ?? ""}:${exitNodeActive}:${[...subnets].sort().join(",")}:${splitMode}:${splitDomains.join(",")}`;
if (proxyKey === this.lastProxyKey) {
return;
}
Expand All @@ -36,6 +39,8 @@ export class ChromeProxyManager {
magicDNSSuffix,
exitNodeActive,
subnets,
splitMode,
splitDomains,
);

chrome.proxy.settings.set(
Expand Down Expand Up @@ -85,6 +90,8 @@ export class ChromeProxyManager {
magicDNSSuffix: string | null | undefined,
exitNodeActive: boolean,
subnets: string[],
splitMode: "bypass" | "only",
splitDomains: string[],
): string {
const proxy = `SOCKS5 127.0.0.1:${port}`;

Expand All @@ -99,6 +106,23 @@ export class ChromeProxyManager {

const safeDNSSuffix = sanitizeMagicDNSSuffix(magicDNSSuffix);

const domainChecks =
splitDomains
.map((d) => `(host === "${d}" || dnsDomainIs(host, ".${d}"))`)
.join(" || ") || "false";

let catchAll: string;
if (!exitNodeActive) {
catchAll = ' return "DIRECT";';
} else if (splitMode === "only") {
// Only mode: empty list means nothing should leave through the exit node.
catchAll = ` if (${domainChecks}) return proxy;\n return "DIRECT";`;
} else if (splitDomains.length > 0) {
catchAll = ` if (${domainChecks}) return "DIRECT";\n return proxy;`;
} else {
catchAll = " return proxy;";
}

return `function FindProxyForURL(url, host) {
var proxy = "${proxy}";

Expand All @@ -108,7 +132,19 @@ ${safeDNSSuffix ? ` if (dnsDomainIs(host, ".${safeDNSSuffix}") || host === "${s

${subnetChecks || " // No subnet routes"}

${exitNodeActive ? " return proxy;" : ' return "DIRECT";'}
${catchAll}
}`;
}
}

function sanitizeSplitDomains(config: DomainSplitConfig): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const raw of config.domains) {
const cleaned = sanitizeDomain(raw);
if (!cleaned || seen.has(cleaned)) continue;
seen.add(cleaned);
out.push(cleaned);
}
return out;
}
91 changes: 91 additions & 0 deletions packages/extension/src/background/firefox-proxy-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,97 @@ describe("FirefoxProxyManager", () => {
});
});

describe("split tunneling rules", () => {
const withExit = (overrides: Record<string, unknown> = {}) =>
baseState({
exitNode: {
id: "exit1",
hostname: "exit",
location: null,
online: true,
},
...overrides,
});
const resolveOf = (manager: FirefoxProxyManager) =>
(url: string) =>
(manager as unknown as { resolveProxy(url: string): { type: string } }).resolveProxy(url);

it("bypass mode: listed domain goes direct, others go through proxy", () => {
pm.apply(
withExit({
domainSplit: { mode: "bypass", domains: ["teams.microsoft.com"] },
}),
);
const resolve = resolveOf(pm);
expect(resolve("https://teams.microsoft.com/").type).toBe("direct");
expect(resolve("https://x.teams.microsoft.com/").type).toBe("direct");
expect(resolve("https://example.com/").type).toBe("socks");
});

it("only mode: listed domain goes through proxy, others go direct", () => {
pm.apply(
withExit({
domainSplit: { mode: "only", domains: ["work.example.com"] },
}),
);
const resolve = resolveOf(pm);
expect(resolve("https://work.example.com/").type).toBe("socks");
expect(resolve("https://google.com/").type).toBe("direct");
});

it("only mode with empty list: catch-all is direct", () => {
pm.apply(withExit({ domainSplit: { mode: "only", domains: [] } }));
const resolve = resolveOf(pm);
expect(resolve("https://example.com/").type).toBe("direct");
expect(resolve("https://google.com/").type).toBe("direct");
// Tailscale-mandatory traffic still proxies.
expect(resolve("http://100.100.100.100/").type).toBe("socks");
expect(resolve("http://srv.example.ts.net/").type).toBe("socks");
});

it("bypass mode with empty list: catch-all is proxy", () => {
pm.apply(withExit({ domainSplit: { mode: "bypass", domains: [] } }));
const resolve = resolveOf(pm);
expect(resolve("https://example.com/").type).toBe("socks");
});

it("only mode still routes Tailscale-mandatory traffic through proxy", () => {
pm.apply(
withExit({
domainSplit: { mode: "only", domains: ["work.example.com"] },
}),
);
const resolve = resolveOf(pm);
expect(resolve("http://100.100.100.100/").type).toBe("socks");
expect(resolve("http://srv.example.ts.net/").type).toBe("socks");
});

it("rules are inert when no exit node is active", () => {
pm.apply(
baseState({
domainSplit: { mode: "bypass", domains: ["teams.microsoft.com"] },
}),
);
const resolve = resolveOf(pm);
expect(resolve("https://teams.microsoft.com/").type).toBe("direct");
expect(resolve("https://example.com/").type).toBe("direct");
});

it("ignores invalid domain entries", () => {
pm.apply(
withExit({
domainSplit: {
mode: "bypass",
domains: ['evil"); alert("xss', "ok.example.com"],
},
}),
);
const resolve = resolveOf(pm);
expect(resolve("https://ok.example.com/").type).toBe("direct");
expect(resolve("https://other.com/").type).toBe("socks");
});
});

describe("listener wake flow", () => {
it("defers to restore and reconnect promises during wake", async () => {
pm.apply(baseState({ proxyPort: 3333 }));
Expand Down
Loading
Loading