fix: address final review (path traversal, sync races, MCP limits)

Findings from a Codex (gpt-6.1-sol, xhigh) review of v0.2.4..HEAD, each
verified in the code before fixing:

- high: ticker symbols reached cache file paths unchecked, so a symbol
  like `../../REPORT` read (and deleted, if unparseable) .json files
  outside the cache, reachable through MCP get_quotes. resolve_symbol now
  accepts only ticker characters, and cache keys are encoded as single
  safe path components.
- high: sync decided before downloading and replaced the database
  without rechecking; an import that landed meanwhile was erased. It now
  rechecks the local state before installing, and idx-mcp runs
  ownership_sync exclusively with ownership reads.
- replaces_sqlite_sha256 never downgrades history: the client requires
  the rebuild to keep at least the local release count, and the
  publisher lists a replaced build only then.
- cache writes are atomic (temp file + rename), so concurrent readers
  never see, and delete, a partial entry.
- idx-mcp caps symbols (20, deduplicated) and limits, and runs at most 4
  tool calls at once.
- each Yahoo client gets its own cookie jar, paired with its crumb.
- ZIP entries (XLSX, KSEI archives) are read with a 256 MiB bound;
  non-ASCII archive dates and huge percentages are parse errors instead
  of panics or overflow.
- agent-smoke --keep deletes every copied agent config, not only auth
  files.

CLI goldens unchanged; the MCP tool golden changes only to state the
new caps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
hermes 2026-10-14 21:58:37 +00:00
commit 60634d3aef
16 changed files with 310 additions and 59 deletions

View file

@ -8,3 +8,27 @@ pub mod search;
pub mod snapshot;
pub mod types;
pub mod xlsx;
use std::io::Read;
use crate::error::IdxError;
/// Largest decompressed ZIP entry read into memory. Real IDX worksheets and
/// KSEI TXT archives are tens of MB; this bounds a hostile "zip bomb".
const MAX_ZIP_ENTRY_BYTES: u64 = 256 * 1024 * 1024;
/// Reads one decompressed ZIP entry as UTF-8, failing past the size limit.
pub(crate) fn read_zip_entry_bounded(entry: impl Read, what: &str) -> Result<String, IdxError> {
let mut bytes = Vec::new();
entry
.take(MAX_ZIP_ENTRY_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|e| IdxError::ParseError(format!("failed to read {what}: {e}")))?;
if bytes.len() as u64 > MAX_ZIP_ENTRY_BYTES {
return Err(IdxError::ParseError(format!(
"{what} expands beyond {MAX_ZIP_ENTRY_BYTES} bytes"
)));
}
String::from_utf8(bytes)
.map_err(|e| IdxError::ParseError(format!("failed to decode {what}: {e}")))
}