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;

View file

@ -136,7 +136,14 @@ impl Cache {
};
let raw = serde_json::to_string_pretty(&entry)
.map_err(|e| IdxError::ParseError(e.to_string()))?;
fs::write(path, raw).map_err(|e| IdxError::Io(e.to_string()))
// Write then rename, so a concurrent reader never sees a partial
// entry (and deletes it as corrupt).
let temp = path.with_extension(format!("json.{}.tmp", fastrand::u64(..)));
fs::write(&temp, raw).map_err(|e| IdxError::Io(e.to_string()))?;
fs::rename(&temp, &path).map_err(|e| {
let _ = fs::remove_file(&temp);
IdxError::Io(e.to_string())
})
}
pub fn info(&self) -> Result<CacheInfo, IdxError> {
@ -244,10 +251,30 @@ impl Cache {
}
fn entry_path(&self, data_type: &str, symbol: &str) -> PathBuf {
self.root.join(data_type).join(format!("{symbol}.json"))
self.root
.join(safe_file_name(data_type))
.join(format!("{}.json", safe_file_name(symbol)))
}
}
/// Keys become single path components: anything that could separate or
/// climb directories is percent-encoded. Keys made of letters, digits, and
/// `._-:^=` (every key idx produces) are unchanged.
fn safe_file_name(key: &str) -> String {
let mut out = String::with_capacity(key.len());
for (i, byte) in key.bytes().enumerate() {
let safe = byte.is_ascii_alphanumeric()
|| matches!(byte, b'_' | b'-' | b':' | b'^' | b'=')
|| (byte == b'.' && i > 0);
if safe {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
fn is_expired(fetched_at: DateTime<Utc>, ttl_secs: u64) -> bool {
let ttl =
chrono::Duration::from_std(Duration::from_secs(ttl_secs)).unwrap_or(chrono::Duration::MAX);
@ -275,7 +302,7 @@ mod tests {
use serde::{Deserialize, Serialize};
use super::{Cache, CacheEntry};
use super::{Cache, CacheEntry, safe_file_name};
static TMP_COUNTER: AtomicUsize = AtomicUsize::new(0);
@ -377,4 +404,22 @@ mod tests {
// The corrupted file should be deleted
assert!(!path.exists(), "corrupted file should be deleted");
}
#[test]
fn cache_keys_cannot_leave_the_cache_root() {
assert_eq!(safe_file_name("BBCA.JK-3mo-1d"), "BBCA.JK-3mo-1d");
assert_eq!(
safe_file_name("top-performers:id:50"),
"top-performers:id:50"
);
assert_eq!(safe_file_name("../../REPORT"), "%2E.%2F..%2FREPORT");
assert_eq!(safe_file_name(".."), "%2E.");
let cache = Cache::at(std::path::PathBuf::from("/cache/idx"));
let path = cache.entry_path("msn-quote", "../../REPORT");
assert_eq!(
path.parent().unwrap(),
std::path::Path::new("/cache/idx/msn-quote")
);
}
}

View file

@ -1,5 +1,5 @@
use std::fs;
use std::io::{Cursor, Read};
use std::io::Cursor;
use std::path::Path;
use chrono::NaiveDate;
@ -177,11 +177,7 @@ fn extract_txt_from_zip(path: &Path) -> Result<String, IdxError> {
continue;
}
let mut output = String::new();
file.read_to_string(&mut output).map_err(|e| {
IdxError::ParseError(format!("failed to decode KSEI archive TXT entry: {e}"))
})?;
return Ok(output);
return super::read_zip_entry_bounded(&mut file, "KSEI archive TXT entry");
}
Err(IdxError::ParseError(
@ -201,7 +197,8 @@ fn validate_header(header: &str) -> Result<(), IdxError> {
fn parse_archive_date(raw: &str) -> Result<NaiveDate, IdxError> {
let trimmed = raw.trim();
if trimmed.len() != 11 {
// Fixed byte offsets below; non-ASCII input would split a character.
if trimmed.len() != 11 || !trimmed.is_ascii() {
return Err(IdxError::ParseError(format!(
"invalid KSEI archive date `{trimmed}`"
)));
@ -357,4 +354,14 @@ mod tests {
assert!(pdf_local_cp.total_shares <= archive_local_cp.total_shares);
assert!(pdf_local_cp.percentage_bps <= archive_local_cp.percentage_bps);
}
#[test]
fn non_ascii_archive_dates_are_parse_errors_not_panics() {
// 11 bytes, but byte offsets would split the 'é'.
assert!(super::parse_archive_date("1éJan-2026").is_err());
assert_eq!(
super::parse_archive_date("27-FEB-2026").unwrap(),
chrono::NaiveDate::from_ymd_opt(2026, 2, 27).unwrap()
);
}
}

View file

@ -81,7 +81,10 @@ pub fn parse_id_percentage(s: &str) -> Result<i64, IdxError> {
IdxError::ParseError(format!("failed to parse Indonesian percentage '{s}': {e}"))
})?;
Ok((whole_i * 100) + frac_i)
whole_i
.checked_mul(100)
.and_then(|bps| bps.checked_add(frac_i))
.ok_or_else(|| IdxError::ParseError(format!("Indonesian percentage '{s}' is out of range")))
}
/// Parse KSEI date format to NaiveDate.
@ -453,4 +456,10 @@ mod tests {
assert!(normalize_ksei_row(&raw).is_err());
}
#[test]
fn huge_percentages_are_parse_errors_not_overflow() {
assert!(super::parse_id_percentage("9223372036854775807,99").is_err());
assert_eq!(super::parse_id_percentage("54,94").unwrap(), 5494);
}
}

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}")))
}

View file

@ -202,6 +202,14 @@ pub fn sync_snapshot(
let install_result = (|| -> Result<(), IdxError> {
validate_snapshot_db(&temp_path, &manifest.snapshot)?;
// The decision was made before the download; an import or edit that
// landed since must not be overwritten without --force.
if !force && !same_local_state(local_state.as_ref(), inspect_local_db(db_path)?.as_ref()) {
return Err(IdxError::DatabaseError(
"the local ownership database changed during sync; rerun `idx ownership sync`"
.to_string(),
));
}
install_snapshot_file(&temp_path, db_path)
})();
@ -362,6 +370,21 @@ fn inspect_local_db(db_path: &Path) -> Result<Option<LocalSnapshotState>, IdxErr
}))
}
fn same_local_state(
before: Option<&LocalSnapshotState>,
after: Option<&LocalSnapshotState>,
) -> bool {
match (before, after) {
(None, None) => true,
(Some(before), Some(after)) => {
before.file_sha256.is_some()
&& before.file_sha256 == after.file_sha256
&& before.release_count == after.release_count
}
_ => false,
}
}
/// Moves committed WAL frames into the database file, so its hash covers local
/// edits too. Returns false when changes remain in the WAL.
fn checkpoint_wal(db_path: &Path) -> bool {
@ -446,10 +469,12 @@ fn build_sync_decision(
};
}
if snapshot
.replaces_sqlite_sha256
.iter()
.any(|sha256| file_sha256.is_some_and(|local| sha256.eq_ignore_ascii_case(local)))
// A rebuild never silently drops history the local database has.
if snapshot.release_count >= local.release_count
&& snapshot
.replaces_sqlite_sha256
.iter()
.any(|sha256| file_sha256.is_some_and(|local| sha256.eq_ignore_ascii_case(local)))
{
return SyncDecision {
action: OwnershipSyncAction::Updated,
@ -909,6 +934,20 @@ mod tests {
assert!(!decision.should_download);
}
#[test]
fn build_sync_decision_never_drops_history_through_a_rebuild() {
// A forced same-date rebuild with fewer releases lists the old build;
// a client holding it keeps its history instead of downgrading.
let earlier = "e".repeat(64);
let mut snapshot = same_date_snapshot(vec![earlier.clone()]);
snapshot.release_count = 1;
let mut local = same_release_local(&snapshot, &earlier);
local.release_count = 3;
let decision = build_sync_decision(Some(&local), &snapshot, false);
assert!(!decision.should_download, "{}", decision.reason);
}
#[test]
fn build_sync_decision_keeps_a_locally_modified_database() {
// Same release metadata but a file matching no published build (local

View file

@ -9,7 +9,7 @@
use std::collections::HashMap;
use std::fs;
use std::io::{Cursor, Read};
use std::io::Cursor;
use std::path::Path;
use chrono::{Duration, NaiveDate};
@ -83,11 +83,7 @@ fn read_zip_entry(
)));
}
};
let mut output = String::new();
file.read_to_string(&mut output).map_err(|e| {
IdxError::ParseError(format!("failed to decode ownership XLSX entry {name}: {e}"))
})?;
Ok(Some(output))
super::read_zip_entry_bounded(&mut file, &format!("ownership XLSX entry {name}")).map(Some)
}
fn first_worksheet_name(zip: &mut zip::ZipArchive<Cursor<&[u8]>>) -> Result<String, IdxError> {