From be519740cb2011dc18787ca3785a4b6e190bfce5 Mon Sep 17 00:00:00 2001 From: Andrii Kotliar Date: Mon, 16 Mar 2026 04:08:58 +0100 Subject: [PATCH 1/6] feat: add upstream proxy configuration for outbound requests --- config.schema.json | 23 +++++++ docs/Architecture.md | 20 ++++++ package-lock.json | 23 +++++++ package.json | 1 + src/config/generated/config.ts | 31 ++++++++++ src/config/index.ts | 6 ++ src/proxy/routes/index.ts | 99 +++++++++++++++++++++++++++++- test/upstreamProxy.test.ts | 108 +++++++++++++++++++++++++++++++++ 8 files changed, 310 insertions(+), 1 deletion(-) create mode 100644 test/upstreamProxy.test.ts diff --git a/config.schema.json b/config.schema.json index c72543037..7d132a1ab 100644 --- a/config.schema.json +++ b/config.schema.json @@ -367,6 +367,29 @@ } } } + }, + "upstreamProxy": { + "description": "Configuration for routing outbound requests to upstream Git hosts via an HTTP(S) proxy.", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether to use an outbound HTTP(S) proxy for upstream Git hosts." + }, + "url": { + "type": "string", + "description": "Proxy URL used for outbound connections to upstream Git hosts when set.", + "format": "uri" + }, + "noProxy": { + "type": "array", + "description": "Additional hostnames or domain suffixes that should bypass the upstream proxy.", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false } }, "definitions": { diff --git a/docs/Architecture.md b/docs/Architecture.md index 7f49ebe62..11bd64221 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -222,6 +222,26 @@ Currently supports the following out-of-the-box: - ActiveDirectory auth configuration for querying via a REST API rather than LDAP - Gitleaks configuration +#### `upstreamProxy` + +Configures routing of outbound requests from the GitProxy server to upstream Git hosts (e.g. GitHub, GitLab) via an HTTP(S) proxy. Use this when the server runs in an environment where direct Internet access is not allowed and all traffic must go through a corporate web proxy ("proxying the proxy"). + +- **`enabled`** (boolean): When `true`, outbound connections to upstream Git hosts use the configured proxy. When `false`, the proxy is not used even if `url` or environment variables are set. +- **`url`** (string): The HTTP(S) proxy URL (e.g. `http://proxy.corp.local:8080` or `http://user:pass@proxy.corp.local:8080`). If omitted, GitProxy falls back to the `HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY` or `http_proxy` environment variables (first defined wins). +- **`noProxy`** (array of strings, optional): Hostnames or domain suffixes for which the proxy should be bypassed (e.g. internal Git hosts). Combined with the `NO_PROXY` / `no_proxy` environment variable. + +Example: + +```json +"upstreamProxy": { + "enabled": true, + "url": "http://proxy.corp.local:8080", + "noProxy": ["github.corp.local", "gitlab.corp.local"] +} +``` + +If `upstreamProxy` is not configured, setting only `HTTPS_PROXY` (or `HTTP_PROXY`) in the environment will also enable use of that proxy for outbound connections, unless `enabled` is explicitly set to `false` in config. + #### `commitConfig` Used in [`checkCommitMessages`](./Processors.md#checkcommitmessages), [`checkAuthorEmails`](./Processors.md#checkauthoremails) and [`scanDiff`](./Processors.md#scandiff) processors to block pushes depending on the given rules. diff --git a/package-lock.json b/package-lock.json index 8cb9a92e8..2d1f34fd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "express-session": "^1.19.0", "font-awesome": "^4.7.0", "history": "5.3.0", + "https-proxy-agent": "^7.0.6", "isomorphic-git": "^1.36.3", "jsonwebtoken": "^9.0.3", "load-plugin": "^6.0.3", @@ -5201,6 +5202,15 @@ "node": ">=4.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "dev": true, @@ -8655,6 +8665,19 @@ "node": ">=0.10" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "1.1.1", "dev": true, diff --git a/package.json b/package.json index b42afd368..0fa2e7370 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "express-session": "^1.19.0", "font-awesome": "^4.7.0", "history": "5.3.0", + "https-proxy-agent": "^7.0.6", "isomorphic-git": "^1.36.3", "jsonwebtoken": "^9.0.3", "load-plugin": "^6.0.3", diff --git a/src/config/generated/config.ts b/src/config/generated/config.ts index 0a85e8e70..aa0c04e93 100644 --- a/src/config/generated/config.ts +++ b/src/config/generated/config.ts @@ -101,6 +101,10 @@ export interface GitProxyConfig { * UI routes that require authentication (logged in or admin) */ uiRouteAuth?: UIRouteAuth; + /** + * Configuration for routing outbound requests to upstream Git hosts via an HTTP(S) proxy. + */ + upstreamProxy?: UpstreamProxy; /** * Customisable URL shortener to share in proxy responses and warnings */ @@ -563,6 +567,24 @@ export interface RouteAuthRule { [property: string]: any; } +/** + * Configuration for routing outbound requests to upstream Git hosts via an HTTP(S) proxy. + */ +export interface UpstreamProxy { + /** + * Whether to use an outbound HTTP(S) proxy for upstream Git hosts. + */ + enabled?: boolean; + /** + * Additional hostnames or domain suffixes that should bypass the upstream proxy. + */ + noProxy?: string[]; + /** + * Proxy URL used for outbound connections to upstream Git hosts when set. + */ + url?: string; +} + // Converts JSON strings to/from your types // and asserts the results of JSON.parse at runtime export class Convert { @@ -780,6 +802,7 @@ const typeMap: any = { { json: 'tempPassword', js: 'tempPassword', typ: u(undefined, r('TempPassword')) }, { json: 'tls', js: 'tls', typ: u(undefined, r('TLS')) }, { json: 'uiRouteAuth', js: 'uiRouteAuth', typ: u(undefined, r('UIRouteAuth')) }, + { json: 'upstreamProxy', js: 'upstreamProxy', typ: u(undefined, r('UpstreamProxy')) }, { json: 'urlShortener', js: 'urlShortener', typ: u(undefined, '') }, ], false, @@ -981,6 +1004,14 @@ const typeMap: any = { ], 'any', ), + UpstreamProxy: o( + [ + { json: 'enabled', js: 'enabled', typ: u(undefined, true) }, + { json: 'noProxy', js: 'noProxy', typ: u(undefined, a('')) }, + { json: 'url', js: 'url', typ: u(undefined, '') }, + ], + false, + ), AuthenticationElementType: ['ActiveDirectory', 'jwt', 'local', 'openidconnect'], DatabaseType: ['fs', 'mongo'], }; diff --git a/src/config/index.ts b/src/config/index.ts index e7efacd39..77b487b47 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -149,6 +149,12 @@ export const getProxyUrl = (): string | undefined => { return config.proxyUrl; }; +// Get upstream proxy configuration +export const getUpstreamProxyConfig = () => { + const config = loadFullConfiguration(); + return config.upstreamProxy || {}; +}; + // Gets a list of authorised repositories export const getAuthorisedList = () => { const config = loadFullConfiguration(); diff --git a/src/proxy/routes/index.ts b/src/proxy/routes/index.ts index f3caa7f5d..7b84fa3a7 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -23,6 +23,9 @@ import { processUrlPath, validGitRequest } from './helper'; import { getAllProxiedHosts } from '../../db'; import { ProxyOptions } from 'express-http-proxy'; import { getErrorMessage, handleAndLogError } from '../../utils/errors'; +import { getUpstreamProxyConfig } from '../../config'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import { OutgoingHttpHeaders, RequestOptions } from 'http'; enum ActionType { ALLOWED = 'Allowed', @@ -144,7 +147,100 @@ const getRequestPathResolver: (prefix: string) => ProxyOptions['proxyReqPathReso }; }; -const proxyReqOptDecorator: ProxyOptions['proxyReqOptDecorator'] = (proxyReqOpts) => proxyReqOpts; +const getEnvProxyUrl = () => + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy; + +const getEnvNoProxyList = (): string[] => { + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (!noProxy) { + return []; + } + return noProxy + .split(',') + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +}; + +const hostMatchesNoProxy = (host: string | null | undefined, noProxyList: string[]): boolean => { + if (!host) { + return false; + } + + const hostname = host.split(':')[0]; + + return noProxyList.some((pattern) => { + if (!pattern) { + return false; + } + const trimmed = pattern.trim(); + if (trimmed === '') { + return false; + } + + // Exact match + if (hostname === trimmed) { + return true; + } + + // Domain suffix match, e.g. example.com matches foo.example.com + if (hostname.endsWith(`.${trimmed}`)) { + return true; + } + + return false; + }); +}; + +const buildUpstreamProxyAgent = ( + proxyReqOpts: Omit & { + headers: OutgoingHttpHeaders; + }, +) => { + const upstreamProxyConfig = getUpstreamProxyConfig(); + + const configuredUrl = upstreamProxyConfig.url; + const envUrl = getEnvProxyUrl(); + + const proxyUrl = configuredUrl || envUrl; + + // If nothing is configured, do not use a proxy + if (!proxyUrl) { + return undefined; + } + + // If config explicitly disabled the proxy, do not use it + if (upstreamProxyConfig.enabled === false) { + return undefined; + } + + const host: string | null | undefined = proxyReqOpts.host || proxyReqOpts.hostname; + + const configNoProxy = upstreamProxyConfig.noProxy ? upstreamProxyConfig.noProxy : []; + const envNoProxy = getEnvNoProxyList(); + const combinedNoProxy = [...configNoProxy, ...envNoProxy]; + + if (hostMatchesNoProxy(host, combinedNoProxy)) { + return undefined; + } + + return new HttpsProxyAgent(proxyUrl); +}; + +const proxyReqOptDecorator: ProxyOptions['proxyReqOptDecorator'] = (proxyReqOpts, _srcReq) => { + const agent = buildUpstreamProxyAgent(proxyReqOpts); + + if (!agent) { + return proxyReqOpts; + } + + return { + ...proxyReqOpts, + agent, + }; +}; const proxyReqBodyDecorator: ProxyOptions['proxyReqBodyDecorator'] = (bodyContent, srcReq) => { if (srcReq.method === 'GET') { @@ -273,4 +369,5 @@ export { isPackPost, extractRawBody, validGitRequest, + buildUpstreamProxyAgent, }; diff --git a/test/upstreamProxy.test.ts b/test/upstreamProxy.test.ts new file mode 100644 index 000000000..78eadd6b9 --- /dev/null +++ b/test/upstreamProxy.test.ts @@ -0,0 +1,108 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +import { buildUpstreamProxyAgent } from '../src/proxy/routes'; +import * as config from '../src/config'; + +vi.mock('../src/config', async (importOriginal) => { + const actual: any = await importOriginal(); + return { + ...actual, + getUpstreamProxyConfig: vi.fn(), + }; +}); + +describe('buildUpstreamProxyAgent', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...originalEnv }; + vi.mocked(config.getUpstreamProxyConfig).mockReturnValue({}); + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('returns undefined when no proxy configuration or environment variables are set', () => { + const agent = buildUpstreamProxyAgent({ host: 'github.com', headers: {} }); + expect(agent).toBeUndefined(); + }); + + it('uses upstreamProxy.url when enabled in configuration', () => { + vi.mocked(config.getUpstreamProxyConfig).mockReturnValue({ + enabled: true, + url: 'http://proxy.example.com:8080', + }); + + const agent = buildUpstreamProxyAgent({ host: 'github.com', headers: {} }); + expect(agent).toBeDefined(); + }); + + it('prefers configuration URL over environment variables', () => { + process.env.HTTPS_PROXY = 'http://env-proxy.example.com:8080'; + vi.mocked(config.getUpstreamProxyConfig).mockReturnValue({ + enabled: true, + url: 'http://config-proxy.example.com:8080', + }); + + const agent = buildUpstreamProxyAgent({ host: 'github.com', headers: {} }); + expect(agent).toBeDefined(); + expect(agent?.proxy.href).toBe('http://config-proxy.example.com:8080/'); + }); + + it('creates an agent when only HTTPS_PROXY is set and config is empty', () => { + process.env.HTTPS_PROXY = 'http://env-proxy.example.com:8080'; + vi.mocked(config.getUpstreamProxyConfig).mockReturnValue({}); + + const agent = buildUpstreamProxyAgent({ host: 'github.com', headers: {} }); + expect(agent).toBeDefined(); + expect(agent?.proxy.href).toBe('http://env-proxy.example.com:8080/'); + }); + + it('does not create an agent when upstreamProxy.enabled is false', () => { + process.env.HTTPS_PROXY = 'http://env-proxy.example.com:8080'; + vi.mocked(config.getUpstreamProxyConfig).mockReturnValue({ + enabled: false, + url: 'http://config-proxy.example.com:8080', + }); + + const agent = buildUpstreamProxyAgent({ host: 'github.com', headers: {} }); + expect(agent).toBeUndefined(); + }); + + it('bypasses proxy when host matches noProxy in configuration', () => { + vi.mocked(config.getUpstreamProxyConfig).mockReturnValue({ + enabled: true, + url: 'http://config-proxy.example.com:8080', + noProxy: ['github.com'], + }); + + const agent = buildUpstreamProxyAgent({ host: 'github.com', headers: {} }); + expect(agent).toBeUndefined(); + }); + + it('bypasses proxy when host matches NO_PROXY environment variable', () => { + process.env.HTTPS_PROXY = 'http://env-proxy.example.com:8080'; + process.env.NO_PROXY = 'github.com'; + + const agent = buildUpstreamProxyAgent({ host: 'github.com', headers: {} }); + expect(agent).toBeUndefined(); + }); +}); From 548e84cff6e0aabb1edfe78cbb3cb0d15e101921 Mon Sep 17 00:00:00 2001 From: Andrii Kotliar Date: Wed, 25 Mar 2026 21:45:21 +0100 Subject: [PATCH 2/6] fix: changes for review --- proxy.config.json | 5 ++ src/config/index.ts | 11 +++ src/proxy/routes/index.ts | 41 +++++++----- test/hostMatchesNoProxy.test.ts | 114 ++++++++++++++++++++++++++++++++ test/redactProxyUrl.test.ts | 62 +++++++++++++++++ 5 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 test/hostMatchesNoProxy.test.ts create mode 100644 test/redactProxyUrl.test.ts diff --git a/proxy.config.json b/proxy.config.json index 715c38f48..26ab732a8 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -156,6 +156,11 @@ } } ], + "upstreamProxy": { + "enabled": true, + "url": "http://localhost:8081", + "noProxy": [] + }, "tls": { "enabled": false, "key": "certs/key.pem", diff --git a/src/config/index.ts b/src/config/index.ts index 91e590930..fbb617d68 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -149,6 +149,17 @@ export const getProxyUrl = (): string | undefined => { return config.proxyUrl; }; +/** + * Redacts the userinfo (credentials) from a proxy URL for safe logging. + * e.g. http://user:pass@proxy.corp.local:8080 → http://@proxy.corp.local:8080 + * + * WARNING: proxyUrl may contain plaintext credentials in the userinfo portion. + * Never log a raw proxy URL — always pass it through this helper first. + */ +export const redactProxyUrl = (url: string): string => { + return url.replace(/(https?:\/\/)[^@]+@/, '$1@'); +}; + // Get upstream proxy configuration export const getUpstreamProxyConfig = () => { const config = loadFullConfiguration(); diff --git a/src/proxy/routes/index.ts b/src/proxy/routes/index.ts index eeb83b0a6..ff71ac17b 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -22,7 +22,7 @@ import { executeChain } from '../chain'; import { processUrlPath, validGitRequest } from './helper'; import { getAllProxiedHosts } from '../../db'; import { ProxyOptions } from 'express-http-proxy'; -import { getErrorMessage, handleErrorAndLog } from '../../utils/errors'; +import { handleErrorAndLog } from '../../utils/errors'; import { getUpstreamProxyConfig } from '../../config'; import { HttpsProxyAgent } from 'https-proxy-agent'; import { OutgoingHttpHeaders, RequestOptions } from 'http'; @@ -175,7 +175,10 @@ const hostMatchesNoProxy = (host: string | null | undefined, noProxyList: string if (!pattern) { return false; } - const trimmed = pattern.trim(); + + const trimmed = pattern.trim().replace(/^\./, ''); // strip leading dot + if (trimmed === '*') return true; // wildcard - bypass all + if (trimmed === '') { return false; } @@ -194,39 +197,40 @@ const hostMatchesNoProxy = (host: string | null | undefined, noProxyList: string }); }; +// WARNING: proxyUrl may contain plaintext credentials in the userinfo portion +// (e.g. http://user:pass@proxy.corp.local:8080). Never log it directly — use +// redactProxyUrl() from config for any log statements involving this value. +let _cachedProxyAgent: { proxyUrl: string; agent: HttpsProxyAgent } | null = null; + +const getOrCreateProxyAgent = (proxyUrl: string): HttpsProxyAgent => { + if (!_cachedProxyAgent || _cachedProxyAgent.proxyUrl !== proxyUrl) { + _cachedProxyAgent = { proxyUrl, agent: new HttpsProxyAgent(proxyUrl) }; + } + return _cachedProxyAgent.agent; +}; + const buildUpstreamProxyAgent = ( proxyReqOpts: Omit & { headers: OutgoingHttpHeaders; }, ) => { - const upstreamProxyConfig = getUpstreamProxyConfig(); - - const configuredUrl = upstreamProxyConfig.url; - const envUrl = getEnvProxyUrl(); - - const proxyUrl = configuredUrl || envUrl; + const { enabled, url, noProxy } = getUpstreamProxyConfig(); - // If nothing is configured, do not use a proxy - if (!proxyUrl) { - return undefined; - } + const proxyUrl = url || getEnvProxyUrl(); - // If config explicitly disabled the proxy, do not use it - if (upstreamProxyConfig.enabled === false) { + if (enabled === false || !proxyUrl) { return undefined; } const host: string | null | undefined = proxyReqOpts.host || proxyReqOpts.hostname; - const configNoProxy = upstreamProxyConfig.noProxy ? upstreamProxyConfig.noProxy : []; - const envNoProxy = getEnvNoProxyList(); - const combinedNoProxy = [...configNoProxy, ...envNoProxy]; + const combinedNoProxy = [...(noProxy || []), ...getEnvNoProxyList()]; if (hostMatchesNoProxy(host, combinedNoProxy)) { return undefined; } - return new HttpsProxyAgent(proxyUrl); + return getOrCreateProxyAgent(proxyUrl); }; const proxyReqOptDecorator: ProxyOptions['proxyReqOptDecorator'] = (proxyReqOpts, _srcReq) => { @@ -370,4 +374,5 @@ export { extractRawBody, validGitRequest, buildUpstreamProxyAgent, + hostMatchesNoProxy, }; diff --git a/test/hostMatchesNoProxy.test.ts b/test/hostMatchesNoProxy.test.ts new file mode 100644 index 000000000..29b0e6be6 --- /dev/null +++ b/test/hostMatchesNoProxy.test.ts @@ -0,0 +1,114 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { hostMatchesNoProxy } from '../src/proxy/routes'; + +describe('hostMatchesNoProxy', () => { + describe('null / undefined / empty host', () => { + it('returns false for null host', () => { + expect(hostMatchesNoProxy(null, ['example.com'])).toBe(false); + }); + + it('returns false for undefined host', () => { + expect(hostMatchesNoProxy(undefined, ['example.com'])).toBe(false); + }); + + it('returns false for empty string host', () => { + expect(hostMatchesNoProxy('', ['example.com'])).toBe(false); + }); + }); + + describe('empty noProxyList', () => { + it('returns false when list is empty', () => { + expect(hostMatchesNoProxy('github.com', [])).toBe(false); + }); + }); + + describe('exact match', () => { + it('matches host exactly', () => { + expect(hostMatchesNoProxy('github.com', ['github.com'])).toBe(true); + }); + + it('does not match a different host', () => { + expect(hostMatchesNoProxy('gitlab.com', ['github.com'])).toBe(false); + }); + + it('strips port before matching', () => { + expect(hostMatchesNoProxy('github.com:443', ['github.com'])).toBe(true); + }); + + it('does not match a subdomain as exact', () => { + expect(hostMatchesNoProxy('api.github.com', ['github.com'])).toBe(true); // suffix match applies + }); + }); + + describe('domain suffix match', () => { + it('matches subdomain when pattern is the parent domain', () => { + expect(hostMatchesNoProxy('api.github.com', ['github.com'])).toBe(true); + }); + + it('matches deeply nested subdomain', () => { + expect(hostMatchesNoProxy('foo.bar.corp.local', ['corp.local'])).toBe(true); + }); + + it('does not match unrelated domain that happens to end with same string', () => { + expect(hostMatchesNoProxy('notgithub.com', ['github.com'])).toBe(false); + }); + }); + + describe('leading dot in pattern', () => { + it('strips leading dot and still matches subdomain', () => { + expect(hostMatchesNoProxy('api.github.com', ['.github.com'])).toBe(true); + }); + + it('strips leading dot and still matches exact host', () => { + expect(hostMatchesNoProxy('github.com', ['.github.com'])).toBe(true); + }); + }); + + describe('wildcard pattern', () => { + it('matches any host when pattern is *', () => { + expect(hostMatchesNoProxy('anything.example.com', ['*'])).toBe(true); + }); + + it('matches bare hostname when pattern is *', () => { + expect(hostMatchesNoProxy('localhost', ['*'])).toBe(true); + }); + }); + + describe('blank / whitespace patterns', () => { + it('ignores empty string pattern', () => { + expect(hostMatchesNoProxy('github.com', [''])).toBe(false); + }); + + it('ignores whitespace-only pattern', () => { + expect(hostMatchesNoProxy('github.com', [' '])).toBe(false); + }); + }); + + describe('multiple patterns', () => { + it('returns true when host matches any pattern in the list', () => { + expect(hostMatchesNoProxy('github.com', ['gitlab.com', 'github.com', 'bitbucket.org'])).toBe( + true, + ); + }); + + it('returns false when host matches none of the patterns', () => { + expect(hostMatchesNoProxy('github.com', ['gitlab.com', 'bitbucket.org'])).toBe(false); + }); + }); +}); diff --git a/test/redactProxyUrl.test.ts b/test/redactProxyUrl.test.ts new file mode 100644 index 000000000..16ce5cdc4 --- /dev/null +++ b/test/redactProxyUrl.test.ts @@ -0,0 +1,62 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { redactProxyUrl } from '../src/config'; + +describe('redactProxyUrl', () => { + describe('URLs with credentials', () => { + it('redacts user and password from http URL', () => { + expect(redactProxyUrl('http://user:pass@proxy.corp.local:8080')).toBe( + 'http://@proxy.corp.local:8080', + ); + }); + + it('redacts user and password from https URL', () => { + expect(redactProxyUrl('https://user:pass@proxy.corp.local:8080')).toBe( + 'https://@proxy.corp.local:8080', + ); + }); + + it('redacts username-only (no password) from URL', () => { + expect(redactProxyUrl('http://user@proxy.corp.local:8080')).toBe( + 'http://@proxy.corp.local:8080', + ); + }); + + it('redacts credentials when no port is present', () => { + expect(redactProxyUrl('http://user:pass@proxy.corp.local')).toBe( + 'http://@proxy.corp.local', + ); + }); + + it('redacts credentials containing special characters', () => { + expect(redactProxyUrl('http://user:p%40ssw0rd!@proxy.corp.local:3128')).toBe( + 'http://@proxy.corp.local:3128', + ); + }); + }); + + describe('URLs without credentials', () => { + it('leaves http URL without credentials unchanged', () => { + expect(redactProxyUrl('http://proxy.corp.local:8080')).toBe('http://proxy.corp.local:8080'); + }); + + it('leaves https URL without credentials unchanged', () => { + expect(redactProxyUrl('https://proxy.corp.local:8080')).toBe('https://proxy.corp.local:8080'); + }); + }); +}); From 9336511658f4572b392eb718f04da4083b55454f Mon Sep 17 00:00:00 2001 From: Andrii Kotliar Date: Wed, 25 Mar 2026 22:01:29 +0100 Subject: [PATCH 3/6] fix: improve regular expression for redactions --- src/config/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/index.ts b/src/config/index.ts index fbb617d68..0d0691271 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -157,7 +157,7 @@ export const getProxyUrl = (): string | undefined => { * Never log a raw proxy URL — always pass it through this helper first. */ export const redactProxyUrl = (url: string): string => { - return url.replace(/(https?:\/\/)[^@]+@/, '$1@'); + return url.replace(/^(https?:\/\/)[^@]+@/, '$1@'); }; // Get upstream proxy configuration From 45624bfb31e5b3e377f5ea6745107408e2290f10 Mon Sep 17 00:00:00 2001 From: Andrew Date: Thu, 26 Mar 2026 14:06:29 +0100 Subject: [PATCH 4/6] Update proxy.config.json Co-authored-by: Thomas Cooper <57812123+coopernetes@users.noreply.github.com> Signed-off-by: Andrew --- proxy.config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/proxy.config.json b/proxy.config.json index 26ab732a8..f97332a69 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -157,7 +157,7 @@ } ], "upstreamProxy": { - "enabled": true, + "enabled": false, "url": "http://localhost:8081", "noProxy": [] }, From 7534cf33a8bd622ace8546c405376d075b3f9e4a Mon Sep 17 00:00:00 2001 From: Andrii Kotliar Date: Fri, 10 Apr 2026 15:17:29 +0200 Subject: [PATCH 5/6] feat: validate upstream proxy URL before creating proxy agent --- src/proxy/routes/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/proxy/routes/index.ts b/src/proxy/routes/index.ts index ff71ac17b..d5063aeb2 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -204,6 +204,13 @@ let _cachedProxyAgent: { proxyUrl: string; agent: HttpsProxyAgent } | nu const getOrCreateProxyAgent = (proxyUrl: string): HttpsProxyAgent => { if (!_cachedProxyAgent || _cachedProxyAgent.proxyUrl !== proxyUrl) { + try { + new URL(proxyUrl); + } catch { + throw new Error( + `Invalid upstream proxy URL: check your upstreamProxy.url config or HTTPS_PROXY env var`, + ); + } _cachedProxyAgent = { proxyUrl, agent: new HttpsProxyAgent(proxyUrl) }; } return _cachedProxyAgent.agent; From 790aa6cbae455d2a7a66421641abcfb00d9e2684 Mon Sep 17 00:00:00 2001 From: Andrii Kotliar Date: Wed, 22 Apr 2026 01:04:33 +0200 Subject: [PATCH 6/6] fix: add check for protocol and hostname and treat unset enabled as 'false' --- src/proxy/routes/index.ts | 17 ++++++++++++++-- test/upstreamProxy.test.ts | 40 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/proxy/routes/index.ts b/src/proxy/routes/index.ts index d5063aeb2..1a5014d2d 100644 --- a/src/proxy/routes/index.ts +++ b/src/proxy/routes/index.ts @@ -204,13 +204,24 @@ let _cachedProxyAgent: { proxyUrl: string; agent: HttpsProxyAgent } | nu const getOrCreateProxyAgent = (proxyUrl: string): HttpsProxyAgent => { if (!_cachedProxyAgent || _cachedProxyAgent.proxyUrl !== proxyUrl) { + let parsed: URL; try { - new URL(proxyUrl); + parsed = new URL(proxyUrl); } catch { throw new Error( `Invalid upstream proxy URL: check your upstreamProxy.url config or HTTPS_PROXY env var`, ); } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error( + `Unsupported upstream proxy URL scheme "${parsed.protocol.replace(/:$/, '')}": only http and https are supported`, + ); + } + if (!parsed.hostname) { + throw new Error( + `Invalid upstream proxy URL: hostname is missing — check your upstreamProxy.url config or HTTPS_PROXY env var`, + ); + } _cachedProxyAgent = { proxyUrl, agent: new HttpsProxyAgent(proxyUrl) }; } return _cachedProxyAgent.agent; @@ -225,7 +236,8 @@ const buildUpstreamProxyAgent = ( const proxyUrl = url || getEnvProxyUrl(); - if (enabled === false || !proxyUrl) { + // If enabled is not existant or false + if (enabled === undefined || enabled === false || !proxyUrl) { return undefined; } @@ -382,4 +394,5 @@ export { validGitRequest, buildUpstreamProxyAgent, hostMatchesNoProxy, + getOrCreateProxyAgent, }; diff --git a/test/upstreamProxy.test.ts b/test/upstreamProxy.test.ts index 78eadd6b9..c80f03f14 100644 --- a/test/upstreamProxy.test.ts +++ b/test/upstreamProxy.test.ts @@ -16,7 +16,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { buildUpstreamProxyAgent } from '../src/proxy/routes'; +import { buildUpstreamProxyAgent, getOrCreateProxyAgent } from '../src/proxy/routes'; import * as config from '../src/config'; vi.mock('../src/config', async (importOriginal) => { @@ -27,6 +27,42 @@ vi.mock('../src/config', async (importOriginal) => { }; }); +describe('getOrCreateProxyAgent', () => { + it('accepts http:// URLs', () => { + expect(() => getOrCreateProxyAgent('http://proxy.example.com:8080')).not.toThrow(); + }); + + it('accepts https:// URLs', () => { + expect(() => getOrCreateProxyAgent('https://proxy.example.com:8080')).not.toThrow(); + }); + + it('rejects socks5:// URLs with a descriptive error', () => { + expect(() => getOrCreateProxyAgent('socks5://proxy.example.com:1080')).toThrow( + /unsupported.*scheme.*socks5/i, + ); + }); + + it('rejects ftp:// URLs with a descriptive error', () => { + expect(() => getOrCreateProxyAgent('ftp://proxy.example.com:21')).toThrow( + /unsupported.*scheme.*ftp/i, + ); + }); + + it('rejects URLs without a protocol (no scheme)', () => { + expect(() => getOrCreateProxyAgent('localhost:8081')).toThrow( + /Unsupported upstream proxy URL scheme/i, + ); + }); + + it('rejects URLs with an empty hostname', () => { + expect(() => getOrCreateProxyAgent('http://:8080')).toThrow(/invalid upstream proxy url/i); + }); + + it('rejects completely invalid URL strings', () => { + expect(() => getOrCreateProxyAgent('not a url at all')).toThrow(/invalid upstream proxy url/i); + }); +}); + describe('buildUpstreamProxyAgent', () => { const originalEnv = process.env; @@ -69,7 +105,7 @@ describe('buildUpstreamProxyAgent', () => { it('creates an agent when only HTTPS_PROXY is set and config is empty', () => { process.env.HTTPS_PROXY = 'http://env-proxy.example.com:8080'; - vi.mocked(config.getUpstreamProxyConfig).mockReturnValue({}); + vi.mocked(config.getUpstreamProxyConfig).mockReturnValue({ enabled: true }); const agent = buildUpstreamProxyAgent({ host: 'github.com', headers: {} }); expect(agent).toBeDefined();