Skip to content

Commit 548e84c

Browse files
committed
fix: changes for review
1 parent b0a4b43 commit 548e84c

5 files changed

Lines changed: 215 additions & 18 deletions

File tree

proxy.config.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,11 @@
156156
}
157157
}
158158
],
159+
"upstreamProxy": {
160+
"enabled": true,
161+
"url": "http://localhost:8081",
162+
"noProxy": []
163+
},
159164
"tls": {
160165
"enabled": false,
161166
"key": "certs/key.pem",

src/config/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,17 @@ export const getProxyUrl = (): string | undefined => {
149149
return config.proxyUrl;
150150
};
151151

152+
/**
153+
* Redacts the userinfo (credentials) from a proxy URL for safe logging.
154+
* e.g. http://user:pass@proxy.corp.local:8080 → http://<redacted>@proxy.corp.local:8080
155+
*
156+
* WARNING: proxyUrl may contain plaintext credentials in the userinfo portion.
157+
* Never log a raw proxy URL — always pass it through this helper first.
158+
*/
159+
export const redactProxyUrl = (url: string): string => {
160+
return url.replace(/(https?:\/\/)[^@]+@/, '$1<redacted>@');
161+
};
162+
152163
// Get upstream proxy configuration
153164
export const getUpstreamProxyConfig = () => {
154165
const config = loadFullConfiguration();

src/proxy/routes/index.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { executeChain } from '../chain';
2222
import { processUrlPath, validGitRequest } from './helper';
2323
import { getAllProxiedHosts } from '../../db';
2424
import { ProxyOptions } from 'express-http-proxy';
25-
import { getErrorMessage, handleErrorAndLog } from '../../utils/errors';
25+
import { handleErrorAndLog } from '../../utils/errors';
2626
import { getUpstreamProxyConfig } from '../../config';
2727
import { HttpsProxyAgent } from 'https-proxy-agent';
2828
import { OutgoingHttpHeaders, RequestOptions } from 'http';
@@ -175,7 +175,10 @@ const hostMatchesNoProxy = (host: string | null | undefined, noProxyList: string
175175
if (!pattern) {
176176
return false;
177177
}
178-
const trimmed = pattern.trim();
178+
179+
const trimmed = pattern.trim().replace(/^\./, ''); // strip leading dot
180+
if (trimmed === '*') return true; // wildcard - bypass all
181+
179182
if (trimmed === '') {
180183
return false;
181184
}
@@ -194,39 +197,40 @@ const hostMatchesNoProxy = (host: string | null | undefined, noProxyList: string
194197
});
195198
};
196199

200+
// WARNING: proxyUrl may contain plaintext credentials in the userinfo portion
201+
// (e.g. http://user:pass@proxy.corp.local:8080). Never log it directly — use
202+
// redactProxyUrl() from config for any log statements involving this value.
203+
let _cachedProxyAgent: { proxyUrl: string; agent: HttpsProxyAgent<string> } | null = null;
204+
205+
const getOrCreateProxyAgent = (proxyUrl: string): HttpsProxyAgent<string> => {
206+
if (!_cachedProxyAgent || _cachedProxyAgent.proxyUrl !== proxyUrl) {
207+
_cachedProxyAgent = { proxyUrl, agent: new HttpsProxyAgent(proxyUrl) };
208+
}
209+
return _cachedProxyAgent.agent;
210+
};
211+
197212
const buildUpstreamProxyAgent = (
198213
proxyReqOpts: Omit<RequestOptions, 'headers'> & {
199214
headers: OutgoingHttpHeaders;
200215
},
201216
) => {
202-
const upstreamProxyConfig = getUpstreamProxyConfig();
203-
204-
const configuredUrl = upstreamProxyConfig.url;
205-
const envUrl = getEnvProxyUrl();
206-
207-
const proxyUrl = configuredUrl || envUrl;
217+
const { enabled, url, noProxy } = getUpstreamProxyConfig();
208218

209-
// If nothing is configured, do not use a proxy
210-
if (!proxyUrl) {
211-
return undefined;
212-
}
219+
const proxyUrl = url || getEnvProxyUrl();
213220

214-
// If config explicitly disabled the proxy, do not use it
215-
if (upstreamProxyConfig.enabled === false) {
221+
if (enabled === false || !proxyUrl) {
216222
return undefined;
217223
}
218224

219225
const host: string | null | undefined = proxyReqOpts.host || proxyReqOpts.hostname;
220226

221-
const configNoProxy = upstreamProxyConfig.noProxy ? upstreamProxyConfig.noProxy : [];
222-
const envNoProxy = getEnvNoProxyList();
223-
const combinedNoProxy = [...configNoProxy, ...envNoProxy];
227+
const combinedNoProxy = [...(noProxy || []), ...getEnvNoProxyList()];
224228

225229
if (hostMatchesNoProxy(host, combinedNoProxy)) {
226230
return undefined;
227231
}
228232

229-
return new HttpsProxyAgent(proxyUrl);
233+
return getOrCreateProxyAgent(proxyUrl);
230234
};
231235

232236
const proxyReqOptDecorator: ProxyOptions['proxyReqOptDecorator'] = (proxyReqOpts, _srcReq) => {
@@ -370,4 +374,5 @@ export {
370374
extractRawBody,
371375
validGitRequest,
372376
buildUpstreamProxyAgent,
377+
hostMatchesNoProxy,
373378
};

test/hostMatchesNoProxy.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { hostMatchesNoProxy } from '../src/proxy/routes';
19+
20+
describe('hostMatchesNoProxy', () => {
21+
describe('null / undefined / empty host', () => {
22+
it('returns false for null host', () => {
23+
expect(hostMatchesNoProxy(null, ['example.com'])).toBe(false);
24+
});
25+
26+
it('returns false for undefined host', () => {
27+
expect(hostMatchesNoProxy(undefined, ['example.com'])).toBe(false);
28+
});
29+
30+
it('returns false for empty string host', () => {
31+
expect(hostMatchesNoProxy('', ['example.com'])).toBe(false);
32+
});
33+
});
34+
35+
describe('empty noProxyList', () => {
36+
it('returns false when list is empty', () => {
37+
expect(hostMatchesNoProxy('github.com', [])).toBe(false);
38+
});
39+
});
40+
41+
describe('exact match', () => {
42+
it('matches host exactly', () => {
43+
expect(hostMatchesNoProxy('github.com', ['github.com'])).toBe(true);
44+
});
45+
46+
it('does not match a different host', () => {
47+
expect(hostMatchesNoProxy('gitlab.com', ['github.com'])).toBe(false);
48+
});
49+
50+
it('strips port before matching', () => {
51+
expect(hostMatchesNoProxy('github.com:443', ['github.com'])).toBe(true);
52+
});
53+
54+
it('does not match a subdomain as exact', () => {
55+
expect(hostMatchesNoProxy('api.github.com', ['github.com'])).toBe(true); // suffix match applies
56+
});
57+
});
58+
59+
describe('domain suffix match', () => {
60+
it('matches subdomain when pattern is the parent domain', () => {
61+
expect(hostMatchesNoProxy('api.github.com', ['github.com'])).toBe(true);
62+
});
63+
64+
it('matches deeply nested subdomain', () => {
65+
expect(hostMatchesNoProxy('foo.bar.corp.local', ['corp.local'])).toBe(true);
66+
});
67+
68+
it('does not match unrelated domain that happens to end with same string', () => {
69+
expect(hostMatchesNoProxy('notgithub.com', ['github.com'])).toBe(false);
70+
});
71+
});
72+
73+
describe('leading dot in pattern', () => {
74+
it('strips leading dot and still matches subdomain', () => {
75+
expect(hostMatchesNoProxy('api.github.com', ['.github.com'])).toBe(true);
76+
});
77+
78+
it('strips leading dot and still matches exact host', () => {
79+
expect(hostMatchesNoProxy('github.com', ['.github.com'])).toBe(true);
80+
});
81+
});
82+
83+
describe('wildcard pattern', () => {
84+
it('matches any host when pattern is *', () => {
85+
expect(hostMatchesNoProxy('anything.example.com', ['*'])).toBe(true);
86+
});
87+
88+
it('matches bare hostname when pattern is *', () => {
89+
expect(hostMatchesNoProxy('localhost', ['*'])).toBe(true);
90+
});
91+
});
92+
93+
describe('blank / whitespace patterns', () => {
94+
it('ignores empty string pattern', () => {
95+
expect(hostMatchesNoProxy('github.com', [''])).toBe(false);
96+
});
97+
98+
it('ignores whitespace-only pattern', () => {
99+
expect(hostMatchesNoProxy('github.com', [' '])).toBe(false);
100+
});
101+
});
102+
103+
describe('multiple patterns', () => {
104+
it('returns true when host matches any pattern in the list', () => {
105+
expect(hostMatchesNoProxy('github.com', ['gitlab.com', 'github.com', 'bitbucket.org'])).toBe(
106+
true,
107+
);
108+
});
109+
110+
it('returns false when host matches none of the patterns', () => {
111+
expect(hostMatchesNoProxy('github.com', ['gitlab.com', 'bitbucket.org'])).toBe(false);
112+
});
113+
});
114+
});

test/redactProxyUrl.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Copyright 2026 GitProxy Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { describe, it, expect } from 'vitest';
18+
import { redactProxyUrl } from '../src/config';
19+
20+
describe('redactProxyUrl', () => {
21+
describe('URLs with credentials', () => {
22+
it('redacts user and password from http URL', () => {
23+
expect(redactProxyUrl('http://user:pass@proxy.corp.local:8080')).toBe(
24+
'http://<redacted>@proxy.corp.local:8080',
25+
);
26+
});
27+
28+
it('redacts user and password from https URL', () => {
29+
expect(redactProxyUrl('https://user:pass@proxy.corp.local:8080')).toBe(
30+
'https://<redacted>@proxy.corp.local:8080',
31+
);
32+
});
33+
34+
it('redacts username-only (no password) from URL', () => {
35+
expect(redactProxyUrl('http://user@proxy.corp.local:8080')).toBe(
36+
'http://<redacted>@proxy.corp.local:8080',
37+
);
38+
});
39+
40+
it('redacts credentials when no port is present', () => {
41+
expect(redactProxyUrl('http://user:pass@proxy.corp.local')).toBe(
42+
'http://<redacted>@proxy.corp.local',
43+
);
44+
});
45+
46+
it('redacts credentials containing special characters', () => {
47+
expect(redactProxyUrl('http://user:p%40ssw0rd!@proxy.corp.local:3128')).toBe(
48+
'http://<redacted>@proxy.corp.local:3128',
49+
);
50+
});
51+
});
52+
53+
describe('URLs without credentials', () => {
54+
it('leaves http URL without credentials unchanged', () => {
55+
expect(redactProxyUrl('http://proxy.corp.local:8080')).toBe('http://proxy.corp.local:8080');
56+
});
57+
58+
it('leaves https URL without credentials unchanged', () => {
59+
expect(redactProxyUrl('https://proxy.corp.local:8080')).toBe('https://proxy.corp.local:8080');
60+
});
61+
});
62+
});

0 commit comments

Comments
 (0)