fix(desktop): normalize loopback host for HTTP writes and stop Reminders nav flicker (#1086)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Will Pfleger
2026-06-17 12:19:14 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent 770266d2a9
commit 771086fd69
2 changed files with 75 additions and 10 deletions
+73 -3
View File
@@ -53,16 +53,30 @@ pub fn relay_http_base_url(relay_url: &str) -> String {
let trimmed = relay_url.trim().trim_end_matches('/');
if let Some(suffix) = trimmed.strip_prefix("wss://") {
return format!("https://{suffix}");
return format!("https://{}", normalize_loopback_host(suffix));
}
if let Some(suffix) = trimmed.strip_prefix("ws://") {
return format!("http://{suffix}");
return format!("http://{}", normalize_loopback_host(suffix));
}
trimmed.to_string()
}
/// Rewrite an exact `localhost` host to `127.0.0.1`, preserving port and path.
///
/// macOS resolves `localhost` to both `::1` and `127.0.0.1`; reqwest's
/// happy-eyeballs may try `::1` first, which fails when the relay binds IPv4
/// only (`0.0.0.0`). Forcing the IPv4 literal makes every HTTP write connect on
/// the first attempt. Exact-match only — `localhost.evil.com` is untouched.
fn normalize_loopback_host(authority: &str) -> String {
let host_len = authority.find([':', '/']).unwrap_or(authority.len());
if &authority[..host_len] == "localhost" {
return format!("127.0.0.1{}", &authority[host_len..]);
}
authority.to_string()
}
pub fn relay_api_base_url() -> String {
if let Some(base) = configured_env_var("BUZZ_RELAY_HTTP") {
return base.trim_end_matches('/').to_string();
@@ -539,9 +553,65 @@ pub async fn submit_event_with_keys(
#[cfg(test)]
mod tests {
use super::{build_profile_event, classify_intercepted_response, parse_command_response};
use super::{
build_profile_event, classify_intercepted_response, parse_command_response,
relay_http_base_url,
};
use serde::Deserialize;
// ── relay_http_base_url loopback normalization ───────────────────────────
#[test]
fn loopback_ws_localhost_rewritten_to_ipv4() {
assert_eq!(
relay_http_base_url("ws://localhost:3000"),
"http://127.0.0.1:3000"
);
}
#[test]
fn loopback_trailing_slash_rewritten_to_ipv4() {
assert_eq!(
relay_http_base_url("ws://localhost:3000/"),
"http://127.0.0.1:3000"
);
}
#[test]
fn remote_wss_host_unchanged() {
assert_eq!(
relay_http_base_url("wss://relay.example.com"),
"https://relay.example.com"
);
}
#[test]
fn loopback_ipv4_literal_unchanged() {
assert_eq!(
relay_http_base_url("ws://127.0.0.1:3000"),
"http://127.0.0.1:3000"
);
}
#[test]
fn localhost_substring_host_not_rewritten() {
// Exact-match only: a host that merely starts with "localhost" must NOT
// be rewritten, or a malicious host could hijack the loopback path.
assert_eq!(
relay_http_base_url("ws://localhost.evil.com:3000"),
"http://localhost.evil.com:3000"
);
}
#[test]
fn loopback_wss_localhost_rewritten_to_ipv4() {
// The wss:// dev arm normalizes identically to ws://.
assert_eq!(
relay_http_base_url("wss://localhost:3000"),
"https://127.0.0.1:3000"
);
}
// ── classify_intercepted_response ────────────────────────────────────────
#[test]
@@ -1,13 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { getIdentity } from "@/shared/api/tauri";
import { useIdentityQuery } from "@/shared/api/hooks";
import { RemindersPanel } from "./RemindersPanel";
export function RemindersScreen() {
const identityQuery = useQuery({
queryKey: ["identity"],
queryFn: getIdentity,
});
const identityQuery = useIdentityQuery();
if (!identityQuery.data?.pubkey) {
return null;