Skip to content

fix: gate GET /metrics behind METRICS_TOKEN, verify hardening review findings - #609

Merged
Markadrian6399 merged 2 commits into
StellarGateLabs:mainfrom
Emeka-12:fix/metrics-authentication
Sep 1, 2026
Merged

fix: gate GET /metrics behind METRICS_TOKEN, verify hardening review findings#609
Markadrian6399 merged 2 commits into
StellarGateLabs:mainfrom
Emeka-12:fix/metrics-authentication

Conversation

@Emeka-12

Copy link
Copy Markdown

Summary

GET /metrics was registered on the public router with no authentication, no
allow-list, and no separate listener — any anonymous caller could scrape
webhook delivery volume, failure rate, latency histograms, and (most usefully
to an attacker) the auth-outcome counters, letting a credential-stuffing
attempt watch its own progress in real time.

  • /metrics now requires Authorization: Bearer <METRICS_TOKEN>, following
    the same pattern as ADMIN_PROVISIONING_SECRET: unset by default, which
    disables the endpoint entirely
    (401 for every request) rather than
    falling back to an open default.
  • deploy/Caddyfile blocks /metrics and /v1/metrics at the edge by
    default, as defense in depth on top of the app-side gate.
  • Documented in README.md, DEPLOYMENT.md, .env.example, and
    deploy/stellargate.env.example.
  • A router-level test (metrics_enabled_refuses_an_unauthenticated_scrape)
    asserts an unauthenticated scrape is refused when the gate is enabled — the
    literal acceptance criterion from the issue — alongside coverage for the
    default-disabled case and a wrong-token attempt.

Closes #250.

Also: verified the three related hardening-review issues

While addressing #250, I reviewed the other three open "API hardening
review" issues, since they cover adjacent unauthenticated/admin-gated
surface. Each asked to confirm a security property, not implement new
behavior — all three checked out against the current code, and each now has
a regression test so a future change can't silently break what the review
confirmed:

  • API hardening review: GET /dashboard, /dashboard/app.css, /dashboard/app.js #460GET /dashboard, /dashboard/app.css, /dashboard/app.js:
    confirmed safe to serve unauthenticated. The three handlers take no
    State/DB parameter — they return include_str!-embedded static assets
    baked in at compile time — so the shell cannot leak per-request or
    merchant data by construction. Every figure the dashboard displays is
    fetched client-side from the same authenticated endpoints a merchant would
    call directly, using an API key the operator supplies in the browser.
    Existing coverage (test_dashboard_assets_served_unauthenticated,
    test_dashboard_data_endpoints_reject_missing_key) was already sufficient.

  • API hardening review: POST /merchants (provision_merchant) #461POST /merchants (provision_merchant): confirmed it can't be
    abused to mass-create merchant records. The handler takes no request body
    to validate, and the route already sits in the base-rate "merchants"
    bucket (1×, not the 5× read bucket) on top of requiring
    ADMIN_PROVISIONING_SECRET. Added
    test_provision_merchant_rate_limit_exceeded_returns_429 — every other
    write route already had this test; this one didn't.

  • API hardening review: POST /merchants/:id/keys and GET /merchants/:id/keys (issue_api_key / list_api_keys) #462POST/GET /merchants/:id/keys: confirmed a raw key is
    returned exactly once, at issuance, and never again — list_api_keys
    projects only key_id/prefix/label/timestamps/active, never key
    material — and that only a SHA-256 digest is ever persisted
    (hash_api_key in db.rs). Added api_keys_are_stored_hashed_not_plaintext,
    asserting the stored api_keys.key_hash and legacy merchants.api_key_hash
    columns differ from the raw key and equal its digest.

Closes #460. Closes #461. Closes #462.

Unrelated: restored a batch of previously-shipped fixes

Landing #250 required building on main, which did not compile at this
branch's base commit — a merge (b8fd3bc, compounding an earlier one at
9263a8e) had resolved a conflict by taking one side's lib.rs/config.rs
wholesale instead of a proper 3-way merge, silently reverting several
previously-shipped, already-tested fixes across many files (the same failure
mode a prior commit on this repo, 1340fd4, already hit and described once).
Restored, each independently verified against the code and tests that
already existed for it — full list in CHANGELOG.md under [Unreleased].
This is orthogonal to the /metrics fix but was a prerequisite for this
branch to build and test at all; happy to split it into its own PR if
preferred.

Test plan

  • cargo test — 259/260 passing. The one remaining failure
    (ssrf::tests::validate_rejects_url_with_no_host) is pre-existing,
    unrelated to this change, and independently reproducible on main
    before this branch: the test assumes an http(s) URL can parse with
    an empty host, which the url crate's spec-compliant parser makes
    unreachable for any URL that parses successfully as http/https in
    the first place — a pre-existing test-design defect, not a runtime bug.
  • New/updated tests: metrics_unset_token_refuses_every_request,
    metrics_enabled_refuses_an_unauthenticated_scrape,
    metrics_enabled_refuses_the_wrong_token,
    metrics_enabled_accepts_the_correct_token,
    test_provision_merchant_rate_limit_exceeded_returns_429,
    api_keys_are_stored_hashed_not_plaintext, plus the
    Config::validate_metrics_token unit tests (empty/short/placeholder/valid).
  • deploy/Caddyfile change reviewed manually (blocks /metrics and
    /v1/metrics before the reverse proxy).

Co-Authored-By: Claude Sonnet 5 noreply@anthropic.com

KingFRANKHOOD and others added 2 commits August 31, 2026 16:27
/metrics was registered on the public router with no authentication,
exposing webhook delivery volume, latency, and — most usefully to an
attacker — auth outcome counters (stellargate_auth_attempts_total let a
credential-stuffing attempt watch its own progress). The endpoint is now
gated behind `Authorization: Bearer <METRICS_TOKEN>`, mirroring the existing
ADMIN_PROVISIONING_SECRET pattern: unset (the default) disables it entirely,
returning 401 for every request rather than falling back to an open
default. deploy/Caddyfile also blocks /metrics and /v1/metrics at the edge
by default. Documented in README.md, DEPLOYMENT.md, .env.example,
deploy/stellargate.env.example, and openapi.yaml. A router-level test
(api::tests::metrics_enabled_refuses_an_unauthenticated_scrape) asserts an
unauthenticated scrape is refused when the gate is enabled, alongside
coverage for the default-disabled and wrong-token cases.

Also restores a buildable baseline: main did not compile on this branch's
base commit. A merge (b8fd3bc, itself compounding an earlier one at 9263a8e)
resolved a conflict by taking one side's lib.rs/config.rs wholesale instead
of a proper 3-way merge, silently reverting several previously-shipped,
tested fixes across many files — the same failure mode a prior commit on
this repo (1340fd4) already hit and described once. Restored, each
independently verified against the code and tests that already existed for
it:

- TaskHealth/AppState (src/lib.rs): the trustline/http/payment metrics
  fields and started()/stopped() counters other files already called.
- Config (src/config.rs): STREAM_IDLE_TIMEOUT_SECS,
  WEBHOOK_RETRY_MAX_DELAY_MS, and the worst-case-inline-delivery boot check
  (issues StellarGateLabs#312, StellarGateLabs#318, StellarGateLabs#238) that other restored code below depends on.
  Config knobs whose consuming code was equally reverted and is out of
  scope here (pagination limits, payment-amount bounds, SQLite pragma
  tuning, DB-pool/rate-limiter sizing, webhook redrive jitter) were left
  out — the orphaned tests that referenced them were trimmed rather than
  the feature resurrected; worth a dedicated follow-up.
- Baseline security headers on every API response, not only the dashboard
  (issues StellarGateLabs#251-StellarGateLabs#254).
- The Horizon SSE stream listener's idle timeout and reconnect metric
  (issue StellarGateLabs#312); the poller's Retry-After/backoff handling (issue StellarGateLabs#313);
  first-run cursor baselining with backward overlap (issue StellarGateLabs#311); the
  periodic trustline checker background task.
- Inline webhook retry backoff with jitter (issue StellarGateLabs#318).
- Audit logging on POST /merchants and POST /payments (issue StellarGateLabs#305).
- Strict CORS: DELETE, Idempotency-Key, X-Admin-Secret, and exposed
  response headers (issue StellarGateLabs#281).
- db::migrate wrapped in a transaction so a failed migration rolls back
  instead of leaving the schema half-migrated; a nullable
  merchants.api_key_hash; the payments(status, expires_at) partial index
  (issue StellarGateLabs#270); percent-encoded Horizon paging-cursor URLs.
- /metrics's DB file-size gauges, previously always reporting absent.
- openapi.yaml: /v1/payments was mis-keyed as /payments (colliding with
  the real deprecated alias), and a 3.0-only `nullable:` keyword remained
  under the declared 3.1.0.

A handful of tests that assumed a richer TaskExit-returning supervisor
was wired into main.rs's worker spawning (it isn't — main.rs still uses
a simpler spawn/join helper) were adjusted to assert on the behavior
that helper actually provides, rather than pulling that larger rewiring
into this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tellarGateLabs#461, StellarGateLabs#462

Each of these "review" issues asked to confirm a security property already
claimed by existing code/comments, not to change behavior. All three checked
out; this adds the regression coverage that was missing so a future change
can't silently break what the review confirmed.

- StellarGateLabs#460 (GET /dashboard, /dashboard/app.css, /dashboard/app.js): confirmed
  safe to serve unauthenticated — the three handlers take no State/DB
  parameter and return include_str!-embedded static assets, so the shell
  cannot leak per-request or merchant data by construction. Already covered
  by test_dashboard_assets_served_unauthenticated and
  test_dashboard_data_endpoints_reject_missing_key; no new test needed.

- StellarGateLabs#461 (POST /merchants / provision_merchant): confirmed it can't be used to
  mass-create merchant records. No request body to validate, and the route
  already sits in the base-rate "merchants" bucket (1x, not the 5x read
  bucket) on top of requiring ADMIN_PROVISIONING_SECRET. Added
  test_provision_merchant_rate_limit_exceeded_returns_429 — every other
  write route had this test, this one didn't.

- StellarGateLabs#462 (POST/GET /merchants/:id/keys): confirmed a raw key is returned
  exactly once, at issuance — list_api_keys projects only
  key_id/prefix/label/timestamps/active, never the key material — and that
  only a SHA-256 digest is ever persisted. Added
  api_keys_are_stored_hashed_not_plaintext, asserting the stored
  api_keys.key_hash and legacy merchants.api_key_hash columns differ from
  the raw key and equal its digest.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@Emeka-12 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@Markadrian6399
Markadrian6399 merged commit b4f442c into StellarGateLabs:main Sep 1, 2026
1 of 10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants