Four distinct code paths accept attacker-controlled input without an upper bound. Each is independently exploitable to either OOM the process or wedge/crash a server thread from a single connection. Grouped as an epic because all share the same root cause (missing resource limit) and a single hardening sweep can cover them.
1. ef_search parameter has no upper bound — single-query OOM
File: nodedb/src/data/executor/handlers/vector_search.rs:354-361 — effective_ef:
fn effective_ef(ef_search: usize, top_k: usize) -> usize {
if ef_search > 0 {
ef_search.max(top_k) // ← only floor, no ceiling
} else {
top_k.saturating_mul(4).max(64)
}
}
and the HNSW consumer at nodedb-vector/src/hnsw/search.rs:18-48:
pub fn search(&self, query: &[f32], k: usize, ef: usize) -> Vec<SearchResult> {
...
let ef = ef.max(k); // ← only floor again
...
let results = search_layer(self, query, current_ep, ef, 0, None);
ef_search propagates from user SQL (SET ef_search = N), from the protocol struct (nodedb-types/src/protocol.rs:391 pub ef_search: Option<u64>), and from the SQL planner (nodedb-sql/src/planner/select.rs:568, 654 sets ef_search: limit * 2) straight into search_layer, which allocates a BinaryHeap of up to ef candidates plus a HashSet<u32> that grows until the heap is drained.
A single authenticated client issuing SET ef_search = 1_000_000_000 causes immediate multi-GB allocation. Also exploitable via a huge LIMIT because planner/select.rs sets ef_search = limit * 2.
Repo-wide grep for MAX_EF / ef.min returns zero matches — no ceiling exists anywhere.
2. TLS handshake has no deadline — slow-loris wedges connection semaphore across native / RESP / ILP listeners
Files:
Representative pattern (native, listener.rs:120-138):
if let Some(ref acceptor) = tls_acceptor {
let acceptor = acceptor.clone();
connections.spawn(async move {
match acceptor.accept(stream).await { // ← no tokio::time::timeout
Ok(tls_stream) => { /* session.run() */ }
Err(e) => { warn!(...); }
}
drop(permit);
});
}
The accept loop acquires a semaphore permit, spawns a task, then awaits the TLS handshake with no deadline. tokio_rustls::TlsAcceptor::accept only makes progress when the client sends data, so a client who opens TCP, sends 1 byte of ClientHello, and holds pins the permit indefinitely. The session-level idle timeout in native/session.rs:88 only runs after a successful handshake.
N slow clients pin N permits; once the semaphore is drained, every legitimate TLS client is RST'd at accept (listener.rs:102-113 — try_acquire_owned + continue with dropped socket).
All three listeners share the same pattern.
3. ILP plaintext listener reads unbounded line length → OOM from one connection
File: nodedb/src/control/server/ilp_listener.rs:154-200
async fn handle_ilp_connection(stream: ConnStream, peer: SocketAddr, state: &SharedState) -> crate::Result<()> {
...
let reader = BufReader::new(stream);
let mut lines = reader.lines();
...
loop {
tokio::select! {
result = lines.next_line() => {
match result {
Ok(Some(line)) => {
...
batch.push_str(&line);
tokio::io::AsyncBufReadExt::lines grows the returned String until it hits \n. No maximum length.
ILP is plaintext (port 9009 by default, used by telegraf / vector / InfluxDB clients) and per-tenant quota checks happen after the read. An attacker connects, streams a bytes forever without ever sending \n — the String reallocates until OOM. The semaphore permit stays held the entire time; the task never yields to any idle-based cancellation at the line level.
Slow-drip variant (one byte per second) is also effective because there's no per-read deadline.
4. SQL expression parser + resolver have no recursion depth limit → stack overflow DoS
Files:
Grep for MAX_DEPTH / recursion_limit / depth across nodedb-query/src/expr_parse.rs and nodedb-sql/src/resolver/expr.rs returns zero matches. No depth guard anywhere in the pipeline.
A WHERE ((((...((x))...)))) with tens of thousands of parentheses (or a deeply nested generated-column expression) recurses through parse_expr → parse_or → parse_and → parse_comparison → parse_additive → parse_multiplicative → parse_unary → parse_primary (≈ 8 stack frames per (), stack-overflowing the server thread. On Linux with default 8 MB stack that's ~10–20 k parens; on macOS non-main threads (512 KB) it's ~1–2 k.
A single SQL statement from a single authenticated client crashes the thread (and in some handler paths, the node).
Reproduction:
SELECT ( ( ( ( ... x ... ) ) ) ) FROM t; -- 10 000 nested parens
-- or:
CREATE TABLE t (x INT GENERATED ALWAYS AS (( … x … )) STORED);
Checklist
Notes
- Found during a CPU/memory + DoS audit sweep. Each item is independently verifiable; checkboxes let PRs close them one-by-one.
Four distinct code paths accept attacker-controlled input without an upper bound. Each is independently exploitable to either OOM the process or wedge/crash a server thread from a single connection. Grouped as an epic because all share the same root cause (missing resource limit) and a single hardening sweep can cover them.
1.
ef_searchparameter has no upper bound — single-query OOMFile:
nodedb/src/data/executor/handlers/vector_search.rs:354-361—effective_ef:and the HNSW consumer at
nodedb-vector/src/hnsw/search.rs:18-48:ef_searchpropagates from user SQL (SET ef_search = N), from the protocol struct (nodedb-types/src/protocol.rs:391 pub ef_search: Option<u64>), and from the SQL planner (nodedb-sql/src/planner/select.rs:568, 654setsef_search: limit * 2) straight intosearch_layer, which allocates aBinaryHeapof up toefcandidates plus aHashSet<u32>that grows until the heap is drained.A single authenticated client issuing
SET ef_search = 1_000_000_000causes immediate multi-GB allocation. Also exploitable via a hugeLIMITbecauseplanner/select.rssetsef_search = limit * 2.Repo-wide grep for
MAX_EF/ef.minreturns zero matches — no ceiling exists anywhere.2. TLS handshake has no deadline — slow-loris wedges connection semaphore across native / RESP / ILP listeners
Files:
nodedb/src/control/server/listener.rs:120-138(native)nodedb/src/control/server/resp/listener.rs(RESP)nodedb/src/control/server/ilp_listener.rs(ILP)Representative pattern (native, listener.rs:120-138):
The accept loop acquires a semaphore permit, spawns a task, then awaits the TLS handshake with no deadline.
tokio_rustls::TlsAcceptor::acceptonly makes progress when the client sends data, so a client who opens TCP, sends 1 byte of ClientHello, and holds pins the permit indefinitely. The session-level idle timeout innative/session.rs:88only runs after a successful handshake.N slow clients pin N permits; once the semaphore is drained, every legitimate TLS client is RST'd at accept (listener.rs:102-113 —
try_acquire_owned+ continue with dropped socket).All three listeners share the same pattern.
3. ILP plaintext listener reads unbounded line length → OOM from one connection
File:
nodedb/src/control/server/ilp_listener.rs:154-200tokio::io::AsyncBufReadExt::linesgrows the returnedStringuntil it hits\n. No maximum length.ILP is plaintext (port 9009 by default, used by telegraf / vector / InfluxDB clients) and per-tenant quota checks happen after the read. An attacker connects, streams
abytes forever without ever sending\n— theStringreallocates until OOM. The semaphore permit stays held the entire time; the task never yields to any idle-based cancellation at the line level.Slow-drip variant (one byte per second) is also effective because there's no per-read deadline.
4. SQL expression parser + resolver have no recursion depth limit → stack overflow DoS
Files:
nodedb-query/src/expr_parse.rs:199—fn parse_expr→ … →parse_primaryrecurses intoparse_expronLParen.nodedb-sql/src/resolver/expr.rs—convert_expris unconditionally recursive, notablyExpr::Nested(inner) => convert_expr(inner).nodedb-query/src/expr/eval.rs—eval_scoperecurses for every nested node.Grep for
MAX_DEPTH/recursion_limit/depthacrossnodedb-query/src/expr_parse.rsandnodedb-sql/src/resolver/expr.rsreturns zero matches. No depth guard anywhere in the pipeline.A
WHERE ((((...((x))...))))with tens of thousands of parentheses (or a deeply nested generated-column expression) recurses throughparse_expr → parse_or → parse_and → parse_comparison → parse_additive → parse_multiplicative → parse_unary → parse_primary(≈ 8 stack frames per(), stack-overflowing the server thread. On Linux with default 8 MB stack that's ~10–20 k parens; on macOS non-main threads (512 KB) it's ~1–2 k.A single SQL statement from a single authenticated client crashes the thread (and in some handler paths, the node).
Reproduction:
Checklist
ef_searchto a configured max ineffective_efandHnswIndex::search; reject excessive values at the protocol boundary.acceptor.accept(stream)intokio::time::timeout(tls_handshake_timeout, …)for all three listeners (native, RESP, ILP). Also consider a pre-handshake read deadline for the plaintext branches.BufReader::linesinilp_listener.rswith a length-bounded reader (e.g.LinesCodec::new_with_max_length, or manualread_until(b'\n')with a cap).parse_expr,convert_expr, andeval_scope(or convert hot cases to iterative-with-explicit-stack). Return a typed error on exceed.Notes