Skip to content

Stellar Index v0.33.0

Choose a tag to compare

@github-actions github-actions released this 13 Aug 15:34
· 169 commits to main since this release

[v0.33.0] — 2026-08-13

Security

  • 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".