mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
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:
parent
5a3ce462cc
commit
60634d3aef
16 changed files with 310 additions and 59 deletions
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue