From 839bd0cdb0df06102251350511d8dfb735266353 Mon Sep 17 00:00:00 2001 From: Park Jong Hyun <81476595+fhrmrk@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:28:21 +0900 Subject: [PATCH] fix(http): add http:// to a URL whose query holds another URL (#988) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request URL without a scheme defaults to http://, but the check was `contains("://")`, so `GET localhost:8080/login?next=http://…` was sent as is and failed to parse. Only a `://` after a leading RFC 3986 scheme counts now, both for HTTP Client requests and the proxy connection check. Co-authored-by: fhrmrk <81476595+fhrmrk@users.noreply.github.com> --- crates/ide-http/src/client.rs | 12 +++++++++++- crates/ide-http/src/exec.rs | 20 ++++++++++++++++---- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/crates/ide-http/src/client.rs b/crates/ide-http/src/client.rs index 0623200ef..b7b9dfa59 100644 --- a/crates/ide-http/src/client.rs +++ b/crates/ide-http/src/client.rs @@ -57,6 +57,16 @@ pub fn shared() -> IdeResult { Ok(client) } +/// `url`, with `http://` in front unless it starts with a scheme: a `://` later +/// on (`localhost:8080/login?next=http://…`) does not count. +pub(crate) fn with_scheme(url: &str) -> String { + let has_scheme = url.split_once("://").is_some_and(|(scheme, _)| { + scheme.starts_with(|c: char| c.is_ascii_alphabetic()) + && scheme.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) + }); + if has_scheme { url.to_owned() } else { format!("http://{url}") } +} + /// HTTP Proxy › Check connection: GETs `url` through `proxy` (the settings as /// edited, not yet applied). Any response but `407` counts as connected. pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &CancellationToken) -> IdeResult { @@ -64,7 +74,7 @@ pub async fn check_connection(proxy: &ProxySettings, url: &str, cancel: &Cancell return Err(IdeError::invalid(problem)); } let url = url.trim(); - let url = if url.contains("://") { url.to_owned() } else { format!("http://{url}") }; + let url = with_scheme(url); let client = build(proxy)?; let request = client.get(&url).timeout(Duration::from_secs(30)); let response = tokio::select! { diff --git a/crates/ide-http/src/exec.rs b/crates/ide-http/src/exec.rs index 47802fa44..963da518f 100644 --- a/crates/ide-http/src/exec.rs +++ b/crates/ide-http/src/exec.rs @@ -1,6 +1,6 @@ //! Preparing (substitution, body includes) and executing requests. -use crate::client::describe; +use crate::client::{describe, with_scheme}; use crate::env::{Environments, Resolver}; use crate::parse::{BodyPart, RawRequest}; use ide_core::{CancellationToken, IdeError, IdeResult}; @@ -84,10 +84,8 @@ pub fn prepare( Some((_, host)) => format!("http://{host}{url}"), None => return Err(IdeError::invalid(format!("relative URL {url} needs a Host header"))), } - } else if !url.contains("://") { - format!("http://{url}") } else { - url + with_scheme(&url) }; Ok(PreparedRequest { name: raw.block.name.clone(), @@ -214,6 +212,20 @@ mod tests { assert_eq!(p.url, "http://localhost:9000/status"); } + #[test] + fn urls_without_a_scheme_default_to_http() { + let url = |text: &str| { + let f = parse(text); + prepare(&f.requests[0], &f.variables, &Environments::default(), None, None).unwrap().url + }; + assert_eq!(url("GET localhost:8080/health\n"), "http://localhost:8080/health"); + assert_eq!( + url("GET localhost:8080/login?next=http://localhost:3000/\n"), + "http://localhost:8080/login?next=http://localhost:3000/" + ); + assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x"); + } + #[tokio::test] async fn executes_against_a_local_server() { use tokio::io::{AsyncReadExt, AsyncWriteExt};