Skip to content

security: /health exposes raw SIEM webhook error bodies unauthenticated #989

Description

@2witstudios

Summary

The processor's `/health` endpoint is unauthenticated (by design — it's a liveness probe) but now surfaces `siem.sources..lastError` per source. That field is populated directly from the SIEM delivery worker and contains raw webhook response bodies.

Where

  • Writer: `apps/processor/src/services/siem-adapter.ts` around line 260 — `sendWebhook` sets `error: `HTTP ${response.status}: ${errorText}`` where `errorText` is the full `response.text()`.
  • Persister: `apps/processor/src/workers/siem-delivery-worker.ts` writes that string into `siem_delivery_cursors.lastError`.
  • Exposer: `apps/processor/src/server.ts` → `/health` handler returns `siem.sources..lastError` via `buildSiemHealth`. `/health` has no auth middleware.

Why it's a problem

If a customer's SIEM receiver returns a verbose error — internal stack trace, authentication detail (`invalid token: abc123…`), schema info, user IDs, or any debug output — that content is readable by anyone who can reach the processor's health endpoint. In the `cloud` deployment mode where processors share infrastructure across tenants this is a cross-tenant information disclosure surface; even in `onprem` it's a reconnaissance aid for attackers fingerprinting the downstream SIEM.

Not a regression

This was introduced earlier (the pre-Wave-3c `/health` already surfaced `cursor.lastError` for a single source). Wave 3c (#987) preserved the behavior per-source but did not widen or narrow it. Filing now because the review pass flagged it and it deserves its own fix.

Proposed mitigations (pick one or combine)

  1. Sanitize at write time — in `sendWebhook`, store only the HTTP status code and a generic classification (`transport_error`, `http_4xx`, `http_5xx`, `hmac_rejected`), never the raw body. Full bodies still go to processor logs for operators.
  2. Sanitize at read time — in `buildSiemHealth` or `siem-cursor-reader`, replace `lastError` with a boolean `hasError: true` plus an enum classification, dropping the message.
  3. Authenticate `/health` — wrap behind `authenticateService` like the other processor routes. Downside: breaks k8s liveness probes unless they inject the service token.
  4. Two endpoints — keep the current unauthenticated `/health` reporting only status + booleans, add authenticated `/health/detail` for operators who need the full error strings.

My preference: #1 + #4. Scrub raw bodies at the source of truth, and if operators need detail gate it behind auth.

Acceptance criteria

  • `siem.sources..lastError` on unauthenticated `/health` never contains customer-controlled strings (webhook response bodies, header values, syslog server banners, etc.).
  • Operators can still see the raw error during triage (logs, authenticated detail endpoint, or admin API).
  • Tests cover: error classification mapping, and that raw bodies do not appear in the `/health` response even when the cursor row contains them.
  • Security audit log entry explaining the change.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions