Skip to content

Suprnova v0.9.0

Choose a tag to compare

@eas4ai eas4ai released this 01 Aug 07:01
· 103 commits to main since this release

Security

  • Auth issuance could only be throttled per caller, never per
    recipient.
    An address-keyed limit answers "is one client noisy"; it
    cannot answer "is one mailbox being flooded". An attacker spread across
    a botnet or a single IPv6 /64 stayed under every per-IP budget while
    filling one victim's inbox with password-reset mail, and nothing in the
    framework could express the limit that would have stopped it — a key
    function could read the path, headers, and query string, but not a
    form-encoded body, so the address was invisible on exactly the route
    that carries it.

    identity_key keys a bucket on the account being acted on. It reads the
    query string first and then a buffered form body, so one key function
    covers both shapes; the value is trimmed and lowercased, because
    Alice@Example.com reaches the same mailbox as alice@example.com and
    a limit bypassed by holding down shift is not a limit; and it is hashed,
    because a rate-limit backend is frequently a shared Redis with weaker
    access control than the primary database.

    Two new middleware builders support it. key_reads_body(cap) buffers
    the body before keying — opt-in, because buffering is work an
    unauthenticated caller gets to make you do, and a body over the cap is
    refused with 413 rather than passed through unkeyed. only_when(pred)
    skips a limiter entirely for requests it has nothing to say about,
    which is what keeps a stacked per-recipient budget from silently
    becoming the binding limit on routes that name no recipient.

    The dogfood app now stacks both on its issuance group: 10 per 5 minutes
    per address, 3 per 15 minutes per recipient.

A review of Torii's session, password, OAuth, and passkey paths turned up
eight defects, all fixed in the pinned fork (suprnova-torii-rs 968b0be).

  • Expired sessions could be refreshed back to life. The SeaORM session
    repository's refresh had no expiry predicate and unconditionally extended
    expires_at, and OpaqueSessionProvider::refresh_session skipped the
    is_expired() check that get_session performs. A token held past its
    expiry could be renewed indefinitely. Fixed at both layers. Not reachable
    through Suprnova's own surface — neither Torii nor the framework exposes
    session refresh — but it is public API of both crates.
  • The login form leaked which accounts exist, by timing. Authentication
    returned as soon as the email missed, skipping Argon2 entirely: measured at
    54µs for an unknown address against 719ms for a wrong password, a ~13,000x
    gap readable over a network. Both failure paths now verify against a dummy
    hash so they cost the same. This one was reachable through Suprnova's
    password login.
  • The JWT iss claim was written but never verified. Algorithm pinning
    was already correct — alg: none and HS/RS confusion were never possible —
    but the issuer was decoration, so two services sharing a signing key would
    accept each other's sessions. Now enforced when an issuer is configured.
  • A single-use PKCE verifier could be claimed twice. Consumption was a
    read followed by a delete, so two OAuth callbacks for the same csrf_state
    could both read it before either delete landed. Now claimed in one
    operation — DELETE ... RETURNING on Postgres, a primary-key delete whose
    affected-row count picks the winner on SeaORM.
  • Expired sessions were listed as active. find_by_user_id had no expiry
    filter, and expired rows survive until cleanup runs, so a "devices you're
    signed in on" screen offered users dead sessions to revoke while saying
    nothing about the live one.
  • A passkey lookup was named authenticate. Torii's
    PasskeyService::authenticate_credential took a credential ID and returned
    the owning user, and PasskeyAuth::authenticate minted a session from it.
    Torii stores passkeys — it carries no WebAuthn dependency and cannot verify
    an assertion, so the only thing those calls proved was that the caller knew
    a credential ID: a value the browser sends in the clear and
    allowCredentials hands to anyone who can start a ceremony. Renamed to
    find_user_by_credential and create_session_for_verified_credential, both
    documenting that verification is the caller's job. Not reachable through
    Suprnova, which drives webauthn-rs itself (see
    torii_integration::passkey) and reaches Torii only for credential storage.
  • A WebAuthn challenge was replayable for its whole TTL. Neither backend
    consumed a challenge on read, and the SeaORM get_challenge also ignored
    expires_at entirely, returning expired challenges as live. Reads now
    exclude expired rows on both backends, and a new take_challenge claims one
    exactly once — the same delete-decides-the-winner shape as the PKCE fix.

Breaking

  • Azure Blob Storage and Google Cloud Storage moved behind the new
    filesystem-azure and filesystem-gcs features.
    Storage::register_azblob,
    register_azblob_with, register_gcs, register_gcs_with, AzBlobConfig
    and GcsConfig no longer exist unless you enable the matching feature. If
    you use either backend, add it to your dependency:

    suprnova = { git = "", tag = "v…", features = ["filesystem-gcs"] }

    You get a compile error naming the missing item, not a runtime failure.

    Both opendal service crates pull rsa, which carries RUSTSEC-2023-0071
    (the Marvin timing attack) with no fixed release upstream. They were the
    only crates enabling reqsign-core/jwt, the feature reqsign-core's
    optional rsa sits behind, so gating them severs all three opendal paths
    to it at once. rsa is now avoidable: --no-default-features --features filesystem,database-postgres resolves without it and still has the
    storage subsystem. Previously no feature combination could shed it while
    keeping storage at all.

    A stock default build still carries rsadatabase-mysql is a default
    feature and sqlx-mysql 0.8.6 depends on it non-optionally — so the audit
    exception stays open. S3 is deliberately not gated: reqsign-aws-v4
    takes reqsign-core without jwt, so the S3 driver never contributed a
    path, and gating it would break the most-used cloud backend while removing
    nothing.

Added

  • suprnova --version, with -v as well as clap's default -V. Asking a
    CLI its version with the flag every other CLI uses should not print a usage
    error.

Fixed

  • Two Redis operations had no upper bound. The cache's tag flush read a
    tag's whole member set with SMEMBERS and deleted key by key, so a tag with
    a large membership stalled the connection and a concurrent write could be
    lost between the read and the delete; tags are now generation-based, flushed
    atomically, and scanned with a bounded SSCAN. The delayed-queue promotion
    pass moved every due job in one unbounded ZRANGEBYSCORE, so a backlog that
    came due together produced a single enormous script; it now promotes in
    batches.
  • Two shutdown drains waited forever. schedule:work on Ctrl-C and the
    workflow worker after cancellation both awaited every in-flight task with no
    deadline, so one task that never returned held the process open until
    SIGKILL — an operator sees a daemon that "doesn't stop". Both now wait a
    bounded grace, then abort what remains and report the count.
  • The release version-pin sweep only recognised one of the two pin
    syntaxes
    , so every file carrying a cargo install --tag vX.Y.Z line and
    no dependency snippet was never discovered. suprnova-cli/README.md had
    been telling readers to install v0.6.0 for three releases; manual/cli.md
    and manual/cli-new.md sat at v0.7.2; manual/installation.md carried
    both forms and had one bumped while the other froze. Discovery and rewrite
    now read from one pattern table, and a file's rules are derived from its
    content.
  • cargo doc failed for any build with filesystem but without
    testing
    — seven Storage::fake intra-doc links could not resolve, and
    lib.rs denies broken links. testing is a default feature, so no gate
    step had ever built that combination; check-feature-matrix.sh now does.
  • Torii's migrations could not be replayed over their own schema, so a
    database holding it without the torii_migrations tracking table — restored
    from a dump that skipped it, or migrated by hand — could not be brought under
    management. Every Table::create() carried .if_not_exists(); none of the 19
    Index::create() calls did, nor did the ADD COLUMN locked_at alter, so
    replay sailed through the tables and died on the first CREATE INDEX. Fixed
    in the pinned fork (suprnova-torii-rs a0f956d) via has_index /
    has_column rather than IF NOT EXISTS, which sea-query silently drops for
    MySQL — the syntactic fix would have left a default-featured build broken.
  • A failed Torii migration aborted the process instead of returning an
    error.
    SeaORMStorage::migrate unwrapped the migrator and returned
    Ok(()) unconditionally, so init_torii's mapping of the failure into a
    FrameworkError was unreachable code.
  • An app's own users table silently suppressed Torii's, because
    .if_not_exists() cannot tell "already mine" from "already somebody
    else's". The migration reported success and authentication failed later on
    a missing column — the reason the --api starter names its table
    app_users. Torii's migration now warns at migrate time when an existing
    users table lacks columns it requires, naming the columns and the remedy.
    It stays a warning rather than a hard failure so existing deployments keep
    booting.
  • The Railway and DigitalOcean deployment guides pointed the platform
    health check at a path that could probe Postgres.
    Both platforms restart
    the container when that check fails, so following the advice turned a
    database blip into a restart loop across every replica. Both now use
    /_suprnova/health/live, with the database probed by hand from the
    console. The legacy paths still resolve; nothing already deployed needs
    changing.