Skip to content

POST /auth/token auto-creates a user for any unknown username and issues a JWT; legacy plaintext passwords accepted #42

Description

@emooreatx

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

  1. 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.
  2. A migration that rehashes any remaining plaintext rows once (or nulls them and forces a reset), then remove the plaintext branch from verify_password.
  3. Use a slow KDF (hashlib.scrypt is stdlib, keeps the "no external dependencies" property) for new hashes.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions