Skip to content

Commit 77faae9

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

File tree

3 files changed

+240
-3
lines changed

3 files changed

+240
-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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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 auth)
7+
* Case 2) host:port@user:pass
8+
* Case 3) host:port:user:pass
9+
* Case 4) user:pass:host:port
10+
*
11+
* All formats with auth are normalized to format 1: user[:pass]@host[:port]
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+
/**
21+
* Regexes for matching different proxy host formats.
22+
* Groups capture different parts based on the format.
23+
*/
24+
export const PROXY_HOST_REGEXES = [
25+
new RegExp(`^(${HOST_PATTERN})(:${PORT_PATTERN})?$`),
26+
new RegExp(`^(${CRED_PATTERN})(:${CRED_PATTERN})?@(${HOST_PATTERN})(:${PORT_PATTERN})?$`),
27+
new RegExp(`^(${HOST_PATTERN}):(${PORT_PATTERN})@(${CRED_PATTERN}):(${CRED_PATTERN})$`),
28+
new RegExp(`^(${HOST_PATTERN}):(${PORT_PATTERN}):(${CRED_PATTERN}):(${CRED_PATTERN})$`),
29+
new RegExp(`^(${CRED_PATTERN}):(${CRED_PATTERN}):(${HOST_PATTERN}):(${PORT_PATTERN})$`),
30+
];
31+
32+
/**
33+
* Normalize a proxy host string if it has auth to the standard format: user[:pass]@host[:port]
34+
*
35+
* If the input has no credentials, then the host is returned as-is.
36+
*
37+
* @param host - The proxy host string in any supported format
38+
* @returns The normalized proxy host string
39+
* @throws Error if the host does not match any supported format
40+
*/
41+
export const normalizeProxyHost = (host: string): string => {
42+
const idx = PROXY_HOST_REGEXES.findIndex(regex => regex.test(host));
43+
if (idx === -1) {
44+
throw new Error(`Proxy format does not match expected patterns: ${host}`);
45+
}
46+
47+
const groups = host.match(PROXY_HOST_REGEXES[idx])!;
48+
49+
switch (idx) {
50+
case 0:
51+
case 1:
52+
return host;
53+
case 2:
54+
return `${groups[3]}:${groups[4]}@${groups[1]}:${groups[2]}`;
55+
case 3:
56+
return `${groups[3]}:${groups[4]}@${groups[1]}:${groups[2]}`;
57+
case 4:
58+
return `${groups[1]}:${groups[2]}@${groups[3]}:${groups[4]}`;
59+
default:
60+
throw new Error(`Unexpected regex index: ${idx}`);
61+
}
62+
};

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 with no auth and no port as-is", () => {
91+
const result = normalizeProxyHost("example.com");
92+
expect(result).to.equal("example.com");
93+
});
94+
95+
it("keeps host:port as-is", () => {
96+
const result = normalizeProxyHost("example.com:8080");
97+
expect(result).to.equal("example.com:8080");
98+
});
99+
100+
it("keeps user:pass@host:port as-is", () => {
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 as-is (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 as-is (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 as-is (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)