Skip to content

[MEDIUM] Ingest pipeline resource exhaustion — memory/CPU exhaustion under adversarial load #98

Description

@austin-barrington

Summary

The ingest pipeline has no per-request point count cap, unbounded in-memory caches, and a busy-spin backpressure mechanism that can saturate CPU under WAL stalls. An attacker with valid credentials can exhaust server memory or CPU.

Findings covered: #9 (capacity amplification), #10 (no point cap + unbounded caches), #11 (busy-spin backpressure)

#9 — Line-protocol capacity amplification

parse_line_body_to_points in application/line_protocol.rs:24:

let cap = body.iter().filter(|&&b| b == b'\n').count().max(1);

Capacity is based on newline count, but blank lines yield zero points. A body of \n characters pre-allocates a large Vec<Point> while producing zero valid points. Severity is Low (bounded by the 25 MiB body limit, and it's a pre-allocation hint only).

#10 — No per-request point cap + unbounded caches

No admission limit: The only HTTP-layer gate is the 25 MiB DefaultBodyLimit. A single write request can contain millions of points with no server-side throttling.

Unbounded caches in native adapter (adapters/chdb/native_adapter.rs:175-189, 386-399):

  • schemas: HashMap<TableKey, TableSchema> — grows without bound
  • series_schemas: HashMap<TableKey, TableSchema> — grows without bound
  • known_series: HashMap<TableKey, HashSet<u64>> — grows without bound

These maps have no eviction policy, no LRU, no size cap. High-cardinality workloads or adversarial writes inflate them indefinitely.

Cardinality limits (max_tag_values_per_measurement, max_measurements_per_database) bound distinct tags/measurements but not total rows per request.

#11 — Enqueue busy-spin backpressure

BatchingWal::enqueue in adapters/wal/batching_wal.rs:153-164:

loop {
    match self.sender.try_send(req) {
        Ok(()) => break,
        Err(TrySendError::Full(pending)) => {
            tokio::task::yield_now().await;  // spin, don't park
            req = pending;
        }
        Err(TrySendError::Disconnected(_)) => { ... }
    }
}

When the channel is full (default depth: 64), callers busy-spin with yield_now() — no backoff, no sleep, no timeout. Under sustained WAL backpressure (slow disk, writer thread stall), every concurrent writer consumes CPU competing for tokio executor slots. This amplifies any WAL stall across all tokio workers.

Recommended Fix

  1. Per-request point cap: Reject writes exceeding a configurable max points per request at the HTTP handler level.
  2. Bounded caches: Replace HashMap with lru::LruCache for schemas, series_schemas, and known_series with configurable max sizes.
  3. Backpressure: Replace try_send + yield_now with tokio::sync::mpsc::Sender::send().await (async-aware, parks when full), or add tokio::time::sleep backoff + timeout returning 503.

Confidence

High. All three sub-findings verified against the source.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecuritySecurity vulnerability or hardening

    Type

    No type

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions