From 654de4abdb1f21fcde172007d63d3c9ec57b23bc Mon Sep 17 00:00:00 2001 From: youdie006 Date: Tue, 18 Aug 2026 14:47:17 +0900 Subject: [PATCH] Reject set_host("") on non-special URLs with credentials or a port Url::set_host's empty-host guard only rejected an empty host for special, non-file schemes. For a non-special scheme, an empty host fell through to Host::parse_opaque_cow("") and was accepted, even when the URL carried credentials or a port. The result is a serialization that the crate's own parser then rejects with EmptyHost - a broken parse/serialize roundtrip (e.g. `foo://user@host/` set to an empty host yields `foo://user@/`, which no longer parses; likewise `foo://host:1/` -> `foo://:1/`). Mirror the parser, which is the oracle: it rejects `foo://user@/` and `foo://:1/` but accepts `foo:///`. The guard now also rejects an empty host on a non-special URL when it has a username, a password, or a port. Empty host on a non-special scheme with no credentials/port still succeeds and roundtrips; special/file schemes are unchanged. Fixes #1144. --- url/src/lib.rs | 12 +++++++++++- url/tests/unit.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/url/src/lib.rs b/url/src/lib.rs index fa2803681..215944eeb 100644 --- a/url/src/lib.rs +++ b/url/src/lib.rs @@ -2025,7 +2025,17 @@ impl Url { let scheme_type = SchemeType::from(self.scheme()); if let Some(host) = host { - if host.is_empty() && scheme_type.is_special() && !scheme_type.is_file() { + // An empty host is only valid for a non-special scheme that has no + // credentials and no port. Setting it otherwise would produce a + // serialization (e.g. `foo://user@/` or `foo://:1/`) that the parser + // itself rejects with `EmptyHost`, breaking the parse/serialize + // roundtrip. Mirror the parser here. + if host.is_empty() + && ((scheme_type.is_special() && !scheme_type.is_file()) + || !self.username().is_empty() + || self.password().is_some() + || self.port().is_some()) + { return Err(ParseError::EmptyHost); } let mut host_substr = host; diff --git a/url/tests/unit.rs b/url/tests/unit.rs index faeb7c5f4..073c86046 100644 --- a/url/tests/unit.rs +++ b/url/tests/unit.rs @@ -97,6 +97,34 @@ fn test_set_empty_host() { assert_eq!(base.as_str(), "file://foo/share/foo/bar"); } +#[test] +/// https://github.com/servo/rust-url/issues/1144 +fn test_set_empty_host_non_special() { + // A non-special URL that has credentials or a port must reject an empty + // host: the resulting serialization (`foo://user@/`, `foo://:1/`) is one + // that `Url::parse` rejects with `EmptyHost`, so allowing it here would + // break the parse/serialize roundtrip. The URL must be left unchanged. + let cases = ["foo://user@host/", "foo://host:1/", "foo://user:pass@host/"]; + for input in cases.iter() { + let mut url = Url::parse(input).unwrap(); + let before = url.as_str().to_owned(); + assert_eq!( + url.set_host(Some("")), + Err(url::ParseError::EmptyHost), + "{}", + input + ); + assert_eq!(url.as_str(), before, "{}", input); + } + + // Without credentials or a port, an empty host stays legal for a + // non-special scheme, and the result must roundtrip through the parser. + let mut url = Url::parse("foo://host/path").unwrap(); + url.set_host(Some("")).unwrap(); + assert_eq!(url.as_str(), "foo:///path"); + assert_eq!(Url::parse(url.as_str()).unwrap(), url); +} + #[test] fn test_set_empty_username_and_password() { let mut base: Url = "moz://foo:bar@servo/baz".parse().unwrap();