You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Registered API keys were completely unmetered in production
(audit 2026-08-13 F1): MirroredKey carried no monthly quota, so the
record the deployed Redis validator reads had none, and the quota
middleware short-circuits at <= 0 — every key /v1/register handed
out advertised a 1,000,000/month cap (in its own response body and in
the public agent docs) and was enforced nowhere. The rate limiter was
the only live bound. Quota now flows through the mirror, with a
round-trip test (real store → real validator) asserting LITERAL
expected values: the prior tests compared a component against its own
input, which is why a dropped field read as correct on both sides.
POST /v1/register was cross-site invocable (F4): the
Content-Type gate only validated the header when present, so a
header-less POST — a CORS simple request, never preflighted —
let any page create an account plus a permanent credential per
visitor via fetch(…, {mode:'no-cors'}), while burning tokens from
the per-IP throttle this endpoint shares with /v1/signup (with the
source addresses distributed across victims). The header is now
required; docs and examples send it.
Fixed
A contract page took ~8s to finish loading because the WASM panel
paid an unbounded lake scan to produce a nicer 404. When a
contract has no captured instance — the common case — /wasm asked
"is this a SAC?" via contract_id = ? ORDER BY ledger_seq DESC LIMIT 1 over contract_events, the quiet-contract reverse-scan trap that contract_active_ledgers exists to prevent. That cost ~0.34s idle,
but the contract page fires five reads at once and the other four
return via stale-while-revalidate while spawning background
refreshes, so the inline WASM read was starved to its full 8s
request deadline. 23 of 25 cold random contract pages breached the
1s budget on this single call, and it also starved sibling panels
into intermittent 503s. The probe is now bounded to the contract's
own recent active ledgers (0.008s measured on r1, ~40x), and an
empty active-ledger walk answers authoritatively without touching contract_events at all. New scripts/ops/contract-page-audit.py
measures the whole page the way a browser loads it — concurrently,
scored on the SLOWEST panel — because the per-endpoint harness
reported every one of these reads as passing.
SECURITY (live surface): a captured passkey sign-in was an
unlimited, never-expiring session mint.POST /v1/auth/passkey/finish-login accepted a replay of the same
ceremony cookie + assertion body indefinitely: the ceremony carried
no server-side expiry, and nothing marked a challenge used. The
expiry was believed to be covered — the guard was written — but
go-webauthn only stamps SessionData.Expires when Config.Timeouts.<ceremony>.Enforce is true and that field defaults
FALSE, so Expires was always the zero time and the check was dead
code. The only bound was the cookie's Max-Age, which is a browser
hint an attacker's HTTP client ignores. Two fixes: the timeouts are
now configured (5 minutes, enforced) and an unstamped ceremony is
refused rather than treated as eternal; and each challenge is now
SINGLE-USE, spent through a Redis-SETNX guard (passkey:ceremony:*,
the same mechanism the SEP-10 replay guard uses — F-1224) after the
assertion verifies and before any session is minted. The guard fails
CLOSED: if the store is unreachable the sign-in is refused (500)
rather than granted on trust, and email-code sign-in is unaffected.
Redis-less deployments fall back to an in-process spent-set
(single-instance accounting, warned at boot). Note for reviewers of
the old behaviour: the sign-counter clone check was NOT a backstop
here — go-webauthn deliberately exempts counter 0, which is what
Apple/iCloud passkeys report forever. Regression tests drive the
real ceremony end-to-end against a software authenticator, including
a mint-then-replay.
SECURITY (live surface): passkey sign-in never asked for or
required user verification, making passwordless sign-in
possession-only — whoever held the authenticator was the account, no
biometric or PIN involved. AuthenticatorSelection was unset and
neither begin call passed a user-verification requirement, so the
library's shouldVerifyUser was false, the UV bit was never
checked, and the options JSON omitted the field entirely (browsers
then applied their own default). Both ceremonies now require user
verification. Trade-off, taken deliberately: a security key with no
PIN configured can no longer be enrolled or used as a first factor.
A passkey label with 34+ multi-byte characters 500'd instead of
saving. The name was truncated by BYTES while the storage CHECK
counts CHARACTERS, so a CJK label was cut mid-rune, and Postgres
rejects invalid UTF-8 — after the authenticator had already burned a
resident-credential slot for a credential the server then never
stored. Truncation is now by runes.
"Body too large" was unreachable on four auth endpoints
(/v1/auth/login, /v1/auth/verify-code, both passkey finish
routes): io.ReadAll(io.LimitReader(…)) returns a nil error at its
cap, so an oversize body was silently TRUNCATED and then surfaced as
a confusing parse error. All four now use http.MaxBytesReader, the
pattern the rest of the repo already follows.
/v1/accounts/{g}/positions runs its six protocol folds in
parallel (sub-second audit's last warm breach, 1.99s): the folds
are independent Postgres reads and were executed serially, so the
endpoint's latency was their sum rather than their max. Output is
byte-identical — each fold writes its own slot and the results plus
coverage notes merge in the original fixed order. Fixing this also
required making the shared per-request asset resolver
concurrency-safe: it memoises into a plain map, and concurrent map
writes are a FATAL runtime throw no recover() catches, so the
parallel folds would have crashed the process under load.
Fixed
Protocol pages keep their bespoke visual suite when the battery
misses its budget (§2.6b grounding incident): the detail VIEW has
been prewarmed + stale-served since 2026-07-31, but the bespoke block
INSIDE it had no cache of its own — it is built last, so it inherited
whatever was left of the rebuild's 90s budget, and when that ran out
(protocol bespoke build failed … context deadline exceeded) the
block was dropped and, on a key with no healthy entry yet, the
suite-less view was cached and stamped fresh. The block now has a
last-good cache with a detached, single-flighted, gate-classed
(protocol_bespoke, its own served-tier gate — these are Postgres
queries, not lake scans) refresh: a build serves the previous block
instantly and never blocks, only a true first-ever miss computes
inline (bounded by its caller's context, with the compute surviving
it so the next build lands warm), and a failed or starved refresh
keeps the last good block. A block older than 45 minutes (≈3 prewarm
sweeps) is still served but reported: analytics.status gains a stale value, distinct from unavailable, and such a build now
counts as COMPLETE for cache displacement instead of being pinned out
as degraded.
/v1/network/throughput is prewarmed and snapshot-served: the
/network page's daily series is a FINAL scan over up to a year of stellar.ledgers with three argMax columns, and it ran inline on the
8s request budget — so a cold or loaded first load lost the panel
(the "no operations in 24h" half of the same incident) and, because
the scan died with the request, no retry could land warm. It now
rides the established SWR shape (5-minute TTL matching the API's
5-minute prewarm loop, detached single-flight refresh under the network_throughput gate class, stale entries served with flags.stale + their real as_of). ONE entry holds the maximum
365-day window and every request slices its tail, which also collapses
the key space: an unauthenticated caller walking ?window_days=1..365
previously bought 365 distinct year-class scans. partial is now
decided at serve time, so a cached series that crosses UTC midnight
no longer advertises a complete day as still accumulating.
Fixed
Explorer: absent data no longer renders as a factual zero
(frontend-honesty sweep, follow-on to the CCTP / roster / /network
incident in docs/operations/v1-launch-plan.md §2.6b). A whole class
of surfaces coalesced a MISSING value — an expensive aggregate the API
honestly omitted on a budget miss, a 503 from an 8s query ceiling, a
build-time transport blip — into ?? 0 / ?? [], then published the
result as an empirical claim about the chain. Absent now renders —
or an explicit "unavailable" affordance; a served zero is still
rendered as 0 / "no X", which is the entire point of the
distinction. Fixed:
/dexes/{source} + /exchanges/{name}: a /v1/markets 503 claimed
"No pools/pairs found in the last 14 days" (and "0 on this page").
/exchanges: the CEX pair board is a Promise.all over four venue
fetches — one 503 headlined "0 CEX pairs · No CEX pairs reporting".
/dexes, /oracles, /aggregators: a failed /v1/sources read
claimed Stellar has no DEXes / no oracles / no aggregators.
/issuers/{g}, /issuers long-tail shell, and the issuer panel on
every asset page: /v1/issuers/{g} SOFT-FAILS its per-asset fan-out
(error or deadline), so absent assets was baking "Assets 0",
"Total observations 0", "Issued assets (0)" and "No issued assets
observed" for issuers with live assets. Unknown first/last-seen
ledgers also rendered as #0, a ledger that cannot exist.
/assets/{slug} liquidity tab: a bespoke fetcher swallowed 5xx,
429 and its own timeout into [], baking "No DEX pools observed
touching {code}" into the static export.
/assets/{slug} supply tab: a failed /v1/chart asserted "No
market-cap history for this asset".
/external/assets/{slug}: any transport failure baked the flat
denial "We don't track an external asset with the slug X"; only an
authoritative 4xx may say that now.
/lending/{pool}: an empty listing (what the API serves when no
lending reader is wired) baked "Auctions (total): 0".
/sources/{name}: a null market read baked "0 pairs · No markets
observed for this source".
/status: an unreachable latency backend rendered "0.0 ms" in green
(a perfect-SLO claim from a missing measurement) and a failed
freshness probe rendered "0 / 0" active sources.
Each fix ships a render test asserting BOTH directions — absent → —/unavailable, served zero → 0/"no X".