Companion to #108, same disclosure note: no SECURITY.md and private reporting is off, so this is public. Fix-first, no recipe.
1. RST .. include:: fan-out is exponential
crates/vera-core/src/parsing/sphinx.rs:52-117 has a depth cap of 16 (:16, :60), a cycle guard (:81), and a genuinely correct containment check at :130-143. What it lacks is a fan-out bound.
The cycle guard tests only the current stack, so a DAG is allowed: file A may include B many times, and each expansion re-runs the recursion independently. include_cache (:84-92) caches raw bytes but never the expansion, and nothing caps the length of output (:64, :111). Output size is O(fanout^depth).
Measured: 9 files, fan-out 3, depth 8, 44 KB on disk, killed at 2m37s holding 2,350,800 KB RSS. Roughly 53,000x amplification, no termination.
Two amplifiers:
std::fs::read at sphinx.rs:87 reads the include target with no size cap, so including a large in-repo file bypasses the 1 MB max_file_size_bytes gate entirely.
preprocess_rst is also on the hashing path (indexing/update.rs:129, via indexing/freshness.rs), so the staleness check makes vera search and vera update hit the same bomb. There is no way to search such a repository without triggering it.
Suggested fix: thread an output-byte budget through resolve_includes_recursive and stop when exceeded, plus a size cap on the fs::read. Caching the expansion rather than the raw bytes would also collapse the DAG case.
2. Downloaded models and the ONNX Runtime library are unverified
ensure_model_file_impl (local_models/assets.rs:359-459) fetches from .../resolve/main/... (:393).
- No revision pinning.
resolve/main tracks a mutable branch, so the content can change under a fixed config.
- No integrity check. The only gate is
validate_file (:254-267): non-empty, regular file, plus a 4 KB protobuf header sniff for ONNX (:269-320) that succeeds on any well-formed graph. There is no sha256/digest/etag handling anywhere under local_models/. Hugging Face returns the sha256 in X-Linked-Etag and it is not consulted.
- The result goes to
Session::commit_from_file (embedding/local_provider.rs:861, retrieval/local_reranker.rs:263), and ONNX protobuf parsing is a known attack surface.
The same applies to the native library: ort.rs:366-417 downloads a release archive and extracts it, and ensure_ort_runtime (ort.rs:20-40) dlopens the result. No checksum. This is not confined to vera setup: ensure_ort_library_for_ep is called lazily from local_provider.rs:301 and local_reranker.rs:23, so an ordinary vera search on a fresh machine downloads and loads unverified native code.
Context rather than a code bug: the default embedding repo is a third-party personal account rather than the model author or onnx-community, which makes account takeover a live path. Pinning to a commit SHA and recording expected digests closes both that and the silent-swap case cheaply.
TLS itself is fine: rustls with ring, and no danger_accept_invalid_certs anywhere.
3. git runs inside the untrusted repo with its config honoured
git_scope.rs:95-118. Argument handling is correct and worth stating: no shell, validate_revision (:44-50) rejects leading -, and -- separators are present. There is no argument injection.
The residual is current_dir(repo_root) at :98 with an unfiltered environment, so git reads the repository's own .git/config. core.fsmonitor names a program git executes during git diff and git ls-files --others, which is exactly what --changed runs at :60-70. safe.directory does not help, since a repo you cloned yourself is owned by you.
Inherited from git rather than introduced here, and cheap to mitigate: prepend -c core.fsmonitor= -c core.hooksPath=/dev/null in run_git.
4. API keys are in the ambient environment for every child process
vera-cli/src/state.rs:208 and :216 copy embedding_api_key and reranker_api_key from credentials.json into the process environment at startup (main.rs:37). Every child inherits them: git in an untrusted repo (item 3), sh -c at commands/setup.rs:356, powershell at ort.rs:1294, and pip install at ort.rs:105, which runs arbitrary package code from PyPI with the user's inference credentials in its environment.
Passing the keys to the providers explicitly instead of via the environment removes this.
vera doctor is clean here: check_env_group (commands/doctor.rs:237-254) reports only a count, never a value.
Also checked, and correct
Model path traversal is properly rejected (assets.rs:461-471, with tests). Archive extraction has no zip-slip or tar-slip. write_private_file (state.rs:255-286) does 0o600, sync_all, re-apply, atomic rename. vera serve binds loopback by default and its bearer comparison is constant-time. SQL is fully parameterized; the only format!-built SQL interpolates integers from config. All eight unsafe blocks are sound.
Verified on master at e3d79b3. Items 1 and 4 were reproduced by execution; 2 and 3 are by code reading.
Companion to #108, same disclosure note: no
SECURITY.mdand private reporting is off, so this is public. Fix-first, no recipe.1. RST
.. include::fan-out is exponentialcrates/vera-core/src/parsing/sphinx.rs:52-117has a depth cap of 16 (:16,:60), a cycle guard (:81), and a genuinely correct containment check at:130-143. What it lacks is a fan-out bound.The cycle guard tests only the current stack, so a DAG is allowed: file A may include B many times, and each expansion re-runs the recursion independently.
include_cache(:84-92) caches raw bytes but never the expansion, and nothing caps the length ofoutput(:64,:111). Output size isO(fanout^depth).Measured: 9 files, fan-out 3, depth 8, 44 KB on disk, killed at 2m37s holding 2,350,800 KB RSS. Roughly 53,000x amplification, no termination.
Two amplifiers:
std::fs::readatsphinx.rs:87reads the include target with no size cap, so including a large in-repo file bypasses the 1 MBmax_file_size_bytesgate entirely.preprocess_rstis also on the hashing path (indexing/update.rs:129, viaindexing/freshness.rs), so the staleness check makesvera searchandvera updatehit the same bomb. There is no way to search such a repository without triggering it.Suggested fix: thread an output-byte budget through
resolve_includes_recursiveand stop when exceeded, plus a size cap on thefs::read. Caching the expansion rather than the raw bytes would also collapse the DAG case.2. Downloaded models and the ONNX Runtime library are unverified
ensure_model_file_impl(local_models/assets.rs:359-459) fetches from.../resolve/main/...(:393).resolve/maintracks a mutable branch, so the content can change under a fixed config.validate_file(:254-267): non-empty, regular file, plus a 4 KB protobuf header sniff for ONNX (:269-320) that succeeds on any well-formed graph. There is no sha256/digest/etag handling anywhere underlocal_models/. Hugging Face returns the sha256 inX-Linked-Etagand it is not consulted.Session::commit_from_file(embedding/local_provider.rs:861,retrieval/local_reranker.rs:263), and ONNX protobuf parsing is a known attack surface.The same applies to the native library:
ort.rs:366-417downloads a release archive and extracts it, andensure_ort_runtime(ort.rs:20-40)dlopens the result. No checksum. This is not confined tovera setup:ensure_ort_library_for_epis called lazily fromlocal_provider.rs:301andlocal_reranker.rs:23, so an ordinaryvera searchon a fresh machine downloads and loads unverified native code.Context rather than a code bug: the default embedding repo is a third-party personal account rather than the model author or
onnx-community, which makes account takeover a live path. Pinning to a commit SHA and recording expected digests closes both that and the silent-swap case cheaply.TLS itself is fine: rustls with ring, and no
danger_accept_invalid_certsanywhere.3.
gitruns inside the untrusted repo with its config honouredgit_scope.rs:95-118. Argument handling is correct and worth stating: no shell,validate_revision(:44-50) rejects leading-, and--separators are present. There is no argument injection.The residual is
current_dir(repo_root)at:98with an unfiltered environment, so git reads the repository's own.git/config.core.fsmonitornames a program git executes duringgit diffandgit ls-files --others, which is exactly what--changedruns at:60-70.safe.directorydoes not help, since a repo you cloned yourself is owned by you.Inherited from git rather than introduced here, and cheap to mitigate: prepend
-c core.fsmonitor= -c core.hooksPath=/dev/nullinrun_git.4. API keys are in the ambient environment for every child process
vera-cli/src/state.rs:208and:216copyembedding_api_keyandreranker_api_keyfromcredentials.jsoninto the process environment at startup (main.rs:37). Every child inherits them:gitin an untrusted repo (item 3),sh -catcommands/setup.rs:356,powershellatort.rs:1294, andpip installatort.rs:105, which runs arbitrary package code from PyPI with the user's inference credentials in its environment.Passing the keys to the providers explicitly instead of via the environment removes this.
vera doctoris clean here:check_env_group(commands/doctor.rs:237-254) reports only a count, never a value.Also checked, and correct
Model path traversal is properly rejected (
assets.rs:461-471, with tests). Archive extraction has no zip-slip or tar-slip.write_private_file(state.rs:255-286) does0o600,sync_all, re-apply, atomic rename.vera servebinds loopback by default and its bearer comparison is constant-time. SQL is fully parameterized; the onlyformat!-built SQL interpolates integers from config. All eightunsafeblocks are sound.Verified on
masterate3d79b3. Items 1 and 4 were reproduced by execution; 2 and 3 are by code reading.