Suprnova v0.9.0
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/64stayed 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_keykeys 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.comreaches the same mailbox asalice@example.comand
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'srefreshhad no expiry predicate and unconditionally extended
expires_at, andOpaqueSessionProvider::refresh_sessionskipped the
is_expired()check thatget_sessionperforms. A token held past its
expiry could be renewed indefinitely. Fixed at both layers. Not reachable
through Suprnova's own surface — neitherToriinor 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
issclaim was written but never verified. Algorithm pinning
was already correct —alg: noneand 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 samecsrf_state
could both read it before either delete landed. Now claimed in one
operation —DELETE ... RETURNINGon Postgres, a primary-key delete whose
affected-row count picks the winner on SeaORM. - Expired sessions were listed as active.
find_by_user_idhad 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_credentialtook a credential ID and returned
the owning user, andPasskeyAuth::authenticateminted 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
allowCredentialshands to anyone who can start a ceremony. Renamed to
find_user_by_credentialandcreate_session_for_verified_credential, both
documenting that verification is the caller's job. Not reachable through
Suprnova, which driveswebauthn-rsitself (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 SeaORMget_challengealso ignored
expires_atentirely, returning expired challenges as live. Reads now
exclude expired rows on both backends, and a newtake_challengeclaims 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-azureandfilesystem-gcsfeatures.Storage::register_azblob,
register_azblob_with,register_gcs,register_gcs_with,AzBlobConfig
andGcsConfigno 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 enablingreqsign-core/jwt, the featurereqsign-core's
optionalrsasits behind, so gating them severs all three opendal paths
to it at once.rsais now avoidable:--no-default-features --features filesystem,database-postgresresolves 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
rsa—database-mysqlis a default
feature andsqlx-mysql 0.8.6depends on it non-optionally — so the audit
exception stays open. S3 is deliberately not gated:reqsign-aws-v4
takesreqsign-corewithoutjwt, 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-vas 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 withSMEMBERSand 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 boundedSSCAN. The delayed-queue promotion
pass moved every due job in one unboundedZRANGEBYSCORE, so a backlog that
came due together produced a single enormous script; it now promotes in
batches. - Two shutdown drains waited forever.
schedule:workon 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 acargo install --tag vX.Y.Zline and
no dependency snippet was never discovered.suprnova-cli/README.mdhad
been telling readers to install v0.6.0 for three releases;manual/cli.md
andmanual/cli-new.mdsat at v0.7.2;manual/installation.mdcarried
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 docfailed for any build withfilesystembut without
testing— sevenStorage::fakeintra-doc links could not resolve, and
lib.rsdenies broken links.testingis a default feature, so no gate
step had ever built that combination;check-feature-matrix.shnow does.- Torii's migrations could not be replayed over their own schema, so a
database holding it without thetorii_migrationstracking table — restored
from a dump that skipped it, or migrated by hand — could not be brought under
management. EveryTable::create()carried.if_not_exists(); none of the 19
Index::create()calls did, nor did theADD COLUMN locked_atalter, so
replay sailed through the tables and died on the firstCREATE INDEX. Fixed
in the pinned fork (suprnova-torii-rsa0f956d) viahas_index/
has_columnrather thanIF 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::migrateunwrapped the migrator and returned
Ok(())unconditionally, soinit_torii's mapping of the failure into a
FrameworkErrorwas unreachable code. - An app's own
userstable 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--apistarter names its table
app_users. Torii's migration now warns at migrate time when an existing
userstable 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.