From c514f32661200251b893bd8599b459b239662d49 Mon Sep 17 00:00:00 2001 From: tke Date: Wed, 23 Sep 2026 14:57:18 +0200 Subject: [PATCH] feat(proxy): credential-failure backoff prevents corp proxy account lockout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After 3 consecutive 407s within 120s (defaults; env-tunable), the bridge stops sending Proxy-Authorization to that upstream for 300s, then retries once with refreshed keyring creds. A successful request clears the failure state. Requests during the lockout fail fast with the upstream's 407 instead of retrying stale credentials — the exact pattern that locks accounts. Tests: 407-stub lockout engages on 4th attempt; success mid-stream resets the counter (re-lock needs a fresh 3); missing creds never send an auth header. Harness: awaitProxyResponse() decouples client-side settle from server FIN timing (previous once(end) pattern wedged under node --test). --- scripts/proxy/bridge.js | 79 +++++++++++++++++--- scripts/proxy/bridge.test.js | 135 ++++++++++++++++++++++++++++++++++- 2 files changed, 203 insertions(+), 11 deletions(-) diff --git a/scripts/proxy/bridge.js b/scripts/proxy/bridge.js index 34598f9..23889fb 100644 --- a/scripts/proxy/bridge.js +++ b/scripts/proxy/bridge.js @@ -88,6 +88,58 @@ function refreshAuth() { } refreshAuth(); +// --- Credential-failure backoff (anti-lockout) --- +// If the corp proxy keeps answering 407 to our stored credentials, retrying them +// on every request can lock the account. Track consecutive failures per upstream +// and stop sending Proxy-Authorization once the threshold is reached; re-check +// the keyring once after a backoff window before trying authenticated again. +const AUTH_LOCK_THRESHOLD = Number(process.env.PROXY_BRIDGE_AUTH_LOCK_THRESHOLD || 3); +const AUTH_LOCK_WINDOW_MS = Number(process.env.PROXY_BRIDGE_AUTH_LOCK_WINDOW_MS || 120_000); +const AUTH_LOCK_BACKOFF_MS = Number(process.env.PROXY_BRIDGE_AUTH_LOCK_BACKOFF_MS || 300_000); +const authState = new Map(); // upstreamKey -> { failures:[ts,...], lockedUntil } + +function authStateFor(key) { + let st = authState.get(key); + if (!st) { st = { failures: [], lockedUntil: 0 }; authState.set(key, st); } + return st; +} + +// Should we attach Proxy-Authorization for this upstream right now? +// May refresh creds once from the keyring when a lock window just expired. +function authHeaderForUpstream(key) { + const st = authStateFor(key); + const now = Date.now(); + if (st.lockedUntil > now) return null; + if (st.lockedUntil !== 0) { + // Backoff expired: one optimistic retry with freshly loaded credentials. + st.lockedUntil = 0; + st.failures = []; + refreshAuth(); + log('INFO', 'auth', 'backoff expired; retrying with refreshed credentials', { upstream: key }); + } + if (!AUTH_HEADER && !refreshAuth()) return null; + return AUTH_HEADER; +} + +function recordAuthFailure(key) { + const st = authStateFor(key); + const now = Date.now(); + st.failures = st.failures.filter(ts => now - ts < AUTH_LOCK_WINDOW_MS); + st.failures.push(now); + if (st.failures.length >= AUTH_LOCK_THRESHOLD && st.lockedUntil <= now) { + st.lockedUntil = now + AUTH_LOCK_BACKOFF_MS; + log('WARN', 'auth', 'repeated 407 from upstream; withholding credentials until backoff expires (check keyring / password expiry)', { upstream: key, failures: st.failures.length, backoffMs: AUTH_LOCK_BACKOFF_MS }); + } +} + +function recordAuthSuccess(key) { + const st = authState.get(key); + if (st && (st.failures.length > 0 || st.lockedUntil !== 0)) { + authState.delete(key); + log('INFO', 'auth', 'upstream accepted credentials; auth-failure state cleared', { upstream: key }); + } +} + function createPacSandbox() { const resolveHost = (host) => { try { return execFileSync('getent', ['hosts', host]).toString().split(/\s+/)[0]; } catch { return ""; } @@ -308,20 +360,26 @@ function createBridge(port, profileName) { clientRes.writeHead(403); return clientRes.end('Forbidden by proxy profile\n'); } + const isProxied = (upstream.type === 'PROXY' || upstream.type === 'HTTPS'); + const upstreamAuthKey = isProxied ? `${upstream.host}:${upstream.port}` : null; const options = { method: clientReq.method, headers: sanitizeProxyHeaders(clientReq.headers), - host: (upstream.type === 'PROXY' || upstream.type === 'HTTPS') ? upstream.host : urlObj.hostname, - port: (upstream.type === 'PROXY' || upstream.type === 'HTTPS') ? upstream.port : (urlObj.port || 80), - path: (upstream.type === 'PROXY' || upstream.type === 'HTTPS') ? urlObj.href : (urlObj.pathname + urlObj.search) + host: isProxied ? upstream.host : urlObj.hostname, + port: isProxied ? upstream.port : (urlObj.port || 80), + path: isProxied ? urlObj.href : (urlObj.pathname + urlObj.search) }; - if (upstream.type === 'PROXY' || upstream.type === 'HTTPS') { - if (!AUTH_HEADER) refreshAuth(); - if (AUTH_HEADER) options.headers['Proxy-Authorization'] = AUTH_HEADER; + if (isProxied) { + const authHeader = authHeaderForUpstream(upstreamAuthKey); + if (authHeader) options.headers['Proxy-Authorization'] = authHeader; } const requestModule = upstream.type === 'HTTPS' ? https : http; const upstreamReq = requestModule.request(options, (upstreamRes) => { + if (isProxied) { + if (upstreamRes.statusCode === 407) recordAuthFailure(upstreamAuthKey); + else recordAuthSuccess(upstreamAuthKey); + } clientRes.writeHead(upstreamRes.statusCode, sanitizeProxyHeaders(upstreamRes.headers)); upstreamRes.pipe(clientRes); upstreamRes.on('error', (err) => { @@ -368,6 +426,9 @@ function createBridge(port, profileName) { return clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n'); } + const isProxiedConnect = (upstream.type === 'PROXY' || upstream.type === 'HTTPS'); + const upstreamAuthKey = isProxiedConnect ? `${upstream.host}:${upstream.port}` : null; + const establishDirect = () => { upstreamSocket.setKeepAlive(true, 10000); upstreamSocket.setNoDelay(true); @@ -381,9 +442,9 @@ function createBridge(port, profileName) { const sendConnectToProxy = () => { upstreamSocket.setKeepAlive(true, 10000); upstreamSocket.setNoDelay(true); - if (!AUTH_HEADER) refreshAuth(); + const authHeader = authHeaderForUpstream(upstreamAuthKey); upstreamSocket.write(`CONNECT ${req.url} HTTP/1.1\r\nHost: ${req.url}\r\n`); - if (AUTH_HEADER) upstreamSocket.write(`Proxy-Authorization: ${AUTH_HEADER}\r\n`); + if (authHeader) upstreamSocket.write(`Proxy-Authorization: ${authHeader}\r\n`); upstreamSocket.write('\r\n'); }; @@ -427,6 +488,7 @@ function createBridge(port, profileName) { const status = parseUpstreamStatus(connectBuffer); if (status.code === 200) { + recordAuthSuccess(upstreamAuthKey); clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); const remaining = connectBuffer.subarray(headerEnd + 4); if (remaining.length > 0) clientSocket.write(remaining); @@ -434,6 +496,7 @@ function createBridge(port, profileName) { upstreamSocket.pipe(clientSocket); clientSocket.pipe(upstreamSocket); } else { + if (status.code === 407) recordAuthFailure(upstreamAuthKey); clientSocket.write(connectBuffer); clientSocket.end(); upstreamSocket.end(); diff --git a/scripts/proxy/bridge.test.js b/scripts/proxy/bridge.test.js index 009ed5e..c9d5fb9 100644 --- a/scripts/proxy/bridge.test.js +++ b/scripts/proxy/bridge.test.js @@ -62,7 +62,7 @@ async function bridge(t, upstreamPort, upstreamCfg) { // Start the bridge with env overrides and return the ephemeral port of a chosen // listenerProfile ('dynamic', 'internet', or a PAC profile name). All listeners // are tracked for cleanup; only the requested one is bound to a random port. -async function bridgeOnProfile(t, listenerProfile, { upstreamCfg, env = {} } = {}) { +async function bridgeOnProfile(t, listenerProfile, { upstreamCfg, env = {}, withCreds = false } = {}) { const servers = []; const fullEnv = { ...process.env, ...env }; vm.runInNewContext(source, { @@ -75,14 +75,32 @@ async function bridgeOnProfile(t, listenerProfile, { upstreamCfg, env = {} } = { if (typeof p === 'string' && p.includes('config.json')) { return JSON.stringify(upstreamCfg || { enabled: false }); } + if (typeof p === 'string' && p.includes('user.json')) { + if (!withCreds) throw new Error('ENOENT: no user.json in test'); + return JSON.stringify({ username: 'test-user' }); + } // PAC files and everything else fall through to the REAL fs return fs.readFileSync(p, 'utf8'); }, - existsSync: fs.existsSync, + existsSync: (p) => { + if (typeof p === 'string') { + if (p.includes('user.json')) return withCreds; + // Isolate runtime state files from the user's live installation; + // everything else (PAC dir etc.) uses the real fs. + if (p.includes('profile.json') || p.includes('overrides.json') || p.includes('remotes.json')) return false; + } + return fs.existsSync(p); + }, readdirSync: fs.readdirSync, watchFile() {}, }; - if (name === 'child_process') return { execFileSync: () => '' }; + if (name === 'child_process') return { + execFileSync: (cmd, args) => { + // secret-tool lookup -> fake standing creds when withCreds, else empty + if (Array.isArray(args) && args[0] === 'lookup' && withCreds) return 'test-password\n'; + return ''; + }, + }; if (name === 'http') return { ...http, createServer(...args) { @@ -318,3 +336,114 @@ test('HTTPS upstream TLS-wraps the CONNECT handshake', { timeout: 5000 }, async assert.ok(sawTls, 'bridge must speak TLS to an HTTPS upstream'); assert.ok(sawConnect, 'bridge must send CONNECT inside the TLS tunnel'); }); + +// --- Credential-failure backoff (anti-lockout) --- +// A corp proxy that answers 407 to authed CONNECT. The bridge must give up +// sending Proxy-Authorization after consecutive failures instead of hammering. + +function connectWithAuthCapture(t, port) { + // One CONNECT through the bridge; returns the client socket (ended by upstream 407). + const socket = net.connect(port, '127.0.0.1'); + t.after(() => socket.destroy()); + return once(socket, 'connect').then(() => { + socket.write('CONNECT example.test:443 HTTP/1.1\r\nHost: example.test:443\r\n\r\n'); + return socket; + }); +} + +// Wait until the client socket has seen one full proxy response head, then settle: +// covers both graceful end and destroy paths without depending on FIN timing. +function awaitProxyResponse(socket) { + return new Promise((resolve) => { + let buf = Buffer.alloc(0); + const done = () => resolve(buf); + const onData = (d) => { + buf = Buffer.concat([buf, d]); + if (buf.includes('\r\n\r\n')) { + socket.off('data', onData); + // give the close path one tick, but don't hang on it + setTimeout(done, 25); + } + }; + socket.on('data', onData); + socket.once('end', () => setTimeout(done, 25)); + socket.once('close', done); + setTimeout(done, 2000); // absolute cap: never wedge the suite + }); +} + +test('after 3 consecutive 407s the bridge stops sending Proxy-Authorization', { timeout: 5000 }, async t => { + const authHeadersSeen = []; + const upstream = net.createServer(socket => { + socket.once('data', d => { + authHeadersSeen.push(/^Proxy-Authorization:/mi.test(d.toString())); + socket.end('HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm="corp"\r\n\r\n'); + }); + }); + const upstreamPort = await listen(t, upstream); + const proxyPort = await bridgeOnProfile(t, 'internet', { + upstreamCfg: { enabled: true, host: '127.0.0.1', port: upstreamPort }, + withCreds: true, + }); + + for (let i = 0; i < 4; i++) { + const socket = await connectWithAuthCapture(t, proxyPort); + await awaitProxyResponse(socket); // upstream 407 is relayed then closed + } + assert.deepEqual(authHeadersSeen, [true, true, true, false], + 'attempts 1-3 carry auth (optimistic), attempt 4 must not (lockout engaged)'); +}); + +test('a 200 response after failures resets the lockout so auth resumes', { timeout: 5000 }, async t => { + const authHeadersSeen = []; + let calls = 0; + const upstream = net.createServer(socket => { + socket.once('data', d => { + authHeadersSeen.push(/^Proxy-Authorization:/mi.test(d.toString())); + calls++; + // 407 twice (below threshold), then 200 once (clears), then 407 x3 (re-locks), then observe + if (calls <= 2 || calls > 3) { + socket.end('HTTP/1.1 407 Proxy Authentication Required\r\n\r\n'); + } else { + socket.write('HTTP/1.1 200 OK\r\n\r\n'); + socket.pipe(socket); + } + }); + }); + const upstreamPort = await listen(t, upstream); + const proxyPort = await bridgeOnProfile(t, 'internet', { + upstreamCfg: { enabled: true, host: '127.0.0.1', port: upstreamPort }, + withCreds: true, + }); + + // 2 fail (count=2), 1 success (clears), 3 fail (re-lock at 3), final observe + for (let i = 0; i < 7; i++) { + const socket = await connectWithAuthCapture(t, proxyPort); + await awaitProxyResponse(socket); + } + assert.deepEqual(authHeadersSeen, [true, true, true, true, true, true, false], + 'success mid-stream resets the counter; re-lock needs a fresh 3 consecutive 407s'); +}); + +test('missing credentials never produce an auth header and do not crash the bridge', { timeout: 5000 }, async t => { + const authHeadersSeen = []; + const upstream = net.createServer(socket => { + socket.once('data', d => { + authHeadersSeen.push(/^Proxy-Authorization:/mi.test(d.toString())); + socket.write('HTTP/1.1 200 OK\r\n\r\n'); + socket.pipe(socket); + }); + }); + const upstreamPort = await listen(t, upstream); + const proxyPort = await bridgeOnProfile(t, 'internet', { + upstreamCfg: { enabled: true, host: '127.0.0.1', port: upstreamPort }, + withCreds: false, // no user.json, empty keyring + }); + + for (let i = 0; i < 3; i++) { + const socket = await connectWithAuthCapture(t, proxyPort); + await readUntil(socket, d => d.includes('200')); + socket.destroy(); + } + assert.deepEqual(authHeadersSeen, [false, false, false]); +});