From 09c18117831ea86b0b00044691e2ec31513f3836 Mon Sep 17 00:00:00 2001 From: Valeriy Selitskiy <239034+iamwavecut@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:20:37 +0200 Subject: [PATCH] Follow bounded public page redirects --- crates/openplotva-app/src/serper.rs | 137 +++++++++++++++++++++++++--- prompts/aifarm/system.prompt | 2 +- 2 files changed, 123 insertions(+), 16 deletions(-) diff --git a/crates/openplotva-app/src/serper.rs b/crates/openplotva-app/src/serper.rs index 5199d768..80281cf8 100644 --- a/crates/openplotva-app/src/serper.rs +++ b/crates/openplotva-app/src/serper.rs @@ -21,6 +21,7 @@ const SERPER_RETRY_COUNT: usize = 3; const SERPER_RETRY_BASE_DELAY: StdDuration = StdDuration::from_secs(1); const SERPER_QUERY_MAX_BYTES: usize = 400; const SERPER_CRAWL_MAX_BYTES: usize = 6000; +const CRAWL_MAX_REDIRECTS: usize = 5; #[derive(Clone)] pub struct SerperClient { @@ -100,21 +101,33 @@ impl SerperClient { if crawl_url.is_empty() { return Err(SerperError::EmptyUrl); } - let destination = validate_public_crawl_url(crawl_url).await?; - let mut builder = reqwest::Client::builder() - .no_proxy() - .redirect(reqwest::redirect::Policy::none()) - .timeout(self.timeout); - if let Some(host) = &destination.resolved_host { - builder = builder.resolve_to_addrs(host, &destination.addresses); - } - let http = builder.build().map_err(SerperError::HttpClient)?; - let response = http - .get(destination.url) - .send() - .await - .map_err(|error| SerperError::Request(error.without_url()))?; - read_crawl_response(response).await + tokio::time::timeout(self.timeout, async { + let mut url = crawl_url.to_owned(); + let mut redirects = 0; + loop { + let destination = validate_public_crawl_url(&url).await?; + let mut builder = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(self.timeout); + if let Some(host) = &destination.resolved_host { + builder = builder.resolve_to_addrs(host, &destination.addresses); + } + let http = builder.build().map_err(SerperError::HttpClient)?; + let response = http + .get(destination.url) + .send() + .await + .map_err(|error| SerperError::Request(error.without_url()))?; + let Some(target) = crawl_redirect_target(&response, redirects)? else { + return read_crawl_response(response).await; + }; + url = target.into(); + redirects += 1; + } + }) + .await + .map_err(|_| SerperError::message("crawl timed out".to_owned()))? } async fn perform_search_with_retry( @@ -180,6 +193,31 @@ impl SerperClient { } } +fn crawl_redirect_target( + response: &reqwest::Response, + redirects: usize, +) -> Result, SerperError> { + if !matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) { + return Ok(None); + } + if redirects >= CRAWL_MAX_REDIRECTS { + return Err(SerperError::message( + "crawl exceeded 5 redirects".to_owned(), + )); + } + let location = response + .headers() + .get(reqwest::header::LOCATION) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| SerperError::message("crawl redirect has no usable location".to_owned()))?; + response + .url() + .join(location) + .map(Some) + .map_err(|_| SerperError::message("crawl redirect has an invalid location".to_owned())) +} + async fn read_crawl_response(mut response: reqwest::Response) -> Result { if !response.status().is_success() { return Err(SerperError::message(format!( @@ -680,6 +718,75 @@ mod tests { ); } + #[tokio::test] + async fn crawl_redirects_resolve_locations_and_revalidate_destinations() + -> Result<(), Box> { + for status in [301, 302, 303, 307, 308] { + let response = redirect_response(status, Some("../notes?version=1")).await?; + let base = response.url().clone(); + assert_eq!( + crawl_redirect_target(&response, 0)?.expect("redirect"), + base.join("../notes?version=1")? + ); + assert!( + crawl_redirect_target(&response, CRAWL_MAX_REDIRECTS) + .expect_err("bounded redirects") + .to_string() + .contains("exceeded 5") + ); + } + for location in [ + "http://127.0.0.1/internal", + "http://169.254.169.254/latest/meta-data", + "http://[::1]/internal", + "http://user:password@example.com/", + "file:///etc/passwd", + ] { + let response = redirect_response(302, Some(location)).await?; + let target = crawl_redirect_target(&response, 0)?.expect("redirect"); + assert!(validate_public_crawl_url(target.as_str()).await.is_err()); + } + for location in [None, Some(""), Some("http://[invalid")] { + let response = redirect_response(302, location).await?; + assert!(crawl_redirect_target(&response, 0).is_err()); + } + let response = redirect_response(304, Some("/cached")).await?; + assert!(crawl_redirect_target(&response, 0)?.is_none()); + assert!(read_crawl_response(response).await.is_err()); + Ok(()) + } + + async fn redirect_response( + status: u16, + location: Option<&str>, + ) -> Result> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = listener.local_addr()?; + let location = location + .map(|value| format!("Location: {value}\r\n")) + .unwrap_or_default(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await?; + read_request(&mut stream) + .await + .map_err(std::io::Error::other)?; + stream + .write_all( + format!("HTTP/1.1 {status} Redirect\r\n{location}Content-Length: 0\r\nConnection: close\r\n\r\n") + .as_bytes(), + ) + .await + }); + let response = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build()? + .get(format!("http://{address}/start/page")) + .send() + .await?; + server.await??; + Ok(response) + } + #[test] fn retry_policy_matches_go_serper_classification() { assert!(should_retry_serper_error("api error: status 429", 0)); diff --git a/prompts/aifarm/system.prompt b/prompts/aifarm/system.prompt index c1c199ab..a08ea254 100644 --- a/prompts/aifarm/system.prompt +++ b/prompts/aifarm/system.prompt @@ -2,7 +2,7 @@ {{>shared_core}} - Для «это», «та ссылка», «проверь выше» сначала установи источник по ID сообщений, авторам и reply-связям. При необходимости используй history_search и get_messages. Если остаются несколько равноправных вариантов, задай один узкий вопрос. Когда проверяешь конкретную ссылку, прочитай именно её через crawl_url; общий поиск не заменяет чтение страницы. После чтения при необходимости найди независимые подтверждения. Если страница недоступна, прямо скажи об этом. + Для «это», «та ссылка», «проверь выше» сначала установи источник по ID сообщений, авторам и reply-связям. При необходимости используй history_search и get_messages. Если остаются несколько равноправных вариантов, задай один узкий вопрос. Когда проверяешь конкретную ссылку, прочитай именно её через crawl_url; общий поиск не заменяет чтение страницы. После чтения при необходимости найди независимые подтверждения. Если страница недоступна, прямо скажи об этом. Успешный web_search не означает, что страница прочитана: так можно назвать только успешный результат crawl_url. Просьбы запомнить, изменить или забыть факт выполняй через memory_manage. Подтверждай действие только после успешного результата инструмента. Для проверки сохранённых карточек используй memory_search: история разговора не доказывает, что карточка существует. ID для изменения или удаления бери из результата memory_search, не придумывай. Подарок draw_image/generate_song с gift=true возможен только при выданном runtime разрешении и по твоей инициативе. Просьбу участника или переделку нельзя выдавать за подарок. Проверяй права через get_user_status; неизвестный статус не означает отсутствие VIP. Отказ объясняй коротко своим голосом, без рекламного хвоста.