fix(security): close 6 network-reachable DoS/path-traversal vectors (Phase 1) - #69
Merged
Conversation
…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
approved these changes
Jul 29, 2026
xerj-org
left a comment
Owner
There was a problem hiding this comment.
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)
- Bulk permit scope:
_bulk_permitis held for the whole ofprocess_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 makingmax_concurrent_bulksconfigurable (it's hardcoded ingovernor::build) and/or dropping the permit after parse. - 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. - Lexical
starts_withchecks in restore are ineffective on their own (data_dir/..is component-prefixed bydata_dir), but theIndexNamevalidation ahead of them is the real gate and the post-create_dir_allcanonicalize check covers symlinks — belt-and-suspenders as advertised. - Repo
locationis still unrestricted (any absolute path an operator names). ES gates this withpath.repo; flagged for Phase 2 alongside the listed TLS/auth items. 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.
This was referenced Jul 30, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
query_stringunbounded paren recursion → stack-overflow process abortIndexNameaccepts./..→ snapshot-restoreremove_dir_alls parent ofdata_dirmore_like_thisfields × likecross-product → unbounded alloc → OOM abortmax_fields_per_index(500) NOT enforced on ingest path — mapping explosionrepo_path/nameunvalidated → writes outside the repoChanges by file
engine/crates/xerj-common/src/types.rs— F2IndexName::validatenow rejects.,.., path separators (/,\, NUL), and any..substring. The leading-dot carve-out for.kibanasystem indices is preserved, but a doubled..anywhere is blocked (mirrors the/_memory/{ns}validator).engine/crates/xerj-query/src/parser.rs— F1, F6parse_qs_unary'sLParenarm now entersDepthGuard::enter()before recursing intoparse_qs_or. On overflow it returnsNone, whichtry_lower_query_stringturns into a graceful fallback to the opaqueQueryNode::QueryStringpath (iterative tokenizer, no recursion). Also capsquery_string.querylength at 64 KiB as defense-in-depth.parse_more_like_thiscapsfieldsat 64,likeat 1024, cross-product at 4096; rejects oversized with a 400-style parse error before allocating.engine/crates/xerj-engine/src/index.rs— F2, F7Index::create_with_settingsgets adebug_assert!containment guard.max_fields_per_indexsnapshot (fromconfig.limits). Bothevolve_schema_from_docandevolve_schema_from_docsnow checkfield_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, F9restore_snapshotnow validatesidx_namewithIndexName::newbefore anyremove_dir_all, adds a lexical containment check ondst_dirbefore the delete, and a canonicalize-and-contain check aftercreate_dir_all(catches symlink escapes).validate_snapshot_pathhelper rejects../separators/NUL in the snapshotname, and verifies the canonicalizedsnap_dirstays inside the canonicalizedrepo_path. Called by bothcreate_snapshotandrestore_snapshot.engine/crates/xerj-api/src/es_compat.rs— F9validate_snapshot_repo_name/validate_snapshot_namehelpers applied toput_snapshot_repo,create_snapshot, andrestore_snapshothandlers (reject../separators/NUL at the API boundary).engine/crates/xerj-engine/src/bulk.rs— F8process_bulk_with_opts(caps concurrent bulks in the parse phase).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— F8bulk_poolsemaphore (default 8 concurrent bulks) onResourceGovernor, withacquire_bulk()/max_concurrent_bulks()accessors and amax_concurrent_bulksfield onGovernorSnapshot.engine/crates/xerj-common/src/config.rs— F8limits.max_actions_per_bulksetting (default 50 000; 0 disables).engine/xerj.default.toml— F8max_actions_per_bulksetting.Verification
cargo build --release -p xerj-common,-p xerj-query,-p xerj-engine,-p xerj-api— all clean (scoped, never workspace-wide, nocargo clean).Test plan
cargo test --release -p xerj-common --lib— 31 passedcargo test --release -p xerj-query --lib— 137 passed (6 new)cargo test --release -p xerj-engine --lib— 226 passedcargo test --release -p xerj-api --lib— 65 passed.kibanaand.security-*system indices still passIndexName::validate(covered by existingindex_name_validtest)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.