feat(proxy): credential-failure backoff prevents corp proxy account lockout
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).
This commit is contained in:
1 parent
0b8c5001fc
commit
c514f32661
2 files changed
+203
-11
No files matched your search
+71
-8
@@ -88,6 +88,58 @@ function refreshAuth() {
|
|||||||
}
|
}
|
||||||
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() {
|
function createPacSandbox() {
|
||||||
const resolveHost = (host) => {
|
const resolveHost = (host) => {
|
||||||
try { return execFileSync('getent', ['hosts', host]).toString().split(/\s+/)[0]; } catch { return ""; }
|
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');
|
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 = {
|
const options = {
|
||||||
method: clientReq.method,
|
method: clientReq.method,
|
||||||
headers: sanitizeProxyHeaders(clientReq.headers),
|
headers: sanitizeProxyHeaders(clientReq.headers),
|
||||||
host: (upstream.type === 'PROXY' || upstream.type === 'HTTPS') ? upstream.host : urlObj.hostname,
|
host: isProxied ? upstream.host : urlObj.hostname,
|
||||||
port: (upstream.type === 'PROXY' || upstream.type === 'HTTPS') ? upstream.port : (urlObj.port || 80),
|
port: isProxied ? upstream.port : (urlObj.port || 80),
|
||||||
path: (upstream.type === 'PROXY' || upstream.type === 'HTTPS') ? urlObj.href : (urlObj.pathname + urlObj.search)
|
path: isProxied ? urlObj.href : (urlObj.pathname + urlObj.search)
|
||||||
};
|
};
|
||||||
if (upstream.type === 'PROXY' || upstream.type === 'HTTPS') {
|
if (isProxied) {
|
||||||
if (!AUTH_HEADER) refreshAuth();
|
const authHeader = authHeaderForUpstream(upstreamAuthKey);
|
||||||
if (AUTH_HEADER) options.headers['Proxy-Authorization'] = AUTH_HEADER;
|
if (authHeader) options.headers['Proxy-Authorization'] = authHeader;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestModule = upstream.type === 'HTTPS' ? https : http;
|
const requestModule = upstream.type === 'HTTPS' ? https : http;
|
||||||
const upstreamReq = requestModule.request(options, (upstreamRes) => {
|
const upstreamReq = requestModule.request(options, (upstreamRes) => {
|
||||||
|
if (isProxied) {
|
||||||
|
if (upstreamRes.statusCode === 407) recordAuthFailure(upstreamAuthKey);
|
||||||
|
else recordAuthSuccess(upstreamAuthKey);
|
||||||
|
}
|
||||||
clientRes.writeHead(upstreamRes.statusCode, sanitizeProxyHeaders(upstreamRes.headers));
|
clientRes.writeHead(upstreamRes.statusCode, sanitizeProxyHeaders(upstreamRes.headers));
|
||||||
upstreamRes.pipe(clientRes);
|
upstreamRes.pipe(clientRes);
|
||||||
upstreamRes.on('error', (err) => {
|
upstreamRes.on('error', (err) => {
|
||||||
@@ -368,6 +426,9 @@ function createBridge(port, profileName) {
|
|||||||
return clientSocket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
|
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 = () => {
|
const establishDirect = () => {
|
||||||
upstreamSocket.setKeepAlive(true, 10000);
|
upstreamSocket.setKeepAlive(true, 10000);
|
||||||
upstreamSocket.setNoDelay(true);
|
upstreamSocket.setNoDelay(true);
|
||||||
@@ -381,9 +442,9 @@ function createBridge(port, profileName) {
|
|||||||
const sendConnectToProxy = () => {
|
const sendConnectToProxy = () => {
|
||||||
upstreamSocket.setKeepAlive(true, 10000);
|
upstreamSocket.setKeepAlive(true, 10000);
|
||||||
upstreamSocket.setNoDelay(true);
|
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`);
|
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');
|
upstreamSocket.write('\r\n');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,6 +488,7 @@ function createBridge(port, profileName) {
|
|||||||
|
|
||||||
const status = parseUpstreamStatus(connectBuffer);
|
const status = parseUpstreamStatus(connectBuffer);
|
||||||
if (status.code === 200) {
|
if (status.code === 200) {
|
||||||
|
recordAuthSuccess(upstreamAuthKey);
|
||||||
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
||||||
const remaining = connectBuffer.subarray(headerEnd + 4);
|
const remaining = connectBuffer.subarray(headerEnd + 4);
|
||||||
if (remaining.length > 0) clientSocket.write(remaining);
|
if (remaining.length > 0) clientSocket.write(remaining);
|
||||||
@@ -434,6 +496,7 @@ function createBridge(port, profileName) {
|
|||||||
upstreamSocket.pipe(clientSocket);
|
upstreamSocket.pipe(clientSocket);
|
||||||
clientSocket.pipe(upstreamSocket);
|
clientSocket.pipe(upstreamSocket);
|
||||||
} else {
|
} else {
|
||||||
|
if (status.code === 407) recordAuthFailure(upstreamAuthKey);
|
||||||
clientSocket.write(connectBuffer);
|
clientSocket.write(connectBuffer);
|
||||||
clientSocket.end();
|
clientSocket.end();
|
||||||
upstreamSocket.end();
|
upstreamSocket.end();
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async function bridge(t, upstreamPort, upstreamCfg) {
|
|||||||
// Start the bridge with env overrides and return the ephemeral port of a chosen
|
// Start the bridge with env overrides and return the ephemeral port of a chosen
|
||||||
// listenerProfile ('dynamic', 'internet', or a PAC profile name). All listeners
|
// listenerProfile ('dynamic', 'internet', or a PAC profile name). All listeners
|
||||||
// are tracked for cleanup; only the requested one is bound to a random port.
|
// 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 servers = [];
|
||||||
const fullEnv = { ...process.env, ...env };
|
const fullEnv = { ...process.env, ...env };
|
||||||
vm.runInNewContext(source, {
|
vm.runInNewContext(source, {
|
||||||
@@ -75,14 +75,32 @@ async function bridgeOnProfile(t, listenerProfile, { upstreamCfg, env = {} } = {
|
|||||||
if (typeof p === 'string' && p.includes('config.json')) {
|
if (typeof p === 'string' && p.includes('config.json')) {
|
||||||
return JSON.stringify(upstreamCfg || { enabled: false });
|
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
|
// PAC files and everything else fall through to the REAL fs
|
||||||
return fs.readFileSync(p, 'utf8');
|
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,
|
readdirSync: fs.readdirSync,
|
||||||
watchFile() {},
|
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 {
|
if (name === 'http') return {
|
||||||
...http,
|
...http,
|
||||||
createServer(...args) {
|
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(sawTls, 'bridge must speak TLS to an HTTPS upstream');
|
||||||
assert.ok(sawConnect, 'bridge must send CONNECT inside the TLS tunnel');
|
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]);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user