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();
|
||||
|
||||
// --- 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();
|
||||
|
||||
Reference in new issue
Block a user