POST /api/login answered every wrong password 401 for as long as someone
kept trying. Now ipx counts them by the client's address: the tenth in a
window holds that address off until the window, which starts at its first
wrong password, ends, answered 429 with how long is left. Counted by
address and not name, since by name anyone could lock anyone out; a right
password does not reset the count, or a guesser with an account of their
own could clear it between guesses.
Behind Caddy every peer is Caddy, so the address is the last one in
X-Forwarded-For, taken only from trusted_proxies. Caddy now sets that to
{client_ip}: Cf-Connecting-IP for the tunnel's requests, believed only
from TUNNEL_PEER (servers trusted_proxies), and the peer for everyone
else, replacing whatever the client sent. Checked with Caddy against an
echo upstream: a forged XFF and Cf-Connecting-IP from elsewhere reach ipx
as the peer, the tunnel's Cf-Connecting-IP as itself.
The sign-in load test sends each attempt from its own address, so it
still measures a flood from many, whose passwords are all checked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
51 lines
2.5 KiB
TypeScript
51 lines
2.5 KiB
TypeScript
import http from 'k6/http';
|
|
import { check, sleep } from 'k6';
|
|
import { Trend } from 'k6/metrics';
|
|
import { BASE, as, me } from './lib.ts';
|
|
|
|
// A flood of sign-in attempts with wrong passwords while everyone else goes on using the site.
|
|
// Two things only many requests at once show: whether checking passwords, tens of milliseconds
|
|
// of CPU each, starves the rest of the server, and whether a wrong password for a name that is
|
|
// an account takes longer to refuse than one for a name that is not -- the answers read the
|
|
// same, and the time would tell anyone which names are accounts here.
|
|
const gap = new Trend('signin_name_gap', true);
|
|
export const options = {
|
|
scenarios: {
|
|
attempts: { executor: 'constant-vus', vus: 40, duration: '40s', exec: 'attempt' },
|
|
everyone: { executor: 'constant-vus', vus: 5, duration: '40s', exec: 'browse' },
|
|
},
|
|
thresholds: {
|
|
http_req_failed: ['rate==0'],
|
|
checks: ['rate==1'],
|
|
// 57ms on 2026-10-05 on SQLite, 68 on Postgres; 4.3s while password checks ran on the async
|
|
// workers (#137).
|
|
'http_req_duration{name:meanwhile}': ['p(95)<300'],
|
|
// Known name against unknown, one straight after the other: no gap but noise. 0.2ms on
|
|
// 2026-10-05; 31ms while an unknown name was refused without a check (#138).
|
|
signin_name_gap: ['med<10'],
|
|
},
|
|
};
|
|
// Each attempt from an address of its own, as Caddy would name it: one address is held off after
|
|
// ten (#409), and the flood to measure is one from many, which still has every password checked.
|
|
const refused = (n: number) => ({
|
|
headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': `10.${(n >> 16) & 255}.${(n >> 8) & 255}.${n & 255}` },
|
|
responseCallback: http.expectedStatuses(401),
|
|
});
|
|
|
|
export function attempt() {
|
|
const n = __VU * 10_000 + __ITER;
|
|
const known = http.post(`${BASE}/api/login`, JSON.stringify({ name: 'piper', password: 'not-the-password' }),
|
|
{ ...refused(2 * n), tags: { name: 'signin-known' } });
|
|
const unknown = http.post(`${BASE}/api/login`, JSON.stringify({ name: `nobody-${__VU}-${__ITER}`, password: 'not-the-password' }),
|
|
{ ...refused(2 * n + 1), tags: { name: 'signin-unknown' } });
|
|
check(known, { 'a wrong password refused': r => r.status === 401 });
|
|
check(unknown, { 'an unknown name refused': r => r.status === 401 });
|
|
gap.add(known.timings.duration - unknown.timings.duration);
|
|
}
|
|
|
|
export function browse() {
|
|
const r = http.get(`${BASE}/api/feeds`, as(me(), 'meanwhile'));
|
|
check(r, { 'the site still answers': r => r.status === 200 });
|
|
sleep(0.5);
|
|
}
|