From 1ede559ff9ec7b47db0ba2f2f4a67aa35e7a124f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= Date: Mon, 14 Sep 2026 16:02:26 +0200 Subject: [PATCH] cookies: accept Secure and __Host- cookies on loopback origins Browsers treat http://localhost, *.localhost, 127.0.0.0/8 and [::1] as potentially trustworthy, so Secure and prefixed cookies work there over plain http. We required an https scheme, which broke cookie-auth logins under Playwright in local development. Add URL.isPotentiallyTrustworthy (Chromium's net::IsLocalhost rule) and use it for the cookie prefix gates, the send-path check and cookieStore. Closes #3477 --- src/browser/URL.zig | 49 ++++++++++++++++++++++ src/browser/tests/cookie_store.html | 18 ++++++++ src/browser/webapi/storage/Cookie.zig | 45 +++++++++++++++----- src/browser/webapi/storage/CookieStore.zig | 16 +++---- 4 files changed, 108 insertions(+), 20 deletions(-) diff --git a/src/browser/URL.zig b/src/browser/URL.zig index a94f9db4e..01c305bdb 100644 --- a/src/browser/URL.zig +++ b/src/browser/URL.zig @@ -224,6 +224,27 @@ pub fn isSecure(raw: [:0]const u8) bool { return std.mem.startsWith(u8, raw, "https:") or std.mem.startsWith(u8, raw, "wss:"); } +/// Cryptographic scheme or loopback host. Browsers let such origins use +/// secure-only features (Secure cookies, prefixed cookie names) so that +/// plain-http local development behaves like production. +pub fn isPotentiallyTrustworthy(raw: [:0]const u8) bool { + return isSecure(raw) or isLoopbackHost(getHostname(raw)); +} + +/// Chromium's net::IsLocalhost. Takes a hostname as returned by +/// `getHostname`: no port, IPv6 literals still bracketed. +pub fn isLoopbackHost(hostname: []const u8) bool { + const host = std.mem.trimEnd(u8, hostname, "."); + if (std.ascii.eqlIgnoreCase(host, "localhost") or std.ascii.endsWithIgnoreCase(host, ".localhost")) { + return true; + } + const address = std.Io.net.IpAddress.parseLiteral(host) catch return false; + return switch (address) { + .ip4 => |ip4| ip4.bytes[0] == 127, + .ip6 => |ip6| std.mem.eql(u8, &ip6.bytes, &([_]u8{0} ** 15 ++ [_]u8{1})), + }; +} + pub fn getHostname(raw: []const u8) []const u8 { const host = getHost(raw); const port_sep = findPortSeparator(host) orelse return host; @@ -1457,6 +1478,34 @@ test "URL: getHostname" { try testing.expectEqualSlices(u8, "[2001:db8::1]", getHostname("https://[2001:db8::1]/")); } +test "URL: isPotentiallyTrustworthy" { + for ([_][:0]const u8{ + "https://example.com/", + "http://localhost/", + "http://LOCALHOST:3000/x", + "http://localhost./", + "http://app.localhost/", + "http://127.0.0.1:8080/", + "http://127.255.255.254/", + "http://[::1]:9/", + }) |url| { + try testing.expect(isPotentiallyTrustworthy(url)); + } + + for ([_][:0]const u8{ + "http://example.com/", + "http://notlocalhost/", + "http://localhost.evil.com/", + "http://127.0.0.1.evil.com/", + "http://128.0.0.1/", + "http://[::2]/", + "http://[::ffff:127.0.0.1]/", + "about:blank", + }) |url| { + try testing.expect(!isPotentiallyTrustworthy(url)); + } +} + test "URL: getPort" { // Regular hosts try testing.expectEqualSlices(u8, "8080", getPort("https://example.com:8080/path")); diff --git a/src/browser/tests/cookie_store.html b/src/browser/tests/cookie_store.html index c933f67f1..90159b5c5 100644 --- a/src/browser/tests/cookie_store.html +++ b/src/browser/tests/cookie_store.html @@ -74,6 +74,24 @@ }); + +