mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-15 07:19:20 -04:00
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:
1 parent
72a05c7efa
commit
1ede559ff9
4 files changed
+108
-20
No files matched your search
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user