agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
240 lines • 11.7 kB
JavaScript
/**
* hosting/signin/limits — attempt limits on a password door, correct under
* concurrency, bounded, and per process.
*
* ── Why the order matters (review idI34 B-1) ────────────────────────────────
* An attempt is COUNTED WHEN IT STARTS (`begin`), before the slow password
* check — never after it. Counting after let forty parallel guesses all read
* the same stale counter and all reach the checker. With `directory-password`
* that burst would walk past this budget into Active Directory's own lockout
* threshold and lock the real account company-wide; counting at the start is
* what makes a budget "half of AD's threshold" mean anything.
*
* Three rules, then:
* 1. **Per typed name: a hard budget.** `perName` attempts per window; past it
* the name is refused (429, `Retry-After`) until the window passes. A delay
* grows with the attempts already counted BEFORE any refusal, because a hard
* refusal is itself a lockout anyone can trigger for a colleague by typing
* their name (the documented trade: that is the price of a name budget).
* 2. **One check in flight per name.** A second attempt for a name whose check
* has not answered is refused at once (429) — parallelism buys nothing.
* 3. **Per client address: a growing DELAY only, never a refusal.** Behind a
* proxy or a shared NAT every person can arrive from one address; a hard
* address budget would let anybody lock the whole company out of sign-in
* with a handful of wrong guesses. The per-name budget is what protects an
* account; the address delay slows one client spraying many names. IPv6
* clients are counted per /64 (one customer's network), so rotating
* addresses inside it does not escape the delay.
* **Opt-in hard refusal (`refuseAddressAfter`):** an install whose clients
* each arrive from their OWN address (no shared NAT or proxy in front, or
* `trustedProxies` set so each person's address is resolved) can ask for a
* refusal too: once an address holds that many failed attempts inside the
* window, it is refused (429) without a check until a full window passes
* since its last counted attempt. Refused attempts are not counted, so the
* refusal is bounded by one window. Off by default, for the NAT reason
* above.
*
* ── Bounded without being flushable ─────────────────────────────────────────
* Name and address counters live in two SEPARATE bounded maps, so address churn
* can never push a name counter out. When a map is full, the least recently used
* counter that is NOT penalising (none in flight, and not delaying or refusing)
* is dropped; a counter that still penalises is never dropped. If every counter
* in the map is penalising, a NEW name or address is refused as `busy` (503) —
* failing closed rather than forgetting a penalty.
*
* What it does not claim: it REDUCES guessing and lockout risk and never
* claims to prevent lockout; the counters are per process (N replicas give
* N budgets — the banner says so).
*/
export function attemptLimiter(limits = {}) {
const perName = positive(limits.perName ?? 5, 'perName');
const perAddress = positive(limits.perAddress ?? 20, 'perAddress');
const windowMs = positive(limits.windowMinutes ?? 15, 'windowMinutes') * 60_000;
const backoffMs = nonNegative(limits.backoffMs ?? 1_000, 'backoffMs');
const maxEntries = positive(limits.maxEntries ?? 10_000, 'maxEntries');
const refuseAddressAfter = limits.refuseAddressAfter === undefined
? undefined
: positiveWhole(limits.refuseAddressAfter, 'refuseAddressAfter');
// A NAME counter with even ONE counted attempt is never evicted by new names
// (idI57 recheck): with `>= 2`, a flood of junk names could push out a
// victim's counter at one failure and hand the guesser a fresh budget. A map
// full of penalising counters answers a new name `busy` (503) instead.
const names = boundedCounters(maxEntries, windowMs, (c) => c.attempts >= 1);
// An address at its opt-in refusal is penalising too, so it is never evicted.
const addressPenalty = Math.min(2, refuseAddressAfter ?? 2);
const addresses = boundedCounters(maxEntries, windowMs, (c) => c.attempts >= addressPenalty);
const delayFor = (prior) => prior <= 1 ? 0 : backoffMs * Math.min(8, 2 ** (prior - 2));
return {
get size() {
return names.size + addresses.size;
},
begin(name, address, now) {
const bucket = addressBucket(address);
const byName = names.get(name, now);
if (byName !== undefined && (byName.inFlight > 0 || byName.attempts >= perName)) {
const inFlight = byName.inFlight > 0;
const left = inFlight ? 1_000 : byName.lastAt + windowMs - now;
return {
kind: 'refuse',
retryAfterSeconds: Math.max(1, Math.ceil(left / 1000)),
why: inFlight ? 'name-in-flight' : 'name-budget',
};
}
if (refuseAddressAfter !== undefined) {
const byAddress = addresses.get(bucket, now);
if (byAddress !== undefined && byAddress.attempts >= refuseAddressAfter) {
const left = byAddress.lastAt + windowMs - now;
return {
kind: 'refuse',
retryAfterSeconds: Math.max(1, Math.ceil(left / 1000)),
why: 'address-budget',
};
}
}
const nameCounter = byName ?? names.create(name, now);
if (nameCounter === undefined)
return { kind: 'busy', retryAfterSeconds: 5 };
const addressCounter = addresses.get(bucket, now) ?? addresses.create(bucket, now);
if (addressCounter === undefined) {
if (byName === undefined)
names.drop(name);
return { kind: 'busy', retryAfterSeconds: 5 };
}
// The delay comes from attempts already counted — the address's scaled
// so it reaches its cap at `perAddress`.
const addressPrior = Math.ceil((addressCounter.attempts * perName) / perAddress);
const delayMs = Math.max(delayFor(nameCounter.attempts), delayFor(addressPrior));
nameCounter.attempts += 1;
nameCounter.inFlight += 1;
nameCounter.lastAt = now;
addressCounter.attempts += 1;
addressCounter.inFlight += 1;
addressCounter.lastAt = now;
return { kind: 'allow', delayMs, ticket: { name, address: bucket } };
},
failed(ticket, now) {
names.settle(ticket.name, 0, now);
addresses.settle(ticket.address, 0, now);
},
succeeded(ticket) {
names.drop(ticket.name);
// A right password is not a strike against the address (a proxy's
// morning of sign-ins must not slow everybody).
addresses.settle(ticket.address, 1);
},
abandoned(ticket) {
names.settle(ticket.name, 1);
addresses.settle(ticket.address, 1);
},
};
}
/**
* One bounded map of counters. Least recently used first; a counter that is
* penalising (in flight, or `penalising(counter)` within its window) is never
* evicted — a new key is refused (`undefined`) instead.
*/
function boundedCounters(maxEntries, windowMs, penalising) {
const map = new Map();
// AD's own rule ("reset account lockout counter after"): a counter is
// forgotten only once a FULL window has passed since its LAST attempt. A
// window fixed at the first attempt let a patient guesser space attempts
// across the boundary and land twice the budget inside one AD window.
const live = (c, now) => now - c.lastAt < windowMs;
return {
get size() {
return map.size;
},
get(key, now) {
const counter = map.get(key);
if (counter === undefined)
return undefined;
if (!live(counter, now) && counter.inFlight === 0) {
map.delete(key);
return undefined;
}
map.delete(key);
map.set(key, counter);
return counter;
},
create(key, now) {
if (map.size >= maxEntries && !evictOne(now))
return undefined;
const counter = { attempts: 0, inFlight: 0, lastAt: now };
map.set(key, counter);
return counter;
},
/** One attempt answered: out of flight, and `uncount` attempts taken back; a failure moves `lastAt`. */
settle(key, uncount, now) {
const counter = map.get(key);
if (counter === undefined)
return;
counter.inFlight = Math.max(0, counter.inFlight - 1);
counter.attempts = Math.max(0, counter.attempts - uncount);
if (now !== undefined)
counter.lastAt = Math.max(counter.lastAt, now);
},
drop(key) {
const counter = map.get(key);
if (counter !== undefined && counter.inFlight > 1)
counter.inFlight -= 1;
else
map.delete(key);
},
};
function evictOne(now) {
for (const [key, counter] of map) {
if (counter.inFlight > 0)
continue;
if (live(counter, now) && penalising(counter))
continue;
map.delete(key);
return true;
}
return false;
}
}
/**
* The address a budget is kept for: an IPv4 address as is; an IPv6 address by
* its /64 (the first four groups) — one network, however many addresses it
* rotates through.
*/
export function addressBucket(address) {
if (!address.includes(':'))
return address;
const groups = expandIPv6(address);
return groups === undefined ? address : `${groups.slice(0, 4).join(':')}::/64`;
}
function expandIPv6(address) {
const bare = address.replace(/^\[|\]$/g, '').split('%')[0] ?? '';
const halves = bare.split('::');
if (halves.length > 2)
return undefined;
const head = halves[0] === '' ? [] : halves[0].split(':');
const tail = halves.length === 2 && halves[1] !== '' ? halves[1].split(':') : [];
const missing = 8 - head.length - tail.length;
if (missing < 0 || (halves.length === 1 && missing !== 0))
return undefined;
const groups = [...head, ...Array.from({ length: missing }, () => '0'), ...tail];
if (groups.some((g) => !/^[0-9a-f]{1,4}$/i.test(g)))
return undefined;
return groups.map((g) => g.toLowerCase().replace(/^0+(?=.)/, ''));
}
function positive(value, name) {
if (!Number.isFinite(value) || value <= 0) {
throw new TypeError(`[hosting] attempt limits: ${name} must be a positive number.`);
}
return value;
}
function positiveWhole(value, name) {
if (!Number.isInteger(value) || value <= 0) {
throw new TypeError(`[hosting] attempt limits: ${name} must be a whole number above 0.`);
}
return value;
}
function nonNegative(value, name) {
if (!Number.isFinite(value) || value < 0) {
throw new TypeError(`[hosting] attempt limits: ${name} must be 0 or more.`);
}
return value;
}
//# sourceMappingURL=limits.js.map