From 0b93d4a7d5f40dd656659a71f641f91f6a0acfa9 Mon Sep 17 00:00:00 2001 From: KeyCode17 Date: Tue, 19 May 2026 00:20:36 +0700 Subject: [PATCH] fix(server): cache hits preserve harvester user-agent `PxSolveDispatcher::solve` and `RoutingDispatcher::solve` were constructing the `PxCookieBundle` with the literal string `"px-harvester"` as its `user_agent` field. The non-cached response path masked this because the body field was sourced from `outcome.user_agent` (the real browser UA), but the cache stored the bundle with the placeholder. On the next request for the same target the cache-hit branch in `solve.rs` reads `bundle.user_agent.clone()` into `SolveOutput.user_agent`, so the JSON body returned `"user_agent": "px-harvester"` instead of the harvester's real UA. Downstream clients that use the returned UA to replay requests (so the TLS ClientHello and the `User-Agent` header look consistent to PX/CF) were getting silently mis-routed: TLS said Firefox, header said `px-harvester`, server flagged it. Concretely this surfaced as a 403 from `pedidosya.com.ar/v4/shoplist/vendors` on the second and later solve calls while the first call worked. Fix: derive `user_agent` from `outcome.user_agent` once, then thread the same value into both the bundle and the `SolveOutput`. Adds a regression test (`bundle_user_agent_matches_harvester`) that exercises the dispatcher and asserts both the response UA and the bundle UA equal the harvester's UA. Co-Authored-By: Claude Opus 4.7 --- px-server/src/application/routing.rs | 16 ++++++++++++++-- px-server/src/application/solve_endpoint.rs | 5 +++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/px-server/src/application/routing.rs b/px-server/src/application/routing.rs index e24f41d..8b32de1 100644 --- a/px-server/src/application/routing.rs +++ b/px-server/src/application/routing.rs @@ -71,15 +71,16 @@ impl SolveDispatcher for RoutingDispatcher { outcome.status ))); } + let user_agent = outcome.user_agent.clone().unwrap_or_default(); let bundle = PxCookieBundle::new( outcome.cookies.set.clone(), - "px-harvester", + user_agent.clone(), SystemTime::now(), Duration::from_secs(600), ); Ok(SolveOutput { bundle, - user_agent: outcome.user_agent.unwrap_or_default(), + user_agent, solve_ms: outcome.metrics.solve_ms, cache_hit: false, handler: outcome.handler, @@ -163,6 +164,17 @@ mod tests { assert_eq!(out.handler, "cloudflare"); } + /// Regression: the cookie bundle's `user_agent` must carry the real + /// harvester UA (so cache-hit replies preserve it), not a literal + /// placeholder string. + #[tokio::test] + async fn bundle_user_agent_matches_harvester() { + let d = RoutingDispatcher::new(Arc::new(StaticHandler { name: "perimeterx" })); + let out = d.solve("https://example.com/").await.expect("solve"); + assert_eq!(out.user_agent, "ua"); + assert_eq!(out.bundle.user_agent, "ua"); + } + #[test] fn parse_csv_trims_and_lowercases() { let r = parse_camoufox_domains(Some(" Pedidosya.com.AR , ,foo.com ")); diff --git a/px-server/src/application/solve_endpoint.rs b/px-server/src/application/solve_endpoint.rs index 92a13e0..c12906a 100644 --- a/px-server/src/application/solve_endpoint.rs +++ b/px-server/src/application/solve_endpoint.rs @@ -46,15 +46,16 @@ impl SolveDispatcher for PxSolveDispatcher { self.handler_name, outcome.status ))); } + let user_agent = outcome.user_agent.clone().unwrap_or_default(); let bundle = PxCookieBundle::new( outcome.cookies.set.clone(), - "px-harvester", + user_agent.clone(), SystemTime::now(), Duration::from_secs(600), ); Ok(SolveOutput { bundle, - user_agent: outcome.user_agent.unwrap_or_default(), + user_agent, solve_ms: outcome.metrics.solve_ms, cache_hit: false, handler: outcome.handler,