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
This commit is contained in:
Adrià Arrufat committed 2026-09-14 16:02:26 +02:00
1 parent 72a05c7efa
commit 1ede559ff9
4 files changed
+108 -20

No files matched your search

+49
View File
@@ -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"));
+18
View File
@@ -74,6 +74,24 @@
});
</script>
<script id=loopback-is-trustworthy type=module>
// Served over plain http on 127.0.0.1, which browsers treat as trustworthy.
const state = await testing.async();
await cookieStore.set({ name: '__Host-lp', value: '1', path: '/' });
const item = await cookieStore.get('__Host-lp');
await cookieStore.delete('__Host-lp');
document.cookie = 'lp_secure=1; Secure';
const doc = document.cookie;
document.cookie = 'lp_secure=; Max-Age=0; Secure';
state.resolve();
await state.done(() => {
testing.expectEqual('__Host-lp', item.name);
testing.expectEqual(true, item.secure);
testing.expectEqual(true, doc.includes('lp_secure=1'));
});
</script>
<script id=change-event-binds-listeners-at-mutation type=module>
const state = await testing.async();
// A change enqueued while nobody is subscribed must not be delivered to a
+35 -10
View File
@@ -145,7 +145,7 @@ pub fn parse(allocator: Allocator, url: [:0]const u8, str: []const u8) !Cookie {
return error.InvalidPrefixedCookie;
}
if (!std.mem.startsWith(u8, url, "https://")) {
if (!URL.isPotentiallyTrustworthy(url)) {
return error.InvalidPrefixedCookie;
}
@@ -160,7 +160,7 @@ pub fn parse(allocator: Allocator, url: [:0]const u8, str: []const u8) !Cookie {
if (secure == null) {
return error.InvalidPrefixedCookie;
}
if (!std.mem.startsWith(u8, url, "https://")) {
if (!URL.isPotentiallyTrustworthy(url)) {
return error.InvalidPrefixedCookie;
}
}
@@ -408,8 +408,7 @@ pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool,
return false;
}
if (url.secure == false and self.secure) {
// secure cookie can only be sent over HTTPs
if (self.secure and !url.trustworthy) {
return false;
}
@@ -678,14 +677,15 @@ fn findSecondLevelDomain(host: []const u8) []const u8 {
pub const PreparedUri = struct {
host: []const u8, // Percent encoded, lower case
path: []const u8, // Percent encoded
secure: bool, // True if scheme is https
trustworthy: bool, // May receive Secure cookies
// init assumes url lifetime exceeds preparedUri one.
pub fn init(url: [:0]const u8) PreparedUri {
const host = URL.getHostname(url);
return .{
.host = URL.getHostname(url),
.host = host,
.path = URL.getPathname(url),
.secure = URL.isSecure(url),
.trustworthy = URL.isSecure(url) or URL.isLoopbackHost(host),
};
}
};
@@ -994,6 +994,25 @@ test "Jar: forRequest" {
// the 'global2' cookie
}
test "Jar: forRequest Secure cookies on loopback origins" {
const expectCookies = struct {
fn expect(expected: []const u8, jar: *Jar, target_url: [:0]const u8, opts: Jar.LookupOpts) !void {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try jar.forRequest(target_url, &aw.writer, opts);
try testing.expectEqual(expected, aw.written());
}
}.expect;
var jar = Jar.init(testing.allocator, null);
defer jar.deinit();
const now = lp.datetime.timestamp(.real);
const url = "http://127.0.0.1:3000/";
try jar.add(try Cookie.parse(testing.allocator, url, "s=1; Secure"), now, true);
try expectCookies("s=1", &jar, url, .{ .origin_url = .{ .url = url }, .is_http = true });
}
test "Jar: forRequest SameSite=Strict on cross-site navigation" {
const expectCookies = struct {
fn expect(expected: []const u8, jar: *Jar, target_url: [:0]const u8, opts: Jar.LookupOpts) !void {
@@ -1095,7 +1114,7 @@ test "Cookie: parse key=value" {
// __Host- cookie-name-prefix rules:
// - must be Secure
// - must be set from an https origin
// - must be set from a potentially trustworthy origin (https or loopback)
// - must not have a Domain attribute
// - must have Path=/
try expectAttribute(.{ .name = "__Host-abc", .value = "1" }, "https://lightpanda.io/", "__Host-abc=1; Secure; Path=/");
@@ -1106,10 +1125,16 @@ test "Cookie: parse key=value" {
try expectError(error.InvalidPrefixedCookie, "https://lightpanda.io/", "__Host-abc=1; Secure; Path=/foo");
try expectError(error.InvalidPrefixedCookie, "https://lightpanda.io/", "__Host-abc=1; Secure; Path=/; Domain=lightpanda.io");
// __Secure- cookie-name-prefix rules: must be Secure and from https.
// __Secure- cookie-name-prefix rules: must be Secure and from a
// potentially trustworthy origin.
try expectAttribute(.{ .name = "__Secure-abc", .value = "1" }, "https://lightpanda.io/", "__Secure-abc=1; Secure");
try expectAttribute(.{ .name = "__SeCuRe-abc", .value = "1" }, "https://lightpanda.io/", "__SeCuRe-abc=1; Secure; Domain=lightpanda.io");
try expectError(error.InvalidPrefixedCookie, "https://lightpanda.io/", "__Secure-abc=1");
// plain-http loopback
try expectAttribute(.{ .name = "__Host-abc" }, "http://127.0.0.1:3000/", "__Host-abc=1; Secure; Path=/");
try expectAttribute(.{ .name = "__Secure-abc" }, "http://localhost/", "__Secure-abc=1; Secure");
try expectError(error.InvalidPrefixedCookie, "http://localhost.evil.com/", "__Host-abc=1; Secure; Path=/");
try expectError(error.InvalidPrefixedCookie, null, "__Secure-abc=1; Secure");
// Empty Domain= is treated as no Domain and accepted on __Host-.
@@ -1380,7 +1405,7 @@ test "Cookie: appliesTo with empty domain" {
const target = PreparedUri{
.host = "example.com",
.path = "/",
.secure = false,
.trustworthy = false,
};
try testing.expectEqual(false, cookie.appliesTo(&target, true, true, true));
+6 -10
View File
@@ -363,11 +363,7 @@ fn matchCookies(
const session = exec.session;
const url_resolved = try resolveQueryUrl(exec, url);
const target = Cookie.PreparedUri{
.host = URL.getHostname(url_resolved),
.path = URL.getPathname(url_resolved),
.secure = URL.isSecure(url_resolved),
};
const target: Cookie.PreparedUri = .init(url_resolved);
if (target.host.len == 0) {
return error.SecurityError;
}
@@ -494,10 +490,10 @@ fn storeCookie(exec: *const Execution, init_: CookieInit, is_delete: bool) !void
return error.SameSiteBlocked;
}
const is_https = URL.isSecure(url);
const trustworthy = URL.isPotentiallyTrustworthy(url);
// Per spec, SameSite=None requires Secure. CookieStore additionally
// marks any cookie written from an HTTPS document as Secure.
const secure = is_https or init.sameSite == .none;
// marks any cookie written from a trustworthy origin as Secure.
const secure = trustworthy or init.sameSite == .none;
// The `__Http-` and `__Host-Http-` prefixes are reserved for HTTP-state
// cookies; the (script) CookieStore API can never set them, on any origin.
@@ -509,7 +505,7 @@ fn storeCookie(exec: *const Execution, init_: CookieInit, is_delete: bool) !void
// catch impersonation attempts (e.g. "__HoSt-").
// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#name-cookie-name-prefixes
if (std.ascii.startsWithIgnoreCase(init.name, "__Host-")) {
if (!is_https) {
if (!trustworthy) {
return error.InvalidPrefixedCookie;
}
if (init.domain) |d| {
@@ -523,7 +519,7 @@ fn storeCookie(exec: *const Execution, init_: CookieInit, is_delete: bool) !void
return error.InvalidPrefixedCookie;
}
} else if (std.ascii.startsWithIgnoreCase(init.name, "__Secure-")) {
if (!is_https) {
if (!trustworthy) {
return error.InvalidPrefixedCookie;
}
}