Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,43 @@
"items": {
"type": "string"
}
},
"auth": {
"description": "Credentials presented to the upstream proxy. Preferred over embedding credentials in `url`.",
"oneOf": [
{
"type": "object",
"name": "Basic Auth",
"description": "HTTP Basic — sends base64(username:password) in the Proxy-Authorization header on every CONNECT.",
"properties": {
"type": { "type": "string", "const": "basic" },
"username": { "type": "string" },
"password": { "type": "string" }
},
"required": ["type", "username", "password"],
"additionalProperties": false
},
{
"type": "object",
"name": "NTLM Auth",
"description": "Windows NTLM — multi-round CONNECT handshake (Type1/Type2/Type3) on the same TCP connection. Use when the proxy advertises `Proxy-Authenticate: NTLM`.",
"properties": {
"type": { "type": "string", "const": "ntlm" },
"username": { "type": "string" },
"password": { "type": "string" },
"domain": {
"type": "string",
"description": "NTLM domain / target. Optional; defaults to empty (proxy decides)."
},
"workstation": {
"type": "string",
"description": "Workstation name reported in the Type1/Type3 messages. Optional; defaults to the host's name."
}
},
"required": ["type", "username", "password"],
"additionalProperties": false
}
]
}
},
"additionalProperties": false
Expand Down
56 changes: 56 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@
"express-session": "^1.19.0",
"font-awesome": "^4.7.0",
"history": "5.3.0",
"httpntlm": "^1.8.13",
"https-proxy-agent": "^7.0.6",
"isomorphic-git": "^1.36.3",
"jsonwebtoken": "^9.0.3",
Expand Down
47 changes: 47 additions & 0 deletions src/config/generated/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,11 @@ export interface RouteAuthRule {
* Configuration for routing outbound requests to upstream Git hosts via an HTTP(S) proxy.
*/
export interface UpstreamProxy {
/**
* Credentials presented to the upstream proxy. Preferred over embedding credentials in
* `url`.
*/
auth?: Auth;
/**
* Whether to use an outbound HTTP(S) proxy for upstream Git hosts.
*/
Expand All @@ -585,6 +590,36 @@ export interface UpstreamProxy {
url?: string;
}

/**
* Credentials presented to the upstream proxy. Preferred over embedding credentials in
* `url`.
*
* HTTP Basic — sends base64(username:password) in the Proxy-Authorization header on every
* CONNECT.
*
* Windows NTLM — multi-round CONNECT handshake (Type1/Type2/Type3) on the same TCP
* connection. Use when the proxy advertises `Proxy-Authenticate: NTLM`.
*/
export interface Auth {
password: string;
type: AuthType;
username: string;
/**
* NTLM domain / target. Optional; defaults to empty (proxy decides).
*/
domain?: string;
/**
* Workstation name reported in the Type1/Type3 messages. Optional; defaults to the host's
* name.
*/
workstation?: string;
}

export enum AuthType {
Basic = 'basic',
NTLM = 'ntlm',
}

// Converts JSON strings to/from your types
// and asserts the results of JSON.parse at runtime
export class Convert {
Expand Down Expand Up @@ -1006,12 +1041,24 @@ const typeMap: any = {
),
UpstreamProxy: o(
[
{ json: 'auth', js: 'auth', typ: u(undefined, r('Auth')) },
{ json: 'enabled', js: 'enabled', typ: u(undefined, true) },
{ json: 'noProxy', js: 'noProxy', typ: u(undefined, a('')) },
{ json: 'url', js: 'url', typ: u(undefined, '') },
],
false,
),
Auth: o(
[
{ json: 'password', js: 'password', typ: '' },
{ json: 'type', js: 'type', typ: r('AuthType') },
{ json: 'username', js: 'username', typ: '' },
{ json: 'domain', js: 'domain', typ: u(undefined, '') },
{ json: 'workstation', js: 'workstation', typ: u(undefined, '') },
],
false,
),
AuthenticationElementType: ['ActiveDirectory', 'jwt', 'local', 'openidconnect'],
DatabaseType: ['fs', 'mongo'],
AuthType: ['basic', 'ntlm'],
};
70 changes: 62 additions & 8 deletions src/proxy/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ import { getAllProxiedHosts } from '../../db';
import { ProxyOptions } from 'express-http-proxy';
import { handleErrorAndLog } from '../../utils/errors';
import { getUpstreamProxyConfig } from '../../config';
import { Auth, AuthType } from '../../config/generated/config';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { OutgoingHttpHeaders, RequestOptions } from 'http';
import { NtlmProxyAgent } from '../upstream/ntlm-proxy-agent';
import http, { OutgoingHttpHeaders, RequestOptions } from 'http';

enum ActionType {
ALLOWED = 'Allowed',
Expand Down Expand Up @@ -197,13 +199,34 @@ const hostMatchesNoProxy = (host: string | null | undefined, noProxyList: string
});
};

// Build a Proxy-Authorization header value from structured auth config.
// Returns undefined when no header should be injected.
const buildProxyAuthHeader = (auth: Auth | undefined): string | undefined => {
if (!auth) return undefined;
if (auth.type === 'basic') {
const encoded = Buffer.from(`${auth.username}:${auth.password}`).toString('base64');
return `Basic ${encoded}`;
}
return undefined;
};

// Stable fingerprint for the auth config used as part of the agent cache key.
const fingerprintAuth = (auth: Auth | undefined): string => {
if (!auth) return '';
return JSON.stringify(auth);
};

// 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<string> } | null = null;

const getOrCreateProxyAgent = (proxyUrl: string): HttpsProxyAgent<string> => {
if (!_cachedProxyAgent || _cachedProxyAgent.proxyUrl !== proxyUrl) {
let _cachedProxyAgent: {
cacheKey: string;
agent: http.Agent;
} | null = null;

const getOrCreateProxyAgent = (proxyUrl: string, auth?: Auth): http.Agent => {
const cacheKey = `${proxyUrl}:${fingerprintAuth(auth)}`;
if (!_cachedProxyAgent || _cachedProxyAgent.cacheKey !== cacheKey) {
let parsed: URL;
try {
parsed = new URL(proxyUrl);
Expand All @@ -222,7 +245,38 @@ const getOrCreateProxyAgent = (proxyUrl: string): HttpsProxyAgent<string> => {
`Invalid upstream proxy URL: hostname is missing — check your upstreamProxy.url config or HTTPS_PROXY env var`,
);
}
_cachedProxyAgent = { proxyUrl, agent: new HttpsProxyAgent(proxyUrl) };

if (auth?.type === AuthType.NTLM) {
_cachedProxyAgent = {
cacheKey,
agent: new NtlmProxyAgent({
proxy: proxyUrl,
username: auth.username,
password: auth.password,
domain: auth.domain,
workstation: auth.workstation,
}),
};
return _cachedProxyAgent.agent;
}

const authHeader = buildProxyAuthHeader(auth);

// When structured auth is provided, strip URL userinfo so the structured
// config wins. Otherwise https-proxy-agent would overwrite our header with
// base64(URL userinfo) on every CONNECT.
let agentUrl = proxyUrl;
if (authHeader && (parsed.username || parsed.password)) {
parsed.username = '';
parsed.password = '';
agentUrl = parsed.toString();
}

const agent = authHeader
? new HttpsProxyAgent(agentUrl, { headers: { 'Proxy-Authorization': authHeader } })
: new HttpsProxyAgent(agentUrl);

_cachedProxyAgent = { cacheKey, agent };
}
return _cachedProxyAgent.agent;
};
Expand All @@ -232,7 +286,7 @@ const buildUpstreamProxyAgent = (
headers: OutgoingHttpHeaders;
},
) => {
const { enabled, url, noProxy } = getUpstreamProxyConfig();
const { enabled, url, noProxy, auth } = getUpstreamProxyConfig();

const proxyUrl = url || getEnvProxyUrl();

Expand All @@ -249,7 +303,7 @@ const buildUpstreamProxyAgent = (
return undefined;
}

return getOrCreateProxyAgent(proxyUrl);
return getOrCreateProxyAgent(proxyUrl, auth);
};

const proxyReqOptDecorator: ProxyOptions['proxyReqOptDecorator'] = (proxyReqOpts, _srcReq) => {
Expand Down
Loading
Loading