fix(net): bounded request time, curl size and time caps, lock waits, sane prices

From the full-repo Codex audit:
- MSN and Yahoo clients set connect and body timeouts but none for the
  response headers, so a server that stalled before answering hung the
  call; four such calls take every MCP call slot. Add a 30 s overall
  deadline. Every curl-impersonate run gets --max-time 600 and a 256 MiB
  --max-filesize, and output above that is rejected (curl cannot cap a
  response without a length); the Yahoo cookie fetch gets --max-time.
- Ownership commands blocked on the database lock without limit, so a
  long sync or import, or a stuck process, held MCP call slots
  indefinitely. Wait up to 30 s, then fail with a "busy, try again" error.
- A price of 1e20 saturated `as i64` and the change subtraction then
  overflowed. Prices that are not finite, negative, or above 1e12 IDR are
  treated as missing, and a quote's own price as an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
This commit is contained in:
hermes 2026-10-17 04:28:30 +00:00
commit 66207d24b6
6 changed files with 144 additions and 34 deletions

View file

@ -30,6 +30,9 @@ impl YahooClient {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(5)))
.timeout_recv_body(Some(Duration::from_secs(10)))
// A server that stalls before sending headers must not hold a
// call (and an MCP call slot) forever.
.timeout_global(Some(Duration::from_secs(30)))
.build()
.into();
@ -141,7 +144,14 @@ impl YahooClient {
// Step 1: fetch fc.yahoo.com to set A3 cookie (returns 404 but writes cookie jar).
// We allow non-zero exit here since 404 still writes the cookie.
let _ = Command::new(binary)
.args(["--silent", "--cookie-jar", cookie_jar_str, COOKIE_FETCH_URL])
.args([
"--silent",
"--max-time",
"30",
"--cookie-jar",
cookie_jar_str,
COOKIE_FETCH_URL,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.output();

View file

@ -20,8 +20,12 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
.ok_or_else(|| IdxError::SymbolNotFound(symbol.to_string()))?;
let raw_prev_close = meta.previous_close.or(meta.chart_previous_close);
let price = round_price(raw_price);
let prev_close = raw_prev_close.map(round_price);
let price = round_price(raw_price).ok_or_else(|| {
IdxError::ParseError(format!(
"Yahoo sent an implausible price for {symbol}: {raw_price}"
))
})?;
let prev_close = raw_prev_close.and_then(round_price);
let change = prev_close.map(|p| price - p);
let change_pct = raw_prev_close
.filter(|p| *p != 0.0)
@ -50,8 +54,8 @@ pub(super) fn parse_quote(symbol: &str, chart: &ChartResponse) -> Result<Quote,
change_pct,
volume: meta.regular_market_volume,
market_cap: meta.market_cap,
week52_high: meta.fifty_two_week_high.map(round_price),
week52_low: meta.fifty_two_week_low.map(round_price),
week52_high: meta.fifty_two_week_high.and_then(round_price),
week52_low: meta.fifty_two_week_low.and_then(round_price),
week52_position,
range_signal,
prev_close,
@ -103,22 +107,22 @@ pub(super) fn parse_history(
.open
.as_ref()
.and_then(|v| v.get(i).copied().flatten())
.map(round_price);
.and_then(round_price);
let high = quote
.high
.as_ref()
.and_then(|v| v.get(i).copied().flatten())
.map(round_price);
.and_then(round_price);
let low = quote
.low
.as_ref()
.and_then(|v| v.get(i).copied().flatten())
.map(round_price);
.and_then(round_price);
let close = quote
.close
.as_ref()
.and_then(|v| v.get(i).copied().flatten())
.map(round_price);
.and_then(round_price);
let volume = quote
.volume
.as_ref()
@ -202,8 +206,12 @@ pub(super) fn parse_fundamentals(
})
}
fn round_price(value: f64) -> i64 {
value.round() as i64
/// Prices are IDR; anything non-finite, negative, or above this is not a
/// real quote, and treating it as missing keeps arithmetic from overflowing.
const MAX_PRICE: f64 = 1e12;
fn round_price(value: f64) -> Option<i64> {
(value.is_finite() && (0.0..=MAX_PRICE).contains(&value)).then(|| value.round() as i64)
}
pub(super) fn map_yahoo_error(symbol: &str, endpoint: &str, err: &ChartError) -> IdxError {