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:
tke committed 2026-09-23 14:57:18 +02:00
1 parent 0b8c5001fc
commit c514f32661
2 files changed
+203 -11

No files matched your search

+132 -3
View File
@@ -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]);
});