Skip to content

fix(security): close 6 network-reachable DoS/path-traversal vectors (Phase 1) - #69

Merged
xerj-org merged 2 commits into
mainfrom
dac/security-fixes
Jul 29, 2026
Merged

fix(security): close 6 network-reachable DoS/path-traversal vectors (Phase 1)#69
xerj-org merged 2 commits into
mainfrom
dac/security-fixes

Conversation

@therandomsecurityguy

Copy link
Copy Markdown
Collaborator

Summary

Phase 1 of a security remediation plan: closes 6 Critical/High, network-reachable findings from a security review of the engine's network-facing surface. All were single-request, network-reachable, and previously bypassed every other defense (process abort or arbitrary directory deletion before any query/ingest admission check fired).

No config defaults or TLS/binding posture changed — those stay for a Phase 2 PR.

Findings fixed

ID Severity One-liner
F1 Critical query_string unbounded paren recursion → stack-overflow process abort
F2 Critical IndexName accepts ./.. → snapshot-restore remove_dir_alls parent of data_dir
F6 High more_like_this fields × like cross-product → unbounded alloc → OOM abort
F7 High max_fields_per_index (500) NOT enforced on ingest path — mapping explosion
F8 High Bulk NDJSON parse-phase memory amplification (~8–15× body) before admission
F9 High Snapshot repo_path/name unvalidated → writes outside the repo

Changes by file

engine/crates/xerj-common/src/types.rs — F2

  • IndexName::validate now rejects ., .., path separators (/, \, NUL), and any .. substring. The leading-dot carve-out for .kibana system indices is preserved, but a doubled .. anywhere is blocked (mirrors the /_memory/{ns} validator).
  • 8 new regression tests for path-traversal vectors.

engine/crates/xerj-query/src/parser.rs — F1, F6

  • F1: parse_qs_unary's LParen arm now enters DepthGuard::enter() before recursing into parse_qs_or. On overflow it returns None, which try_lower_query_string turns into a graceful fallback to the opaque QueryNode::QueryString path (iterative tokenizer, no recursion). Also caps query_string.query length at 64 KiB as defense-in-depth.
  • F6: parse_more_like_this caps fields at 64, like at 1024, cross-product at 4096; rejects oversized with a 400-style parse error before allocating.
  • 6 new regression tests (deep-paren no-overflow, length cap, moderate nesting still parses, MLT cross-product cap, MLT fields cap, MLT within caps).

engine/crates/xerj-engine/src/index.rs — F2, F7

  • F2: Index::create_with_settings gets a debug_assert! containment guard.
  • F7: New per-Index max_fields_per_index snapshot (from config.limits). Both evolve_schema_from_doc and evolve_schema_from_docs now check field_count() + new_fields.len() against the limit before adding fields, with a re-check under the write lock for concurrency.

engine/crates/xerj-engine/src/engine.rs — F2, F9

  • F2: restore_snapshot now validates idx_name with IndexName::new before any remove_dir_all, adds a lexical containment check on dst_dir before the delete, and a canonicalize-and-contain check after create_dir_all (catches symlink escapes).
  • F9: New validate_snapshot_path helper rejects ../separators/NUL in the snapshot name, and verifies the canonicalized snap_dir stays inside the canonicalized repo_path. Called by both create_snapshot and restore_snapshot.

engine/crates/xerj-api/src/es_compat.rs — F9

  • New validate_snapshot_repo_name / validate_snapshot_name helpers applied to put_snapshot_repo, create_snapshot, and restore_snapshot handlers (reject ../separators/NUL at the API boundary).

engine/crates/xerj-engine/src/bulk.rs — F8

  • Acquires a global bulk-concurrency permit at the top of process_bulk_with_opts (caps concurrent bulks in the parse phase).
  • Enforces max_actions_per_bulk (default 50 000) — rejects oversized bulks with a 413-style error item before allocating the parse-phase Vecs.

engine/crates/xerj-engine/src/governor.rs — F8

  • New bulk_pool semaphore (default 8 concurrent bulks) on ResourceGovernor, with acquire_bulk() / max_concurrent_bulks() accessors and a max_concurrent_bulks field on GovernorSnapshot.

engine/crates/xerj-common/src/config.rs — F8

  • New limits.max_actions_per_bulk setting (default 50 000; 0 disables).

engine/xerj.default.toml — F8

  • Documents the new max_actions_per_bulk setting.

Verification

  • Builds: cargo build --release -p xerj-common, -p xerj-query, -p xerj-engine, -p xerj-api — all clean (scoped, never workspace-wide, no cargo clean).
  • Unit tests: xerj-common 31/31, xerj-query 137/137 (6 new regression tests), xerj-engine 226/226, xerj-api 65/65 — all pass.
  • Hard gate: ES-YAML conformance 1360 passed / 0 failed / 3 skipped — unchanged from baseline.

Test plan

  • cargo test --release -p xerj-common --lib — 31 passed
  • cargo test --release -p xerj-query --lib — 137 passed (6 new)
  • cargo test --release -p xerj-engine --lib — 226 passed
  • cargo test --release -p xerj-api --lib — 65 passed
  • ES-YAML conformance suite (199 files) — 1360/0/3
  • Reviewer: spot-check that .kibana and .security-* system indices still pass IndexName::validate (covered by existing index_name_valid test)
  • Reviewer: confirm no existing ES-YAML suite sends a bulk > 50 000 actions (conformance run confirms none do)

Out of scope (Phase 2)

The remaining Medium/High findings from the review (default-secure TLS/binding posture, plaintext gRPC, plaintext minted-key at-rest storage, role_descriptors not enforced, cluster Raft auth, console cookie/WebAuthn hardening, symlink escape in autoindex, neural model integrity check, dashboard security headers) are deferred to a Phase 2 PR per the agreed scope.

…Phase 1)

Six Critical/High findings from a security review of the engine's
network-facing surface. All are single-request, network-reachable, and
previously bypassed every other defense (they triggered process abort
or arbitrary directory deletion before any query/ingest admission check
fired). No config defaults or TLS/binding posture changed (those stay
for a Phase 2 PR).

F1 — query_string stack-overflow process abort (Critical)
  Root cause: the Lucene-style query_string parser is a recursive-
  descent parser over nested parens. The thread-local DepthGuard
  (parser.rs:53) wraps parse_query but the parse_qs_* family never
  re-enters parse_query, so the depth counter stayed at 1 regardless
  of paren-nesting depth. A ~200 KB body of nested parens overflowed
  the tokio worker stack (default 2 MiB) and the process aborted with
  SIGSEGV — not catchable by catch_unwind.
  Fix: parse_qs_unary's LParen arm now enters DepthGuard::enter()
  before recursing into parse_qs_or. On overflow it returns None,
  which try_lower_query_string turns into a graceful fallback to the
  opaque QueryNode::QueryString path (iterative tokenizer, no
  recursion). Also caps query_string.query length at 64 KiB as
  defense-in-depth before tokenizing.
  Files: engine/crates/xerj-query/src/parser.rs:1099, 1645

F2 — IndexName accepts "."/".." -> parent-of-data_dir deletion (Critical)
  Root cause: IndexName::validate carved out a leading '.' for system
  indices (.kibana) and allowed '.' in the body, which let the literal
  names "." and ".." through. PUT /.. wrote WAL/segments/schema into
  the parent of data_dir; DELETE /.. then remove_dir_all'd the
  parent. In restore_snapshot, IndexName::new(idx_name) ran AFTER
  remove_dir_all(data_dir.join(idx_name)), so a manifest carrying
  ".." deleted the parent of data_dir before validation.
  Fix: IndexName::validate now rejects "."/".."/separators/".."
  substrings. restore_snapshot validates idx_name BEFORE any
  filesystem op and adds a canonicalize-and-contain check on dst_dir.
  Index::create_with_settings gets a debug_assert containment guard.
  Files: engine/crates/xerj-common/src/types.rs:109-145,
         engine/crates/xerj-engine/src/engine.rs:1721-1790,
         engine/crates/xerj-engine/src/index.rs:2077

F6 — more_like_this cross-product OOM abort (High)
  Root cause: parse_more_like_this built
  Vec::with_capacity(fields.len() * like.len()) with neither fields
  nor like capped. A ~200 KB body with 10 000 fields x 10 000 like-
  texts forced a 10^8-entry Vec (~10 GiB) and jemalloc aborted on OOM.
  Fix: caps fields at 64, like at 1 024, cross-product at 4 096;
  rejects oversized with a 400-style parse error before allocating.
  Files: engine/crates/xerj-query/src/parser.rs:3068

F7 — max_fields_per_index not enforced on ingest path (High)
  Root cause: ManagedSchema::apply_document enforced the 500-field
  cap, but the production dynamic-mapping path (evolve_schema_from_doc
  / evolve_schema_from_docs) called Schema::add_field directly,
  bypassing the check. apply_document was only called from tests.
  An authenticated client could ingest documents with arbitrarily many
  distinct field names, bloating the schema unbounded.
  Fix: both evolve_schema_from_doc(s) now check field_count() +
  new_fields.len() against self.max_fields_per_index (a new
  per-Index snapshot of config.limits.max_fields_per_index) before
  adding fields, with a re-check under the write lock for concurrency.
  Files: engine/crates/xerj-engine/src/index.rs:1763, 2227, 2507,
         12718, 12801

F8 — bulk NDJSON parse-phase memory amplification (High)
  Root cause: the bulk body (bounded at 100 MiB) was materialized into
  4 Vecs (lines, pairs, parse_results, parsed) totalling ~8-15x the body
  size before any memtable admission check. A 100 MiB body of one-byte
  lines produced ~50 M lines -> ~25 M action pairs x ~300 B = ~7.5 GiB of
  heap per request. No per-bulk action cap and no per-bulk concurrency
  limit existed.
  Fix: adds limits.max_actions_per_bulk (default 50 000; 0 disables).
  Rejects oversized bulks with a 413-style error item before allocating
  the parse-phase Vecs. Adds a global bulk_pool semaphore to the
  ResourceGovernor (default 8 concurrent bulks) acquired at the top of
  process_bulk_with_opts, bounding the total parse-phase heap.
  Files: engine/crates/xerj-common/src/config.rs:892, 965
         engine/crates/xerj-engine/src/bulk.rs:267
         engine/crates/xerj-engine/src/governor.rs:85, 211, 512

F9 — snapshot repo_path/name path traversal (High)
  Root cause: PUT /_snapshot/{repo} stored an arbitrary
  settings.location as repo_path. create_snapshot/restore_snapshot
  did Path::new(repo_path).join(name) with no validation of name,
  so a snapshot name = ".." made snap_dir = repo_path/.., writing
  manifest.json and index data into the parent of the repo.
  Fix: adds validate_snapshot_repo_name / validate_snapshot_name
  at the API layer (reject ".."/separators/NUL), and
  validate_snapshot_path in the engine (canonicalize-and-contain check
  on snap_dir).
  Files: engine/crates/xerj-api/src/es_compat.rs:20874-21051,
         engine/crates/xerj-engine/src/engine.rs:1562, 1653, 1838

Verification:
  - Builds: cargo build --release -p xerj-common, -p xerj-query,
    -p xerj-engine, -p xerj-api — all clean.
  - Unit tests: xerj-common 31/31, xerj-query 137/137 (6 new regression
    tests), xerj-engine 226/226, xerj-api 65/65 — all pass.
  - Hard gate: ES-YAML conformance 1360 passed / 0 failed / 3 skipped
    (unchanged from baseline).
  - New tests: IndexName "."/".."/separator rejection, query_string
    5000-paren no-overflow, query_string 64 KiB cap, MLT cross-product
    cap, MLT fields cap, MLT within-caps still parses.
Apply rustfmt to the three files touched by the Phase 1 security
commit (ed9b0bd) so cargo fmt --check is clean:
  - xerj-api/src/es_compat.rs: split a long || chain
  - xerj-common/src/types.rs: align regression-test comment
  - xerj-engine/src/engine.rs: reflow EngineError::Common(...) nesting

No behavior change; cargo clippy clean across all four touched crates;
unit tests unchanged (xerj-common 31, xerj-query 137, xerj-engine 226,
xerj-api 65).

@xerj-org xerj-org left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: APPROVE

Every claimed finding was independently verified against main before accepting this PR — all six are real, pre-existing, network-reachable defects, and the fix diff is purely additive hardening (no weakened checks, no unrelated changes):

ID Verified on main
F1 parse_qs_unary's LParen arm recursed with no depth guard (DepthGuard existed at parser.rs:53 but was never used there) — stack-overflow abort reachable via one request
F2 IndexName::validate accepted ./.. (leading-dot carve-out + . allowed in body, charset-only check); restore_snapshot ran remove_dir_all(dst_dir) before IndexName::new — arbitrary directory deletion via manifest
F6 Vec::with_capacity(fields.len() * like.len()) at parser.rs:3043, uncapped
F7 Neither evolve_schema_from_doc(s) path checked max_fields_per_index
F8 No action cap or concurrency bound anywhere in bulk.rs
F9 Path::new(repo_path).join(name) raw at engine.rs:1562/1653, snapshot name unvalidated

CI: fmt+clippy, build+test, API smoke, and ES-YAML conformance (1360/0/3) all green — no compat regression from the stricter validation.

Non-blocking notes (fine to land as-is)

  1. Bulk permit scope: _bulk_permit is held for the whole of process_bulk_with_opts, not just the parse phase, so the cap of 8 serializes entire bulk requests end-to-end. Fine as a DoS bound; consider making max_concurrent_bulks configurable (it's hardcoded in governor::build) and/or dropping the permit after parse.
  2. F7 semantics diverge from ES: on limit breach ES rejects the document with illegal_argument_exception; here new fields are silently dropped (doc still indexes, warn-level log only). Acceptable for Phase 1, worth an explicit error in Phase 2.
  3. Lexical starts_with checks in restore are ineffective on their own (data_dir/.. is component-prefixed by data_dir), but the IndexName validation ahead of them is the real gate and the post-create_dir_all canonicalize check covers symlinks — belt-and-suspenders as advertised.
  4. Repo location is still unrestricted (any absolute path an operator names). ES gates this with path.repo; flagged for Phase 2 alongside the listed TLS/auth items.
  5. a..b-style index names are now rejected, which is stricter than ES's own rules; conformance is clean, so no action needed.

Phase 2 scope in the PR body is acknowledged and tracked.

@xerj-org
xerj-org merged commit 48eafef into main Jul 29, 2026
4 checks passed
xerj-org added a commit that referenced this pull request Jul 30, 2026
… security pass

Dogfood XERJ as an AI agent's retrieval substrate for a Rust security audit,
mirroring the WordPress case study. XERJ had no Rust parser, so this adds a
tree-sitter-rust extractor that indexes the engine's own source into three
XERJ indices (functions, call-edges, routes) and audits it from there.

Tooling (docs/examples/rust-ast-audit/):
- rust_ast_index.py: 100% function coverage (5095/5095), 0 parse errors, 197
  files. Emits unsafe ops, panic/abort sites, as-casts, alloc-from-param
  shapes, axum extractors, sinks, validators, lock-across-await + a call graph.
- ingest.py: explicit keyword/text mappings, sent==indexed assertion.
- find_recursion_cycles.py: SCCs of the free-function call graph — the
  stack-overflow-from-nesting shape grep cannot express. Method-call edges
  excluded (unresolvable without type inference; wiring them collapsed the
  graph into one spurious SCC).

Case study (docs/case-studies/xerj-self-audit/):
- Ground-truth recall vs PR #69's six known bugs: the query-parser recursion
  cycle is UNGUARDED pre-#69 and GUARDED on main; evolve_schema_* gains
  reads_config_limit=true after the fix. The signal flips exactly on the fix.
- Confirmed Critical: POST /_sql unauthenticated stack-overflow -> process
  abort (SIGABRT), proven by crashing a live server. Same class as F1, which
  PR #69 fixed in the query_string parser only; the SQL WHERE parser is the
  unfixed sibling. parse_or_expr -> parse_and_expr -> parse_condition ->
  parse_or_expr with no DepthGuard (sql.rs:414/440/466/483).
- Complete unsafe inventory: 22 non-test blocks, all FFI/mmap/documented, all
  sound. Two defense-in-depth gaps logged but NOT claimed (unproven).
- COVERAGE.md states what the substrate cannot see (trait dispatch, macros,
  method-call edges, build.rs).
xerj-org added a commit that referenced this pull request Jul 30, 2026
…flow DoS)

The SQL WHERE parser is a recursion cycle — parse_or_expr → parse_and_expr →
parse_condition → parse_or_expr — driven one turn per '(' (and parse_condition
self-recurses on NOT). It had no depth guard, so `POST /_sql` with a WHERE
clause of ~50k nested parens overflowed the thread stack and aborted the whole
process (SIGABRT, exit 134 — not a catchable panic; every tenant goes down).

This is the same bug class as PR #69's F1, which added a DepthGuard to the
query_string parser (xerj-query) but not to this SQL parser. Found by the
call-graph recursion-cycle query in the Rust self-audit case study
(docs/case-studies/xerj-self-audit); the cycle shows UNGUARDED here vs GUARDED
in the query_string parser after #69.

Fix: thread an explicit `depth` through the three parse fns and reject past
MAX_SQL_DEPTH (64, mirroring xerj-query's MAX_QUERY_DEPTH). Verified: the exact
payload that aborted the unpatched server now returns HTTP 400 ("WHERE clause
nesting exceeds max depth of 64") in ~2ms and the server stays up. 3 regression
tests (nested parens, nested NOT, moderate nesting still parses); full
xerj-engine lib suite green; fmt + clippy clean.
xerj-org added a commit that referenced this pull request Jul 30, 2026
…32x cheaper triage

Adds the missing half of the case study: a quantified vulnerability-detection
comparison against known ground truth, plus the extractor fixes that measurement
forced.

detection_quality.py: a harness that runs, for each of PR #69's six real bugs,
the class-level query an auditor would write (never the bug's own name/line)
against the pre-fix index, and the fairest equivalent grep on the raw tree.
Reports recall, candidate count, rank of the true positive, and the tokens an
agent must read to triage each candidate set.

Results: XERJ recall 6/6 vs grep 4/6; triage 84,122 vs 2,715,003 tokens (32x).
grep cannot find F7/F8 even in principle — both are MISSING code (absent limit
check, absent action cap); `grep max_actions_per_bulk` pre-fix returns 0 lines.

Reported honestly: the FIRST run scored 3/6, worse than grep. The test set
exposed three real extractor defects, each fixed here:
- alloc-arg parsing split on the first ')', truncating `a.len() * b.len()` to
  `a.len(` and losing the multiplication -> balanced-paren extraction.
- taint provenance tracked only direct params; the sizes come from locals
  derived from a param -> one-hop local-derivation tracking.
- validator detection was presence-only, so validation running AFTER the delete
  (F2's exact shape) scored as "guarded", and a generic early `starts_with`
  masked it -> ordering signal `guard_after_destructive_op`, strong path/name
  validators only.
Each signal verified to go QUIET on the patched code, so it discriminates rather
than matches. 6/6 is therefore in-sample; the out-of-sample result is the /_sql
Critical. Precision is stated as the weak axis (TP at rank 6/186, 12/13, 18/27,
19/21): a strong filter, a mediocre ranker.

Sweep of current main returned no new confirmed findings, and one candidate
REFUTED by testing: highlight_text_with_terms allocates
`merged.len() * (pre.len() + post.len())` with user-supplied pre_tags/post_tags —
the F6 shape — but a live 20k-match test at tag sizes to 100 KB returned 200 in
~2 ms with no growth, because the highlighter truncates to a fragment. Bounded,
not a bug; published in the refuted section because a finder without a verifier
would have shipped it.

Website page + card updated with the measured detection-quality chart, the
in-sample caveat, and the refuted candidate.
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.

2 participants