fix(api): bound concurrent login bcrypt to shed CPU-pin floods

The public /auth/login route runs a full-cost bcrypt compare on every
request — including the anti-enumeration dummy-hash compare for an unknown
user — with no bound on how many run at once. A flood of concurrent logins
therefore pins every core in bcrypt, starving the rest of the API.

Cap the simultaneous compares with a small non-blocking concurrency limiter
(a buffered-channel semaphore): a login that cannot take a slot is shed with
429 auth_busy before the compare, rather than piling more work onto the
scheduler. The slot guards only the hash and is released the instant the
compare returns. It is a concurrency cap, not a per-account lockout, so it
never fences out the one admin trying to break-glass in, and the 429 lands
before any credential distinction so it leaks nothing about the username.

The cap follows the existing "zero disables" lever idiom (WakeCooldown,
MaxRunningServers); cmd/felis wires it to the core count (floored at 4).
This commit is contained in:
flyemoji committed 2026-07-01 21:01:28 +09:00
1 parent f34711c174
commit 7a51c1d9c3
4 files changed
+133 -3

No files matched your search

+12 -2
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"os"
goruntime "runtime"
"time"
"felis.lolicon.best/internal/api"
@@ -137,6 +138,14 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// agree on what local auth knows.
repo := api.NewPGRepo(drv.DB())
// Bound concurrent login bcrypt to roughly the core count (floored so even a 1–2
// vCPU demo box tolerates a handful of simultaneous staff logins). bcrypt is
// CPU-costly and the public login route runs a full compare on every request, so
// this caps the work a login flood can pile on the scheduler; the excess is shed
// as a cheap 429. Staff password logins are rare (players never use this path), so
// the cap never bites legitimate use.
loginBcryptCap := max(goruntime.NumCPU(), 4)
a := &api.API{
Repo: repo,
Cluster: api.NewK8sCluster(cl, cfg.K8s.Namespace),
@@ -161,8 +170,9 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
RootDomain: cfg.Server.RootDomain,
AdminHostname: cfg.Auth.AdminHostname,
},
RootDomain: cfg.Server.RootDomain,
WakeCooldown: 30 * time.Second,
RootDomain: cfg.Server.RootDomain,
WakeCooldown: 30 * time.Second,
MaxConcurrentLogins: loginBcryptCap,
}
fmt.Fprintln(stderr, "felis api: external face fails closed (Access JWKS key function not configured)")