diff --git a/src/components/settings/connection-settings-card.tsx b/src/components/settings/connection-settings-card.tsx index b8e0de3b..dd975174 100644 --- a/src/components/settings/connection-settings-card.tsx +++ b/src/components/settings/connection-settings-card.tsx @@ -36,6 +36,7 @@ import { SettingsExplanation } from './settings-components'; import { StringSettingsList, ConfigValueRow } from './string-settings-list'; +import { PROXY_HOST_REGEXES, normalizeProxyHost } from '../../model/http/proxy'; const UpstreamProxySettings = styled.div` @@ -82,7 +83,7 @@ const validateClientCertHost = inputValidation(isValidClientCertHost, ); const isValidProxyHost = (host: string | undefined): boolean => - !!host?.match(/^([^/@]*@)?[A-Za-z0-9\-.]+(:\d+)?$/); + !!host && PROXY_HOST_REGEXES.some(regex => regex.test(host)); const validateProxyHost = inputValidation(isValidProxyHost, "Should be a plain hostname, optionally with a specific port and/or username:password" ); @@ -126,7 +127,7 @@ class UpstreamProxyConfig extends React.Component<{ rulesStore: RulesStore }> { // We update the rules store proxy type only at the point where we save the host: const rulesStore = this.props.rulesStore; rulesStore.upstreamProxyType = this.proxyType; - rulesStore.upstreamProxyHost = this.proxyHostInput; + rulesStore.upstreamProxyHost = normalizeProxyHost(this.proxyHostInput); } @action.bound @@ -216,7 +217,7 @@ class UpstreamProxyConfig extends React.Component<{ rulesStore: RulesStore }> { diff --git a/src/model/http/proxy.ts b/src/model/http/proxy.ts new file mode 100644 index 00000000..5bf24e8d --- /dev/null +++ b/src/model/http/proxy.ts @@ -0,0 +1,59 @@ +/** + * Proxy host parsing and normalization utilities. + * + * Supports multiple proxy host formats: + * Case 0) host[:port] + * Case 1) user[:pass]@host[:port] (standard format with credentials) + * Case 2) host:port@user:pass + * Case 3) host:port:user:pass + * Case 4) user:pass:host:port + * + * Cases 2-4 are normalized to case 1. + */ + +// Sub-patterns for building regexes +const HOST_PATTERN = '[A-Za-z0-9\\-.]+'; +const PORT_PATTERN = '\\d+'; +// Disallow "@", "/" and ":" in username/password +export const CRED_PATTERN = '[^@/:]+'; + +// Regexes for matching different proxy host formats. +export const PROXY_HOST_REGEXES = [ + new RegExp(`^(${HOST_PATTERN})(:${PORT_PATTERN})?$`), + new RegExp(`^(${CRED_PATTERN})(:${CRED_PATTERN})?@(${HOST_PATTERN})(:${PORT_PATTERN})?$`), + new RegExp(`^(${HOST_PATTERN}):(${PORT_PATTERN})@(${CRED_PATTERN}):(${CRED_PATTERN})$`), + new RegExp(`^(${HOST_PATTERN}):(${PORT_PATTERN}):(${CRED_PATTERN}):(${CRED_PATTERN})$`), + new RegExp(`^(${CRED_PATTERN}):(${CRED_PATTERN}):(${HOST_PATTERN}):(${PORT_PATTERN})$`), +]; + +/** + * Normalizes a proxy host string containing credentials into the standard form: user[:pass]@host[:port] + * + * If the input contains no credentials, it is returned unchanged. + * + * @param host - The proxy host value in any supported format + * @returns The normalized proxy host string + * @throws Error if the host does not match any supported format + */ +export const normalizeProxyHost = (host: string): string => { + const idx = PROXY_HOST_REGEXES.findIndex(regex => regex.test(host)); + if (idx === -1) { + throw new Error(`Proxy format does not match expected patterns: ${host}`); + } + + const groups = host.match(PROXY_HOST_REGEXES[idx])!; + + switch (idx) { + case 0: + case 1: + return host; + case 2: + return `${groups[3]}:${groups[4]}@${groups[1]}:${groups[2]}`; + case 3: + return `${groups[3]}:${groups[4]}@${groups[1]}:${groups[2]}`; + case 4: + return `${groups[1]}:${groups[2]}@${groups[3]}:${groups[4]}`; + default: + throw new Error(`Unexpected regex index: ${idx}`); + } +}; diff --git a/test/unit/model/http/proxy.spec.ts b/test/unit/model/http/proxy.spec.ts new file mode 100644 index 00000000..258d0825 --- /dev/null +++ b/test/unit/model/http/proxy.spec.ts @@ -0,0 +1,174 @@ +import { expect } from "chai"; + +import { + PROXY_HOST_REGEXES, + normalizeProxyHost, + CRED_PATTERN, +} from "../../../../src/model/http/proxy"; + +describe("Proxy host regexes and normalization", () => { + describe("CRED_PATTERN", () => { + const credRegex = new RegExp(`^${CRED_PATTERN}$`); + + it("matches valid username", () => { + expect(credRegex.test("user")).to.be.true; + }); + + it("matches username with dots, underscores, commas, plus, and minus", () => { + expect(credRegex.test("user_123,type_residential.tag+info-v1")).to.be + .true; + }); + + it("does not match if contains @", () => { + expect(credRegex.test("user@name")).to.be.false; + }); + + it("does not match if contains /", () => { + expect(credRegex.test("user/name")).to.be.false; + }); + + it("does not match if contains :", () => { + expect(credRegex.test("user:name")).to.be.false; + }); + + it("does not match empty string", () => { + expect(credRegex.test("")).to.be.false; + }); + }); + + describe("PROXY_HOST_REGEXES", () => { + it("case 0 matches host", () => { + expect(PROXY_HOST_REGEXES[0].test("example.com")).to.be.true; + }); + + it("case 0 matches host:port", () => { + expect(PROXY_HOST_REGEXES[0].test("example.com:8080")).to.be.true; + }); + + it("case 1 matches user:pass@host:port", () => { + expect(PROXY_HOST_REGEXES[1].test("user:pass@example.com:8080")).to.be + .true; + }); + + it("case 1 matches user@host:port (no password)", () => { + expect(PROXY_HOST_REGEXES[1].test("user@example.com:8080")).to.be.true; + }); + + it("case 1 matches user:pass@host (no port)", () => { + expect(PROXY_HOST_REGEXES[1].test("user:pass@example.com")).to.be.true; + }); + + it("case 1 matches user@host (no password and no port)", () => { + expect(PROXY_HOST_REGEXES[1].test("user@example.com")).to.be.true; + }); + + it("case 1 matches username with allowed special characters", () => { + expect( + PROXY_HOST_REGEXES[1].test( + "user_123,type_residential:pass@example.com:8080" + ) + ).to.be.true; + }); + + it("case 2 matches host:port@user:pass", () => { + expect(PROXY_HOST_REGEXES[2].test("example.com:8080@user:pass")).to.be + .true; + }); + + it("case 3 matches host:port:user:pass", () => { + expect(PROXY_HOST_REGEXES[3].test("example.com:8080:user:pass")).to.be + .true; + }); + + it("case 4 matches user:pass:host:port", () => { + expect(PROXY_HOST_REGEXES[4].test("user:pass:example.com:8080")).to.be + .true; + }); + }); + + describe("normalizeProxyHost", () => { + it("keeps host unchanged (no credentials and no port)", () => { + const result = normalizeProxyHost("example.com"); + expect(result).to.equal("example.com"); + }); + + it("keeps host:port unchanged", () => { + const result = normalizeProxyHost("example.com:8080"); + expect(result).to.equal("example.com:8080"); + }); + + it("keeps user:pass@host:port unchanged", () => { + const result = normalizeProxyHost("user:pass@example.com:8080"); + expect(result).to.equal("user:pass@example.com:8080"); + }); + + it("keeps user@host:port unchanged (no password)", () => { + const result = normalizeProxyHost("user@example.com:8080"); + expect(result).to.equal("user@example.com:8080"); + }); + + it("keeps user:pass@host unchanged (no port)", () => { + const result = normalizeProxyHost("user:pass@example.com"); + expect(result).to.equal("user:pass@example.com"); + }); + + it("keeps user@host unchanged (no password and no port)", () => { + const result = normalizeProxyHost("user@example.com"); + expect(result).to.equal("user@example.com"); + }); + + it("converts host:port@user:pass to standard format", () => { + const result = normalizeProxyHost("example.com:8080@user:pass"); + expect(result).to.equal("user:pass@example.com:8080"); + }); + + it("converts host:port:user:pass to standard format", () => { + const result = normalizeProxyHost("example.com:8080:user:pass"); + expect(result).to.equal("user:pass@example.com:8080"); + }); + + it("converts user:pass:host:port to standard format", () => { + const result = normalizeProxyHost("user:pass:example.com:8080"); + expect(result).to.equal("user:pass@example.com:8080"); + }); + + it("normalizes complex example", () => { + const result = normalizeProxyHost( + "proxy.domain.com:1234:user_12345,type_residential,session_123:pass" + ); + expect(result).to.equal( + "user_12345,type_residential,session_123:pass@proxy.domain.com:1234" + ); + }); + + it("throws for too many colons in user:pass:host:port format", () => { + expect(() => + normalizeProxyHost( + "user_12345,type_residential,session_123:pass:proxy.host.com:1234:extra" + ) + ).to.throw("Proxy format does not match expected patterns"); + }); + + it("throws for too many @ in host:port@user:pass format", () => { + expect(() => + normalizeProxyHost( + "user_12345,type_residential,session_123:pass@proxy.host.com@1234" + ) + ).to.throw("Proxy format does not match expected patterns"); + }); + + it("throws when host contains protocol (e.g. https://)", () => { + expect(() => + normalizeProxyHost( + "user_12345,type_residential,session_123:pass:https://proxy.host.com:1080" + ) + ).to.throw("Proxy format does not match expected patterns"); + }); + + it("throws for invalid format", () => { + expect(() => normalizeProxyHost("not a valid proxy")).to.throw( + "Proxy format does not match expected patterns" + ); + }); + }); +});