Skip to content

Commit 2a9181d

Browse files
committed
Support additional upstream proxy connection formats
1 parent 2e67811 commit 2a9181d

File tree

3 files changed

+237
-3
lines changed

3 files changed

+237
-3
lines changed

src/components/settings/connection-settings-card.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
SettingsExplanation
3737
} from './settings-components';
3838
import { StringSettingsList, ConfigValueRow } from './string-settings-list';
39+
import { PROXY_HOST_REGEXES, normalizeProxyHost } from '../../model/http/proxy';
3940

4041

4142
const UpstreamProxySettings = styled.div`
@@ -82,7 +83,7 @@ const validateClientCertHost = inputValidation(isValidClientCertHost,
8283
);
8384

8485
const isValidProxyHost = (host: string | undefined): boolean =>
85-
!!host?.match(/^([^/@]*@)?[A-Za-z0-9\-.]+(:\d+)?$/);
86+
!!host && PROXY_HOST_REGEXES.some(regex => regex.test(host));
8687
const validateProxyHost = inputValidation(isValidProxyHost,
8788
"Should be a plain hostname, optionally with a specific port and/or username:password"
8889
);
@@ -126,7 +127,7 @@ class UpstreamProxyConfig extends React.Component<{ rulesStore: RulesStore }> {
126127
// We update the rules store proxy type only at the point where we save the host:
127128
const rulesStore = this.props.rulesStore;
128129
rulesStore.upstreamProxyType = this.proxyType;
129-
rulesStore.upstreamProxyHost = this.proxyHostInput;
130+
rulesStore.upstreamProxyHost = normalizeProxyHost(this.proxyHostInput);
130131
}
131132

132133
@action.bound
@@ -216,7 +217,7 @@ class UpstreamProxyConfig extends React.Component<{ rulesStore: RulesStore }> {
216217
<SettingsButton
217218
disabled={
218219
!isValidProxyHost(proxyHostInput) ||
219-
(proxyHostInput === savedProxyHost && proxyType === savedProxyType)
220+
(normalizeProxyHost(proxyHostInput) === savedProxyHost && proxyType === savedProxyType)
220221
}
221222
onClick={saveProxyHost}
222223
>

src/model/http/proxy.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/**
2+
* Proxy host parsing and normalization utilities.
3+
*
4+
* Supports multiple proxy host formats:
5+
* Case 0) host[:port]
6+
* Case 1) user[:pass]@host[:port] (standard format with credentials)
7+
* Case 2) host:port@user:pass
8+
* Case 3) host:port:user:pass
9+
* Case 4) user:pass:host:port
10+
*
11+
* Cases 2-4 are normalized to case 1.
12+
*/
13+
14+
// Sub-patterns for building regexes
15+
const HOST_PATTERN = '[A-Za-z0-9\\-.]+';
16+
const PORT_PATTERN = '\\d+';
17+
// Disallow "@", "/" and ":" in username/password
18+
export const CRED_PATTERN = '[^@/:]+';
19+
20+
// Regexes for matching different proxy host formats.
21+
export const PROXY_HOST_REGEXES = [
22+
new RegExp(`^(${HOST_PATTERN})(:${PORT_PATTERN})?$`),
23+
new RegExp(`^(${CRED_PATTERN})(:${CRED_PATTERN})?@(${HOST_PATTERN})(:${PORT_PATTERN})?$`),
24+
new RegExp(`^(${HOST_PATTERN}):(${PORT_PATTERN})@(${CRED_PATTERN}):(${CRED_PATTERN})$`),
25+
new RegExp(`^(${HOST_PATTERN}):(${PORT_PATTERN}):(${CRED_PATTERN}):(${CRED_PATTERN})$`),
26+
new RegExp(`^(${CRED_PATTERN}):(${CRED_PATTERN}):(${HOST_PATTERN}):(${PORT_PATTERN})$`),
27+
];
28+
29+
/**
30+
* Normalizes a proxy host string containing credentials into the standard form: user[:pass]@host[:port]
31+
*
32+
* If the input contains no credentials, it is returned unchanged.
33+
*
34+
* @param host - The proxy host value in any supported format
35+
* @returns The normalized proxy host string
36+
* @throws Error if the host does not match any supported format
37+
*/
38+
export const normalizeProxyHost = (host: string): string => {
39+
const idx = PROXY_HOST_REGEXES.findIndex(regex => regex.test(host));
40+
if (idx === -1) {
41+
throw new Error(`Proxy format does not match expected patterns: ${host}`);
42+
}
43+
44+
const groups = host.match(PROXY_HOST_REGEXES[idx])!;
45+
46+
switch (idx) {
47+
case 0:
48+
case 1:
49+
return host;
50+
case 2:
51+
return `${groups[3]}:${groups[4]}@${groups[1]}:${groups[2]}`;
52+
case 3:
53+
return `${groups[3]}:${groups[4]}@${groups[1]}:${groups[2]}`;
54+
case 4:
55+
return `${groups[1]}:${groups[2]}@${groups[3]}:${groups[4]}`;
56+
default:
57+
throw new Error(`Unexpected regex index: ${idx}`);
58+
}
59+
};

test/unit/model/http/proxy.spec.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { expect } from "chai";
2+
3+
import {
4+
PROXY_HOST_REGEXES,
5+
normalizeProxyHost,
6+
CRED_PATTERN,
7+
} from "../../../../src/model/http/proxy";
8+
9+
describe("Proxy host regexes and normalization", () => {
10+
describe("CRED_PATTERN", () => {
11+
const credRegex = new RegExp(`^${CRED_PATTERN}$`);
12+
13+
it("matches valid username", () => {
14+
expect(credRegex.test("user")).to.be.true;
15+
});
16+
17+
it("matches username with dots, underscores, commas, plus, and minus", () => {
18+
expect(credRegex.test("user_123,type_residential.tag+info-v1")).to.be
19+
.true;
20+
});
21+
22+
it("does not match if contains @", () => {
23+
expect(credRegex.test("user@name")).to.be.false;
24+
});
25+
26+
it("does not match if contains /", () => {
27+
expect(credRegex.test("user/name")).to.be.false;
28+
});
29+
30+
it("does not match if contains :", () => {
31+
expect(credRegex.test("user:name")).to.be.false;
32+
});
33+
34+
it("does not match empty string", () => {
35+
expect(credRegex.test("")).to.be.false;
36+
});
37+
});
38+
39+
describe("PROXY_HOST_REGEXES", () => {
40+
it("case 0 matches host", () => {
41+
expect(PROXY_HOST_REGEXES[0].test("example.com")).to.be.true;
42+
});
43+
44+
it("case 0 matches host:port", () => {
45+
expect(PROXY_HOST_REGEXES[0].test("example.com:8080")).to.be.true;
46+
});
47+
48+
it("case 1 matches user:pass@host:port", () => {
49+
expect(PROXY_HOST_REGEXES[1].test("user:pass@example.com:8080")).to.be
50+
.true;
51+
});
52+
53+
it("case 1 matches user@host:port (no password)", () => {
54+
expect(PROXY_HOST_REGEXES[1].test("user@example.com:8080")).to.be.true;
55+
});
56+
57+
it("case 1 matches user:pass@host (no port)", () => {
58+
expect(PROXY_HOST_REGEXES[1].test("user:pass@example.com")).to.be.true;
59+
});
60+
61+
it("case 1 matches user@host (no password and no port)", () => {
62+
expect(PROXY_HOST_REGEXES[1].test("user@example.com")).to.be.true;
63+
});
64+
65+
it("case 1 matches username with allowed special characters", () => {
66+
expect(
67+
PROXY_HOST_REGEXES[1].test(
68+
"user_123,type_residential:pass@example.com:8080"
69+
)
70+
).to.be.true;
71+
});
72+
73+
it("case 2 matches host:port@user:pass", () => {
74+
expect(PROXY_HOST_REGEXES[2].test("example.com:8080@user:pass")).to.be
75+
.true;
76+
});
77+
78+
it("case 3 matches host:port:user:pass", () => {
79+
expect(PROXY_HOST_REGEXES[3].test("example.com:8080:user:pass")).to.be
80+
.true;
81+
});
82+
83+
it("case 4 matches user:pass:host:port", () => {
84+
expect(PROXY_HOST_REGEXES[4].test("user:pass:example.com:8080")).to.be
85+
.true;
86+
});
87+
});
88+
89+
describe("normalizeProxyHost", () => {
90+
it("keeps host unchanged (no credentials and no port)", () => {
91+
const result = normalizeProxyHost("example.com");
92+
expect(result).to.equal("example.com");
93+
});
94+
95+
it("keeps host:port unchanged", () => {
96+
const result = normalizeProxyHost("example.com:8080");
97+
expect(result).to.equal("example.com:8080");
98+
});
99+
100+
it("keeps user:pass@host:port unchanged", () => {
101+
const result = normalizeProxyHost("user:pass@example.com:8080");
102+
expect(result).to.equal("user:pass@example.com:8080");
103+
});
104+
105+
it("keeps user@host:port unchanged (no password)", () => {
106+
const result = normalizeProxyHost("user@example.com:8080");
107+
expect(result).to.equal("user@example.com:8080");
108+
});
109+
110+
it("keeps user:pass@host unchanged (no port)", () => {
111+
const result = normalizeProxyHost("user:pass@example.com");
112+
expect(result).to.equal("user:pass@example.com");
113+
});
114+
115+
it("keeps user@host unchanged (no password and no port)", () => {
116+
const result = normalizeProxyHost("user@example.com");
117+
expect(result).to.equal("user@example.com");
118+
});
119+
120+
it("converts host:port@user:pass to standard format", () => {
121+
const result = normalizeProxyHost("example.com:8080@user:pass");
122+
expect(result).to.equal("user:pass@example.com:8080");
123+
});
124+
125+
it("converts host:port:user:pass to standard format", () => {
126+
const result = normalizeProxyHost("example.com:8080:user:pass");
127+
expect(result).to.equal("user:pass@example.com:8080");
128+
});
129+
130+
it("converts user:pass:host:port to standard format", () => {
131+
const result = normalizeProxyHost("user:pass:example.com:8080");
132+
expect(result).to.equal("user:pass@example.com:8080");
133+
});
134+
135+
it("normalizes complex example", () => {
136+
const result = normalizeProxyHost(
137+
"proxy.domain.com:1234:user_12345,type_residential,session_123:pass"
138+
);
139+
expect(result).to.equal(
140+
"user_12345,type_residential,session_123:pass@proxy.domain.com:1234"
141+
);
142+
});
143+
144+
it("throws for too many colons in user:pass:host:port format", () => {
145+
expect(() =>
146+
normalizeProxyHost(
147+
"user_12345,type_residential,session_123:pass:proxy.host.com:1234:extra"
148+
)
149+
).to.throw("Proxy format does not match expected patterns");
150+
});
151+
152+
it("throws for too many @ in host:port@user:pass format", () => {
153+
expect(() =>
154+
normalizeProxyHost(
155+
"user_12345,type_residential,session_123:pass@proxy.host.com@1234"
156+
)
157+
).to.throw("Proxy format does not match expected patterns");
158+
});
159+
160+
it("throws when host contains protocol (e.g. https://)", () => {
161+
expect(() =>
162+
normalizeProxyHost(
163+
"user_12345,type_residential,session_123:pass:https://proxy.host.com:1080"
164+
)
165+
).to.throw("Proxy format does not match expected patterns");
166+
});
167+
168+
it("throws for invalid format", () => {
169+
expect(() => normalizeProxyHost("not a valid proxy")).to.throw(
170+
"Proxy format does not match expected patterns"
171+
);
172+
});
173+
});
174+
});

0 commit comments

Comments
 (0)