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 ee592bc12..89ae6ee6d 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", @@ -5247,6 +5248,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, @@ -8715,6 +8725,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 c10721372..f79a8f153 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,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/proxy.config.json b/proxy.config.json index 715c38f48..f97332a69 100644 --- a/proxy.config.json +++ b/proxy.config.json @@ -156,6 +156,11 @@ } } ], + "upstreamProxy": { + "enabled": false, + "url": "http://localhost:8081", + "noProxy": [] + }, "tls": { "enabled": false, "key": "certs/key.pem", 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 0d4591300..0d0691271 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -149,6 +149,23 @@ 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(); + 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 6c809eff4..ff71ac17b 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 { handleErrorAndLog } 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,104 @@ 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().replace(/^\./, ''); // strip leading dot + if (trimmed === '*') return true; // wildcard - bypass all + + 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; + }); +}; + +// 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 { enabled, url, noProxy } = getUpstreamProxyConfig(); + + const proxyUrl = url || getEnvProxyUrl(); + + if (enabled === false || !proxyUrl) { + return undefined; + } + + const host: string | null | undefined = proxyReqOpts.host || proxyReqOpts.hostname; + + const combinedNoProxy = [...(noProxy || []), ...getEnvNoProxyList()]; + + if (hostMatchesNoProxy(host, combinedNoProxy)) { + return undefined; + } + + return getOrCreateProxyAgent(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 +373,6 @@ export { isPackPost, 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'); + }); + }); +}); 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(); + }); +});