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
7 changes: 4 additions & 3 deletions src/components/settings/connection-settings-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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"
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -216,7 +217,7 @@ class UpstreamProxyConfig extends React.Component<{ rulesStore: RulesStore }> {
<SettingsButton
disabled={
!isValidProxyHost(proxyHostInput) ||
(proxyHostInput === savedProxyHost && proxyType === savedProxyType)
(normalizeProxyHost(proxyHostInput) === savedProxyHost && proxyType === savedProxyType)
}
onClick={saveProxyHost}
>
Expand Down
59 changes: 59 additions & 0 deletions src/model/http/proxy.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
};
174 changes: 174 additions & 0 deletions test/unit/model/http/proxy.spec.ts
Original file line number Diff line number Diff line change
@@ -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"
);
});
});
});