Found while checking two blank-password admin rows (eric@ciris.ai, test@ciris.ai, password='') that existed in the EU cirisnode database until 2026-09-24 (now deleted).
The blank-password rows were NOT a bypass
cirisnode/auth/passwords.py::verify_password returns False for an empty stored value before any comparison, so those rows could never log in. Good — but reviewing the login path turned up two real problems.
1. Unauthenticated account creation
cirisnode/api/auth/routes.py::login_for_access_token (POST /auth/token):
if row is None:
# Auto-create user with anonymous role (hashed password)
await conn.execute("INSERT INTO users (username, password, role) VALUES ($1, $2, 'anonymous')", ...)
role = "anonymous"
Any client can POST an arbitrary username/password to a production API and get (a) a persistent users row and (b) a signed JWT. The anonymous role has no access per the RBAC table, so this isn't privilege escalation — but it is an unauthenticated write path on a public endpoint (Caddy logs show scanners probing /auth/* daily), a trivial way to fill the users table, and a source of confusion because the endpoint returns 200 for wrong usernames. CLAUDE.md's stated model is "@ciris.ai accounts auto-created via Google check-access; everyone else anonymous" — that doesn't require creating rows for strangers.
2. Legacy plaintext passwords
verify_password accepts a stored value with no $ as plaintext and compares it in constant time; the login route then rehashes on success. Reasonable as a one-time migration, but there is no cutoff, no metric, and nothing prevents new plaintext rows from being inserted by hand (the two EU rows were exactly that). hash_password is also unsalted-SHA-256-with-salt rather than a slow KDF.
Asks
POST /auth/token returns 401 for unknown usernames. Remove the auto-create branch; if some flow relies on it, put it behind an explicit ALLOW_LOCAL_SIGNUP setting that is false in production.
- A migration that rehashes any remaining plaintext rows once (or nulls them and forces a reset), then remove the plaintext branch from
verify_password.
- Use a slow KDF (
hashlib.scrypt is stdlib, keeps the "no external dependencies" property) for new hashes.
- A
NOT NULL CHECK (password <> '') or equivalent on users.password so blank rows can't be created by hand again.
Evidence source: CIRISCore host inventory 2026-09-24; local review of cirisnode/auth/passwords.py and cirisnode/api/auth/routes.py at 5a617c4.
Found while checking two blank-password admin rows (
eric@ciris.ai,test@ciris.ai,password='') that existed in the EUcirisnodedatabase until 2026-09-24 (now deleted).The blank-password rows were NOT a bypass
cirisnode/auth/passwords.py::verify_passwordreturnsFalsefor an empty stored value before any comparison, so those rows could never log in. Good — but reviewing the login path turned up two real problems.1. Unauthenticated account creation
cirisnode/api/auth/routes.py::login_for_access_token(POST /auth/token):Any client can POST an arbitrary
username/passwordto a production API and get (a) a persistentusersrow and (b) a signed JWT. Theanonymousrole has no access per the RBAC table, so this isn't privilege escalation — but it is an unauthenticated write path on a public endpoint (Caddy logs show scanners probing/auth/*daily), a trivial way to fill theuserstable, and a source of confusion because the endpoint returns 200 for wrong usernames. CLAUDE.md's stated model is "@ciris.aiaccounts auto-created via Google check-access; everyone else anonymous" — that doesn't require creating rows for strangers.2. Legacy plaintext passwords
verify_passwordaccepts a stored value with no$as plaintext and compares it in constant time; the login route then rehashes on success. Reasonable as a one-time migration, but there is no cutoff, no metric, and nothing prevents new plaintext rows from being inserted by hand (the two EU rows were exactly that).hash_passwordis also unsalted-SHA-256-with-salt rather than a slow KDF.Asks
POST /auth/tokenreturns 401 for unknown usernames. Remove the auto-create branch; if some flow relies on it, put it behind an explicitALLOW_LOCAL_SIGNUPsetting that isfalsein production.verify_password.hashlib.scryptis stdlib, keeps the "no external dependencies" property) for new hashes.NOT NULL CHECK (password <> '')or equivalent onusers.passwordso blank rows can't be created by hand again.Evidence source: CIRISCore host inventory 2026-09-24; local review of
cirisnode/auth/passwords.pyandcirisnode/api/auth/routes.pyat5a617c4.