Skip to content

harden(console): SSRF/CSRF/headers/supply-chain hardening for the credential-holding web UI (part of #369)#607

Merged
ELares merged 1 commit into
mainfrom
harden/369-console-web
Jul 10, 2026
Merged

harden(console): SSRF/CSRF/headers/supply-chain hardening for the credential-holding web UI (part of #369)#607
ELares merged 1 commit into
mainfrom
harden/369-console-web

Conversation

@ELares

@ELares ELares commented Jul 10, 2026

Copy link
Copy Markdown
Owner

What

Hardens the ironcache-console web surface. The console is a monitoring UI that holds a privileged cache credential, proxies a metrics backend, and reaches internal nodes -- the exact pattern behind real monitoring-UI SSRF + account-takeover CVEs. Audited four surfaces; fixed the genuine gaps (much was already safe, verified line-by-line).

1. SSRF / outbound fetch. Already safe (outbound URLs are server-config/discovery only; the /api/timeseries proxy allowlists the metric to a bare ironcache_* name, rejects PromQL/URL smuggling, builds+encodes the query itself and clamps range/step/points; the client refuses redirects + non-http schemes + link-local/metadata IPs incl. IPv4-mapped-IPv6, with size+time caps). FIXED: outbound URLs + seed host:port are now validated at BOOT (fail-fast) rather than first-use.

2. CSRF / session / auth. Session is cookie-free (per-request bearer in tab-scoped storage). FIXED: a same-origin mutation gate (Sec-Fetch-Site first, else Origin authority must equal Host) runs before auth on EVERY non-GET route, closing the loopback-dev token-free mutation vector (DNS-rebinding / cross-site form POST); non-browser clients unaffected, reads not gated. FIXED: a fail-closed login rate limiter (10 failures / 60s) answering 429 before any compare. Constant-time compare + token zeroization/redaction were already present.

3. Security headers. /api/* responses (every outcome incl. 401/403/405/429) now carry CSP default-src 'none'; frame-ancestors 'none', X-Frame-Options: DENY, Referrer-Policy: no-referrer, nosniff, Cache-Control: no-store. The UI already carried a strict self-only CSP.

4. Supply chain. Verified fully-embedded frontend (zero external loads), locked in by a ui_loads_no_external_resources test. Zero new dependencies. Extended the console SECURITY.md with a "Web-surface hardening" section (general deployment posture: private network, VPN-restricted ingress, TLS at the edge, least-privilege monitor ACL user).

Review + verification

Adversarially security-reviewed: NO holes found across all five threat classes (SSRF structurally unbypassable, CSRF gate airtight before auth, constant-time + rate-limited auth with no bypass route, universal headers with no reflected-input injection, no request-reachable panic). Two documented tradeoffs (global-not-per-IP rate limiter = operator-lockout-under-attack, mitigated by VPN posture; prefix-not-enumerated metric allowlist, sound against injection) are called out in SECURITY.md, not holes.

Tests: security-headers (unit + over-TCP), timeseries allowlist rejection of request-supplied url/host/metric, outbound redirect refusal, CSRF cross-site-403/same-origin-accept, login rate-limit 401-then-429, embedded-assets no-external-load; 311 console tests pass; clippy -D warnings + prior-art + fmt clean; no dashes; no company identifiers.

Part of #369.

🤖 Generated with Claude Code

…limit, headers)

The console is a credential-holding monitoring UI: it proxies a metrics
backend (/api/timeseries) and reaches internal cache nodes, the exact
pattern behind real-world dashboard SSRF and account-takeover CVEs. This
audits the four web surfaces and closes the gaps the audit found.

SSRF / outbound fetch (audited; boundary confirmed, then extended):
* Every outbound target (seed nodes, node admin URL, metrics base URL)
  comes only from server config; no request input reaches an outbound
  fetch (/api/nodes/{addr} is a topology lookup, and discovery-derived
  endpoints are displayed, never dialed). The timeseries route stays an
  allowlist (bare ironcache_* metric names, clamped range/step, no raw
  query passthrough), and the outbound client already refuses redirects,
  non-http schemes, and link-local/metadata IPs with bounded time and
  response size.
* New: the outbound URLs and every seed host:port are validated at BOOT
  (config::validate + httpclient::validate_url), failing fast instead of
  erroring on the first query or dial.

CSRF / session / auth:
* New same-origin mutation gate: any state-changing /api/* request a
  browser marks as cross-site (Sec-Fetch-Site, else Origin vs Host) is
  refused 403 before the auth gate, in every posture including the
  loopback dev mode, which serves mutations token-free and was otherwise
  reachable by a hostile page (DNS rebinding / cross-site form POST).
  Non-browser clients are unaffected; reads are not gated (no CORS
  header is ever emitted, so a cross-site reader cannot read anything).
* New failed-auth fixed-window rate limit (10 failures / 60s): invalid
  Bearer presentations are counted and a tripped window answers 429
  before the compare; token-free requests are never throttled. The token
  compare was already constant time; the session model stays cookie-free
  (Bearer header + tab-scoped sessionStorage), so there is no ambient
  credential for a cross-site request to ride.

Headers / browser surface:
* /api/* responses now carry a deny-all CSP (default-src 'none';
  frame-ancestors 'none'), X-Frame-Options: DENY, and Referrer-Policy:
  no-referrer on top of the existing nosniff + Cache-Control: no-store.
  The UI's strict self-only CSP (no unsafe-inline) was already in place
  and is asserted end to end.

Supply chain / docs:
* Verified the frontend is fully embedded (no CDN/external script,
  style, font, image, or fetch) and locked that in with a test; no new
  dependencies anywhere.
* SECURITY.md gains a web-surface hardening section: the gates above,
  the egress allowlist, the cookie-free session model, and the general
  deployment posture (private network, VPN-restricted ingress, TLS at
  the edge, least-privilege monitor credential on the nodes).

Tests: full header set on / and /api/* (unit + over TCP), allowlist
rejection of request-supplied URLs/hosts/metrics, redirect refusal
(existing live-socket test), cross-site-rejected / same-origin-accepted
mutations over TCP, rate-limit 401-then-429 over TCP, plus unit coverage
for the gate rules, the limiter window, and the boot-time validators.

Part of #369.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VFWCZR47TDvsFPJ1waX85X
Signed-off-by: Zeke <ezequiel.lares@outlook.com>
@github-actions

Copy link
Copy Markdown

perf-gate (A5)

Same-runner ratchet of HEAD against the merge-base (both rebuilt and measured in this job).
PASS = within the noise band, WARN = a real move inside budget (does not fail), FAIL = past budget in the bad direction.

metric base head delta% band budget verdict
qps_median (peak) 140519.93 132904.18 -5.42% +/-5.00% drop <= 15% WARN
bytes_per_key int 45.16 45.16 0.00% det rise <= 5% PASS
bytes_per_key embstr 60.19 60.19 0.00% det rise <= 5% PASS
bytes_per_key raw 332.51 332.51 0.00% det rise <= 5% PASS

Overall: WARN

  • qps: noisy on shared CI, so the band comes from the base reps spread (floored at 5%); a drop is only a regression past the 15% budget.
  • bytes_per_key: deterministic (allocator-true memmodel), so a tight 5% rise budget; any rise beyond it FAILs.
  • Open-loop tails / criterion micro-benches are reported-not-failed (tail noise is high) and are not part of this ratchet.
  • An intentional perf trade is landed by raising the relevant budget in this PR with a documented reason (CI never auto-commits a baseline).

@ELares
ELares merged commit 0254270 into main Jul 10, 2026
18 checks passed
ELares added a commit that referenced this pull request Jul 10, 2026
…TOR, ACL) (#610)

A doc-audit found stale claims. Each correction below uses only measured numbers
(c7g, 2026-07-10):

- README.md (perf/competitive): reframed from "Dragonfly leads GET / close the
  remaining gap" to the corrected thread-per-core re-bench. IronCache now LEADS GET
  by about 19% single-endpoint (2.43M vs Dragonfly 2.04M ops/sec) and by roughly 2x
  cluster-aware (4.32M vs 2.19M) once #517 zero-hop routing removes the cross-shard
  hop; the old "leads both" was a benchmark CONFIG artifact (shards oversubscribed
  relative to cores). Kept honest nuance: baseline p99.9 TIES Dragonfly (~15ms), the
  durable-save tail (291ms) is competitive not category-leading, and the decisive
  edges are GET throughput, memory, and determinism. Fixed the two-vCPU section's
  stale "Dragonfly pulls ahead at real core count" reading.

- docs/PRODUCTION_READINESS.md: goal-completion verdict is now MET on GET (#507
  CLOSED, #517 hop-elimination paid off) instead of "unproven pending a re-bench";
  moved console web-surface hardening (#369, PR #607) from the "remain" list to
  "landed"; removed the stale within-3%/re-bench finish-line items; fixed the false
  "README.md:143 claims MONITOR redaction" citation in items 27 (README documents
  SLOWLOG/INFO/logs redaction, and MONITOR is intentionally unimplemented, so there
  is no doc mismatch).

- docs/bench/TAIL_LATENCY.md: corrected the plain-GET "can trail Dragonfly" framing
  to "now LEADS Dragonfly under a proper thread-per-core config"; the durable-load
  tail nuance (already honest) is unchanged.

- docs/design/ACL.md: documented per-subcommand ACL grants (Redis 7 `+cmd|sub`),
  specifically `+slowlog|get` for the least-privilege monitor user (read-only
  SLOWLOG without the `SLOWLOG RESET` wipe), and clarified that the
  CLUSTER_CONTRACT.md "no per-subcommand carve-out" note is narrow.

- docs/research/dragonfly.md: reworded one phrase to remove a stale-deficit-grep
  false positive.

- CHANGELOG.md: annotated the historical 2026-07-03 benchmark entry with the
  corrective re-bench note (record preserved).

Not changed (already current): docs/design/CONFIG.md and docs/design/PERSISTENCE.md
already point the save-throttle (#577) warning at the per-slot Arc-COW (#588) fix
that cut the snapshot tail 3.5s -> 291ms (11.5x).

Docs currency sweep (post competitive re-bench + console completion).

Signed-off-by: Zeke <ezequiel.lares@outlook.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant