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

@ -163,6 +163,19 @@ pub fn resolve_symbol(symbol: &str, exchange: &str) -> Result<String, IdxError>
"ticker symbol cannot be empty".into(),
));
}
// Tickers name cache files and provider URLs: letters, digits, and the
// separators real symbols use (BBCA.JK, BRK-B, ^JKSE), nothing else.
if trimmed.len() > 32
|| trimmed.contains("..")
|| !trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '^' | '='))
{
return Err(IdxError::InvalidInput(format!(
"invalid ticker symbol '{}'",
symbol.trim()
)));
}
if let Some((base, suffix)) = trimmed.rsplit_once('.')
&& !base.is_empty()
&& !suffix.is_empty()
@ -301,6 +314,26 @@ mod tests {
assert_eq!(resolve_symbol("BBCA", "JK").unwrap(), "BBCA.JK");
}
#[test]
fn rejects_symbols_that_are_not_tickers() {
for bad in [
"../../REPORT",
"a/b",
"a\\b",
"BB CA",
"BBCA..JK",
"BBCA\u{0}",
&"A".repeat(40),
] {
assert!(
matches!(resolve_symbol(bad, "JK"), Err(IdxError::InvalidInput(_))),
"{bad:?} accepted"
);
}
assert_eq!(resolve_symbol("^jkse", "JK").unwrap(), "^JKSE.JK");
assert_eq!(resolve_symbol("BRK-B.US", "JK").unwrap(), "BRK-B.US");
}
#[test]
fn selected_provider_exposes_expected_capabilities_for_msn() {
let provider = build_selected_provider(ProviderKind::Msn, false, false);

View file

@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::process::Stdio;
use std::sync::Mutex;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use crate::api::types::{Interval, Period};
@ -19,6 +20,9 @@ const CRUMB_FETCH_URL: &str = "https://query1.finance.yahoo.com/v1/test/getcrumb
pub(super) struct YahooClient {
agent: ureq::Agent,
crumb: Mutex<Option<String>>,
/// One jar per client, paired with its crumb: clients in the same process
/// (concurrent MCP calls) must not overwrite each other's cookies.
cookie_jar: PathBuf,
}
impl YahooClient {
@ -29,9 +33,16 @@ impl YahooClient {
.build()
.into();
static NEXT_JAR: AtomicU64 = AtomicU64::new(0);
let cookie_jar = std::env::temp_dir().join(format!(
"idx_yf_{}_{}.txt",
std::process::id(),
NEXT_JAR.fetch_add(1, Ordering::Relaxed)
));
Self {
agent,
crumb: Mutex::new(None),
cookie_jar,
}
}
@ -49,10 +60,6 @@ impl YahooClient {
)
}
fn cookie_jar_path() -> PathBuf {
std::env::temp_dir().join(format!("idx_yf_{}.txt", std::process::id()))
}
pub(super) fn parse_crumb_body(raw: &str) -> Result<String, IdxError> {
let crumb = raw.trim();
if crumb.is_empty() {
@ -123,7 +130,7 @@ impl YahooClient {
fn fetch_crumb_via_curl(&self) -> Result<String, IdxError> {
let binary = curl_impersonate::chrome_curl_binary()?;
let cookie_jar = Self::cookie_jar_path();
let cookie_jar = &self.cookie_jar;
let cookie_jar_str = cookie_jar.to_str().ok_or_else(|| {
IdxError::Io(format!(
"failed to encode Yahoo cookie jar path {}",
@ -235,7 +242,7 @@ impl YahooClient {
) -> Result<QuoteSummaryResponse, IdxError> {
for auth_attempt in 0..2 {
let crumb = self.get_or_init_crumb()?;
let cookie_header = match Self::cookie_header_from_jar(&Self::cookie_jar_path()) {
let cookie_header = match Self::cookie_header_from_jar(&self.cookie_jar) {
Ok(header) => header,
Err(err) => {
runtime::warn(format!("failed to parse Yahoo cookie jar: {err}"));
@ -284,6 +291,12 @@ fn jitter() -> Duration {
Duration::from_millis(fastrand::u64(0..100))
}
impl Drop for YahooClient {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.cookie_jar);
}
}
#[cfg(test)]
mod tests {
use super::YahooClient;