Enforce is_fake, and close the holes that let an unattended signup in

FeatureSecurityService
Shipped
August 19, 2026 at 8:09 AM UTC
Author
Kamo
Commit
19ddc01

Two halves of one incident. An account registered, verified an address at a disposable provider, took an auto-login session and created five organizations in three minutes and forty-two seconds — four of them byte-identical "Acme Corp" rows differing only in their domain. It produced ZERO rows in system_access_logs, so there is no IP and no user-agent on record for any of it. ── Enforcing the flag **************** - UserAuthenticationService: `u.is_fake = FALSE` goes into ACCOUNT_QUERY_PREFIX, not into a caller. All three login identifiers — username, account email, and the primary-mailbox re-lookup keyed by id — reuse that one projection, so one clause closes all three and a fourth identifier added later inherits it. A flagged account reads as non-existent and gets the generic "user does not exist or password is incorrect"; a message naming the flag would tell an abuser exactly what to change. - KSessionService.createSession: refuses outright. This is the narrowest waist in the system for "may this account act" — every *** is minted here, so one gate covers password login, enter-as, desktop SSO, device auth AND the register funnel's post-verification auto-login, which is how the account in question got a session without ever calling /login. Throws rather than returning null, because thirty callers assume a non-null id and would NPE somewhere else entirely; login and the auto-session path catch it and shape a clean answer. - FakeAccountGuard: two tiers on purpose. The single-account check is UNCACHED (it backs the hard gates, where a stale answer means a flagged account still gets in); the id sets are cached for 60s (they back read filtering, where the worst case is a synthetic org lingering in a listing for under a minute). Fails open — hiding a handful of rows is not worth 500ing an org listing — and does NOT cache a failure, so one blip does not become a minute of the flag silently not working. - OrgObjectStorageSweep: stops snapshotting synthetic orgs. This is where the money was: snapshots are written per org per day forever, and five abandoned orgs had already become ~9% of that table. An account that cannot log in does nothing about work the platform keeps doing on its behalf. - FakeAccountController: flag, unflag, list — behind MANAGE_ORGANIZATIONS rather than a new platform right, because kamo-internal's platformRightCoverage test asserts the right set against the console's rendered surfaces. Revokes live sessions on flagging, since the gates are on MINTING a session, not on using one; refuses to flag the System User, which would make the platform unable to enter any child org. ── The holes **************** - /register never read `***Token`. The register UI has always solved an Capcha challenge and posted the result; grep for the field across all Java returned nothing. The widget was in the browser and the endpoint was wide open. Now verified, and FAIL CLOSED — asymmetric with login on purpose: login can only verify a payload when one is present, because the mobile client sends none and hard-requiring it there would lock real users out. Registration has exactly one client and it already sends the token. - CapchaVerificationService returned TRUE when `capcha.service-url` was unset, so a config omission was indistinguishable from a solved challenge. Now rejects, behind `capcha.allow-unconfigured` (default false) for local runs. No production change — the property is set in kamowssecurity-config — but the latent bypass is gone. - POST /org had no rate limit of any kind. Now capped per ACCOUNT (not per IP — an IP key would punish everyone behind one corporate NAT). Deliberately generous: a real customer created three orgs in five and a half minutes, two with the same name and alias, while working out the wizard. A tight limit would have blocked them. Counts attempts, not successes, and fails open on a Redis outage. - The org alias had NO uniqueness check on this path — only the domain did, which is why four live orgs share "acme-corp" and why the creator varied only the domain each time: it was the one field the server would reject. Now a 409, scoped to the security provider because `(security_provider_id, alias)` is the pair login resolves a web-alias host on. - REGISTRATION / REGISTRATION_REJECTED / EMAIL_VERIFIED / ORG_CREATED are now written with IP and user-agent. Every detection rule keys off login events, so the whole register funnel sat outside the detection engine's field of view. Deliberately NOT done, having checked: login is not made ***-strict (breaks the mobile app, needs a coordinated release); disposable-email domains are not blocked (a real customer signs in from proton.me, and a naive "not a mainstream provider" rule blocks real people); no content-scoring auto-block (the strongest-looking behavioural signal — duplicate org names created minutes apart — matches a real customer exactly). Schema prerequisite already satisfied: KamoInitializer applied users.is_fake before the shared-lib push, verified as `boolean NOT NULL DEFAULT false` with its partial index, and re-ran idempotently without clearing the flag.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing