Skip to content

v1.5.0

Choose a tag to compare

@github-actions github-actions released this 11 Jul 20:34
· 90 commits to main since this release

Minor release — interface coverage ([limits]/[anomaly]/[plugins].kv_* config), plugin-KV admin endpoints, PG-wire protocol benchmarks, and security hardening (stored-XSS fix in the admin dashboard). Feature-additive with one default-behavior change (plugin kv_set is now bounded — see Changed).

Added

  • [limits] config section — eleven operational safety bounds (ten
    formerly-named consts plus the previously-inline 30s backend read timeout)
    that were hardcoded in src/server.rs are now tunable via proxy.toml. Each
    default reproduces the prior constant exactly, resolved once at startup, so a
    config without a [limits] block is byte-for-byte unchanged. Keys (default):
    max_cancel_keys (100000), startup_timeout_secs (30),
    backend_write_timeout_secs (30), backend_read_timeout_secs (30),
    client_write_timeout_secs (60), reprepare_timeout_secs (15),
    max_prepared_statements (8192), max_prepared_bytes (67108864 / 64 MiB),
    max_pending_bytes (67108864 / 64 MiB),
    max_total_idle_backend_conns (8192, pool-modes), and
    pool_reap_interval_secs (30). validate() rejects a 0 for any of these
    (a safety bound, not "unbounded") with a key-named error, and caps every
    *_secs timeout at one year (31536000) — a value above that would overflow
    the connect-time Instant + Duration deadline and panic the per-connection
    task, so it is refused up front.
  • [anomaly] config section — the in-process anomaly detector previously
    ran on a hardcoded AnomalyConfig::default() plus a MAX_SEEN_FINGERPRINTS
    module const with no way to tune it. Its eight tunables are now exposed via
    proxy.toml, defaults reproducing the prior behavior exactly: rate_window_secs
    (60), spike_z_threshold (3.0), auth_window_secs (60), auth_critical_count
    (10), auth_warning_count (5), event_buffer_size (1024), emit_novel_queries
    (true), and max_seen_fingerprints (100000). validate() rejects degenerate
    values (windows/buffer/fingerprint-cap > 0, spike_z_threshold finite and
    > 0, auth_critical_count >= 1, auth_warning_count <= auth_critical_count).
    The detector is built once at startup, so changing [anomaly] requires a
    restart (a SIGHUP reload does not rebuild it).
  • /admin/kv/<plugin>/<key> admin endpoints — the per-plugin KV store
    (KvBackend, read by plugins through their kv_get/kv_set host imports)
    can now be read, written, listed, and deleted from outside the WASM sandbox,
    so operators can push a plugin's runtime config (budgets, region maps, mask
    rules, allowlists) without a restart. GET /admin/kv/<plugin>/<key> returns
    {"plugin","key","value"} (404 if absent), GET /admin/kv/<plugin>/
    (trailing slash) lists the namespace as {"plugin","keys":[...]},
    PUT sets a value (UTF-8 body via from_utf8_lossy), and DELETE removes one
    (idempotent 200). A trailing-slash list accepts an optional ?prefix= filter;
    any query string is stripped before the plugin/key split, so ?… never leaks
    into a stored key, and an empty <plugin> segment (/admin/kv//<key>) is
    rejected 400. All four sit behind the normal admin bearer gate; the build
    returns 501 without --features wasm-plugins and 503 when no plugin
    manager is attached. Four [plugins] caps bound writes and are tunable
    (0 = unlimited): kv_max_value_bytes (default 65536, now bounds a single
    key OR value), kv_max_keys_per_plugin (default 1024), kv_max_plugins
    (default 256, bounds how many <plugin> namespaces can exist so a token-holder
    cannot exhaust memory by writing to unboundedly-many namespace names), and
    kv_max_total_bytes (default 67108864 / 64 MiB) — a total-footprint backstop
    that sums each entry's key + value bytes plus each live namespace's name bytes
    and keeps the whole store within a survivable ceiling regardless of the
    per-axis product (which could otherwise retain tens of GiB). A PUT
    past a cap returns 413 (and the in-WASM kv_set returns -1); an oversized
    body is rejected before it is copied. Overwriting an existing key never trips
    the key-count cap, writing to an existing namespace never trips the namespace
    cap, and deleting a namespace's last key frees its slot (the reclaimed bytes
    are subtracted from the total-footprint counter too).
    Keys must not contain ?: a query string is stripped before the plugin/key
    split (so ?prefix= can filter a listing), which means a plugin-created key
    containing ? is listable but not addressable via GET/DELETE over the admin
    surface.
  • benches/protocol.rs — a Criterion benchmark covering the PG-wire
    per-query hot path that every client frame and backend response flows
    through, previously uncovered by the pool/routing benches (so a regression
    there was invisible to quality gate 3). Three groups —
    protocol/decode_message, protocol/encode_message, and
    protocol/query_text — each run over three payload sizes (a trivial
    SELECT 1, a ~60-char WHERE query, and a deterministically-built ~1 KiB
    IN (...) statement) with Throughput::Bytes so a regression surfaces as
    both a per-call delta and a bytes/sec change. Feature-free: it exercises only
    the always-public protocol API, so it compiles under every feature set.

Changed

  • Plugin kv_set is now bounded (was unbounded in 1.4.0). The per-plugin KV
    store went from an infallible unbounded write to a capped one. The in-WASM
    kv_set import keeps its i32 ABI — 0 on success, -1 when the write is
    refused (a cap breach now joins the internal-error case in returning -1);
    the internal store set() method that backs it changed from returning () to
    returning a bool (false = refused). The new [plugins] caps default to
    kv_max_value_bytes 65536,
    kv_max_keys_per_plugin 1024, kv_max_plugins 256, and kv_max_total_bytes
    67108864 (64 MiB). Upgrade impact: a plugin deployed under 1.4.0 that
    stored values larger than 64 KiB, or more than 1024 keys in its namespace,
    will silently start receiving -1 from kv_set after upgrade — writes past a
    cap fail instead of succeeding. Setting any kv_* cap to 0 disables that
    cap and restores the unbounded 1.4.0 behavior for that axis (set all four to
    0 for byte-for-byte 1.4.0 semantics).
  • CI now lints test code (cargo clippy --tests) — both clippy invocations
    in .github/workflows/ci.yml gained --tests, so #[cfg(test)] modules and
    the tests/ integration crate are held to the same -D warnings bar as the
    library. Pre-existing clippy::field_reassign_with_default warnings in test
    code (converted let mut x = T::default(); x.field = …; sequences into
    struct-literal T { field: …, ..Default::default() } initializers, behavior
    unchanged) were cleared so the gate starts green.
  • Rewrote docs/transaction-replay.md and docs/topology-providers.md against
    the current code.
    Both documents were conceptually dated. The TR deep dive is now
    grounded in src/transaction_journal.rs, src/failover_replay.rs,
    src/failover_controller.rs, src/switchover_buffer.rs, src/replay/mod.rs, and the
    tr_enabled / tr_mode / write_timeout_secs keys — correcting invented keys
    (tr_max_journal_bytes, switchover_drain_timeout_secs never existed), the
    write_timeout_secs default (30, not 15), the default tr_mode (session), the
    text-format (not binary) replay parameter path, and the fact that session-state/cursor
    migration exists only as unwired library modules (src/cursor_restore.rs,
    src/session_migrate.rs) not reachable from the replay path, while
    FailoverController/PrimaryTracker are library components rather than
    daemon-wired. The topology doc now separates the daemon's
    static-role-plus-health primary tracking (surfaced at /topology) from the
    TopologyProvider library abstraction, and notes the PostgreSQL provider is constructed
    programmatically (not from [[nodes]]). Each doc carries a "last verified against"
    commit line.

Fixed

  • /healthz, /livez, /readyz admin routes — these three
    Kubernetes-style probe paths were already token-exempt in the admin auth gate
    but had no handler, so they fell through to the catch-all and returned 404.
    They now route to the same handlers as their slash-form twins (/healthz
    /health, /livez/health/live, /readyz/health/ready), returning
    byte-for-byte identical responses. Because they are token-exempt, orchestrators
    can use /livez and /readyz for unauthenticated liveness/readiness probes
    even when admin_token is set (the slash-form /health/live and
    /health/ready remain token-gated, unchanged).
  • Embedded admin dashboard usable with admin_token set — v1.4.0 made
    token-gating the recommended posture, but the embedded web UI
    (src/admin_ui.html, served at / and /ui) sent no Authorization
    header on any of its fetch() calls, so with a token set every panel
    401ed — the secure configuration broke the dashboard. The UI now wraps
    window.fetch once to inject Authorization: Bearer <token> (from the tab's
    sessionStorage, key helios_admin_token) into every request; on a 401
    it prompts once per page load for the token and reloads. A token button
    in the header bar clears the saved token so a wrong one can be re-entered.
    The static shell (GET /, /ui) is now token-exempt so the page can load
    and prompt — it carries no privileged data, and every API call it makes is
    still individually gated. Without a token, behavior is unchanged (no prompt).

Security

  • Stored XSS in the embedded admin dashboard (present in 1.4.0 — prioritize
    this upgrade).
    The admin web UI (src/admin_ui.html, served at / and
    /ui) interpolated backend- and attacker-derived strings into innerHTML
    with only a partial <-escape. A crafted SQL query whose text flowed into an
    anomaly fingerprint (or sql_excerpt) — surfaced on the /anomalies panel
    — could therefore inject script that runs in an authenticated operator's
    dashboard, an admin-API-takeover vector. The same gap affected the other
    anomaly fields (tenant, user, client_ip, each patterns_matched
    entry) and the node (address), plugin (name/version/hooks/state/
    error), edge (edge_id/region/error), and topology (currentPrimary)
    strings rendered into innerHTML. Every backend/attacker-derived string
    interpolated into an innerHTML template now passes through a single esc()
    HTML-escape helper; numeric/boolean/hardcoded values and textContent sinks
    were already safe and are unchanged. This path shipped live in the published
    1.4.0, so 1.4.0 operators should upgrade.

Full Changelog: v1.4.0...v1.5.0