From 37f5e0ad19a5542534926946963b081d5eac2f35 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:40:26 +0200 Subject: [PATCH 01/15] deleteCookies --- src/browser/storage/cookie.zig | 147 +++++++++++++++++---------------- src/cdp/domains/network.zig | 48 +++++++++++ 2 files changed, 125 insertions(+), 70 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index bdd93e3f7..1b565046b 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -32,6 +32,13 @@ pub const Jar = struct { self.cookies.deinit(self.allocator); } + pub fn clear(self: *Jar) void { + for (self.cookies.items) |c| { + c.deinit(); + } + self.cookies.clearRetainingCapacity(); + } + pub fn add( self: *Jar, cookie: Cookie, @@ -173,43 +180,43 @@ pub const Jar = struct { } }; -pub const CookieList = struct { - _cookies: std.ArrayListUnmanaged(*const Cookie) = .{}, - - pub fn deinit(self: *CookieList, allocator: Allocator) void { - self._cookies.deinit(allocator); - } - - pub fn cookies(self: *const CookieList) []*const Cookie { - return self._cookies.items; - } - - pub fn len(self: *const CookieList) usize { - return self._cookies.items.len; - } - - pub fn write(self: *const CookieList, writer: anytype) !void { - const all = self._cookies.items; - if (all.len == 0) { - return; - } - try writeCookie(all[0], writer); - for (all[1..]) |cookie| { - try writer.writeAll("; "); - try writeCookie(cookie, writer); - } - } - - fn writeCookie(cookie: *const Cookie, writer: anytype) !void { - if (cookie.name.len > 0) { - try writer.writeAll(cookie.name); - try writer.writeByte('='); - } - if (cookie.value.len > 0) { - try writer.writeAll(cookie.value); - } - } -}; +// pub const CookieList = struct { +// _cookies: std.ArrayListUnmanaged(*const Cookie) = .{}, + +// pub fn deinit(self: *CookieList, allocator: Allocator) void { +// self._cookies.deinit(allocator); +// } + +// pub fn cookies(self: *const CookieList) []*const Cookie { +// return self._cookies.items; +// } + +// pub fn len(self: *const CookieList) usize { +// return self._cookies.items.len; +// } + +// pub fn write(self: *const CookieList, writer: anytype) !void { +// const all = self._cookies.items; +// if (all.len == 0) { +// return; +// } +// try writeCookie(all[0], writer); +// for (all[1..]) |cookie| { +// try writer.writeAll("; "); +// try writeCookie(cookie, writer); +// } +// } + +// fn writeCookie(cookie: *const Cookie, writer: anytype) !void { +// if (cookie.name.len > 0) { +// try writer.writeAll(cookie.name); +// try writer.writeByte('='); +// } +// if (cookie.value.len > 0) { +// try writer.writeAll(cookie.value); +// } +// } +// }; fn isCookieExpired(cookie: *const Cookie, now: i64) bool { const ce = cookie.expires orelse return false; @@ -660,39 +667,39 @@ test "Jar: forRequest" { // the 'global2' cookie } -test "CookieList: write" { - var arr: std.ArrayListUnmanaged(u8) = .{}; - defer arr.deinit(testing.allocator); - - var cookie_list = CookieList{}; - defer cookie_list.deinit(testing.allocator); - - const c1 = try Cookie.parse(testing.allocator, &test_uri, "cookie_name=cookie_value"); - defer c1.deinit(); - { - try cookie_list._cookies.append(testing.allocator, &c1); - try cookie_list.write(arr.writer(testing.allocator)); - try testing.expectEqual("cookie_name=cookie_value", arr.items); - } - - const c2 = try Cookie.parse(testing.allocator, &test_uri, "x84"); - defer c2.deinit(); - { - arr.clearRetainingCapacity(); - try cookie_list._cookies.append(testing.allocator, &c2); - try cookie_list.write(arr.writer(testing.allocator)); - try testing.expectEqual("cookie_name=cookie_value; x84", arr.items); - } - - const c3 = try Cookie.parse(testing.allocator, &test_uri, "nope="); - defer c3.deinit(); - { - arr.clearRetainingCapacity(); - try cookie_list._cookies.append(testing.allocator, &c3); - try cookie_list.write(arr.writer(testing.allocator)); - try testing.expectEqual("cookie_name=cookie_value; x84; nope=", arr.items); - } -} +// test "CookieList: write" { +// var arr: std.ArrayListUnmanaged(u8) = .{}; +// defer arr.deinit(testing.allocator); + +// var cookie_list = CookieList{}; +// defer cookie_list.deinit(testing.allocator); + +// const c1 = try Cookie.parse(testing.allocator, &test_uri, "cookie_name=cookie_value"); +// defer c1.deinit(); +// { +// try cookie_list._cookies.append(testing.allocator, &c1); +// try cookie_list.write(arr.writer(testing.allocator)); +// try testing.expectEqual("cookie_name=cookie_value", arr.items); +// } + +// const c2 = try Cookie.parse(testing.allocator, &test_uri, "x84"); +// defer c2.deinit(); +// { +// arr.clearRetainingCapacity(); +// try cookie_list._cookies.append(testing.allocator, &c2); +// try cookie_list.write(arr.writer(testing.allocator)); +// try testing.expectEqual("cookie_name=cookie_value; x84", arr.items); +// } + +// const c3 = try Cookie.parse(testing.allocator, &test_uri, "nope="); +// defer c3.deinit(); +// { +// arr.clearRetainingCapacity(); +// try cookie_list._cookies.append(testing.allocator, &c3); +// try cookie_list.write(arr.writer(testing.allocator)); +// try testing.expectEqual("cookie_name=cookie_value; x84; nope=", arr.items); +// } +// } test "Cookie: parse key=value" { try expectError(error.Empty, null, ""); diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index a161f589c..db3b2392f 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -28,6 +28,7 @@ pub fn processMessage(cmd: anytype) !void { disable, setCacheDisabled, setExtraHTTPHeaders, + deleteCookies, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -35,6 +36,7 @@ pub fn processMessage(cmd: anytype) !void { .disable => return disable(cmd), .setCacheDisabled => return cmd.sendResult(null, .{}), .setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd), + .deleteCookies => return deleteCookies(cmd), } } @@ -71,6 +73,52 @@ fn setExtraHTTPHeaders(cmd: anytype) !void { return cmd.sendResult(null, .{}); } +// const CookiePartitionKey = struct { +// topLevelSite: []const u8, +// hasCrossSiteAncestor: bool, +// }; + +const Cookie = @import("../../browser/storage/storage.zig").Cookie; +const CookieJar = @import("../../browser/storage/storage.zig").CookieJar; + +fn cookieMatches(cookie: *const Cookie, name: []const u8, url: ?[]const u8, domain: ?[]const u8, path: ?[]const u8) bool { + if (!std.mem.eql(u8, cookie.name, name)) return false; + + _ = url; // TODO + + if (domain) |domain_| { + if (!std.mem.eql(u8, cookie.domain, domain_)) return false; + } + if (path) |path_| { + if (!std.mem.eql(u8, cookie.path, path_)) return false; + } + + return true; +} + +fn deleteCookies(cmd: anytype) !void { + const params = (try cmd.params(struct { + name: []const u8, + url: ?[]const u8 = null, + domain: ?[]const u8 = null, + path: ?[]const u8 = null, + // partitionKey: ?CookiePartitionKey, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const cookies = &bc.session.cookie_jar.cookies; + + var index = cookies.items.len; + while (index > 0) { + index -= 1; + const cookie = &cookies.items[index]; + if (cookieMatches(cookie, params.name, params.url, params.domain, params.path)) { + cookies.swapRemove(index).deinit(); + } + } + return cmd.sendResult(null, .{}); +} + // Upsert a header into the headers array. // returns true if the header was added, false if it was updated fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: std.http.Header) bool { From 7b65eaef67c818bf91e237c4679f1418d96be37f Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Wed, 11 Jun 2025 13:54:19 +0200 Subject: [PATCH 02/15] setCookies --- src/browser/storage/cookie.zig | 8 +-- src/cdp/domains/network.zig | 121 ++++++++++++++++++++++++++++++--- 2 files changed, 117 insertions(+), 12 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index 1b565046b..c76ba308c 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -266,9 +266,9 @@ pub const Cookie = struct { path: []const u8, domain: []const u8, expires: ?i64, - secure: bool, - http_only: bool, - same_site: SameSite, + secure: bool = false, + http_only: bool = false, + same_site: SameSite = .none, const SameSite = enum { strict, @@ -372,7 +372,7 @@ pub const Cookie = struct { if (std.mem.endsWith(u8, host, value) == false) { return error.InvalidDomain; } - domain = value; + domain = value; // TODO to lower case: https://www.rfc-editor.org/rfc/rfc6265#section-5.2.3 }, .secure => secure = true, .@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue, diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index db3b2392f..6f563bcbb 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -29,6 +29,7 @@ pub fn processMessage(cmd: anytype) !void { setCacheDisabled, setExtraHTTPHeaders, deleteCookies, + setCookies, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -37,6 +38,7 @@ pub fn processMessage(cmd: anytype) !void { .setCacheDisabled => return cmd.sendResult(null, .{}), .setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd), .deleteCookies => return deleteCookies(cmd), + .setCookies => return setCookies(cmd), } } @@ -73,19 +75,17 @@ fn setExtraHTTPHeaders(cmd: anytype) !void { return cmd.sendResult(null, .{}); } -// const CookiePartitionKey = struct { -// topLevelSite: []const u8, -// hasCrossSiteAncestor: bool, -// }; +const CookiePartitionKey = struct { + topLevelSite: []const u8, + hasCrossSiteAncestor: bool, +}; const Cookie = @import("../../browser/storage/storage.zig").Cookie; const CookieJar = @import("../../browser/storage/storage.zig").CookieJar; -fn cookieMatches(cookie: *const Cookie, name: []const u8, url: ?[]const u8, domain: ?[]const u8, path: ?[]const u8) bool { +fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, path: ?[]const u8) bool { if (!std.mem.eql(u8, cookie.name, name)) return false; - _ = url; // TODO - if (domain) |domain_| { if (!std.mem.eql(u8, cookie.domain, domain_)) return false; } @@ -112,13 +112,118 @@ fn deleteCookies(cmd: anytype) !void { while (index > 0) { index -= 1; const cookie = &cookies.items[index]; - if (cookieMatches(cookie, params.name, params.url, params.domain, params.path)) { + const domain = try percentEncodedDomain(cmd.arena, params.url, params.domain); + // TBD does chrome take the path from the url as default? (unlike setCookies) + if (cookieMatches(cookie, params.name, domain, params.path)) { cookies.swapRemove(index).deinit(); } } return cmd.sendResult(null, .{}); } +const SameSite = enum { + Strict, + Lax, + None, +}; +const CookiePriority = enum { + Low, + Medium, + High, +}; +const CookieSourceScheme = enum { + Unset, + NonSecure, + Secure, +}; + +fn isHostChar(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, + ':' => true, + '[', ']' => true, + else => false, + }; +} + +// Note: Chrome does not apply rules like removing a leading `.` from the domain. +fn percentEncodedDomain(allocator: Allocator, default_url: ?[]const u8, domain: ?[]const u8) !?[]const u8 { + if (domain) |domain_| { + return try allocator.dupe(u8, domain_); + } else if (default_url) |url| { + const uri = std.Uri.parse(url) catch return error.InvalidParams; + + switch (uri.host orelse return error.InvalidParams) { + .raw => |str| { + var list = std.ArrayList(u8).init(allocator); + try list.ensureTotalCapacity(str.len); // Expect no precents needed + try std.Uri.Component.percentEncode(list.writer(), str, isHostChar); + return list.items; // @memory retains memory used before growing + }, + .percent_encoded => |str| { + return try allocator.dupe(u8, str); + }, + } + } else return null; +} + +fn setCookies(cmd: anytype) !void { + const params = (try cmd.params(struct { + cookies: []const struct { + name: []const u8, + value: []const u8, + url: ?[]const u8 = null, + domain: ?[]const u8 = null, + path: ?[]const u8 = null, + secure: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 + httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 + sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies + expires: ?i64 = null, // -1? says google + priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00 + sameParty: ?bool = null, + sourceScheme: ?CookieSourceScheme = null, + // sourcePort: Temporary ability and it will be removed from CDP + partitionKey: ?CookiePartitionKey = null, + }, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + for (params.cookies) |param| { + if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) { + return error.NotYetImplementedParams; + } + if (param.name.len == 0) return error.InvalidParams; + if (param.value.len == 0) return error.InvalidParams; + + var arena = std.heap.ArenaAllocator.init(bc.session.cookie_jar.allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + + // NOTE: The param.url can affect the default domain, path, source port, and source scheme. + const domain = try percentEncodedDomain(a, param.url, param.domain) orelse return error.InvalidParams; + + const cookie = Cookie{ + .arena = arena, + .name = try a.dupe(u8, param.name), + .value = try a.dupe(u8, param.value), + .path = if (param.path) |path| try a.dupe(u8, path) else "/", // Chrome does not actually take the path from the url and just defaults to "/". + .domain = domain, + .expires = param.expires, + .secure = param.secure, + .http_only = param.httpOnly, + .same_site = switch (param.sameSite) { + .Strict => .strict, + .Lax => .lax, + .None => .none, + }, + }; + try bc.session.cookie_jar.add(cookie, std.time.timestamp()); + } + + return cmd.sendResult(null, .{}); +} + // Upsert a header into the headers array. // returns true if the header was added, false if it was updated fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: std.http.Header) bool { From f63646c74089a91b094919f8901f8a39b16472f7 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Wed, 11 Jun 2025 14:05:05 +0200 Subject: [PATCH 03/15] setCookie --- src/cdp/domains/network.zig | 111 +++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 46 deletions(-) diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 6f563bcbb..7273a83ba 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -29,6 +29,7 @@ pub fn processMessage(cmd: anytype) !void { setCacheDisabled, setExtraHTTPHeaders, deleteCookies, + setCookie, setCookies, }, cmd.input.action) orelse return error.UnknownMethod; @@ -38,6 +39,7 @@ pub fn processMessage(cmd: anytype) !void { .setCacheDisabled => return cmd.sendResult(null, .{}), .setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd), .deleteCookies => return deleteCookies(cmd), + .setCookie => return setCookie(cmd), .setCookies => return setCookies(cmd), } } @@ -168,60 +170,77 @@ fn percentEncodedDomain(allocator: Allocator, default_url: ?[]const u8, domain: } else return null; } +const CdpCookie = struct { + name: []const u8, + value: []const u8, + url: ?[]const u8 = null, + domain: ?[]const u8 = null, + path: ?[]const u8 = null, + secure: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 + httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 + sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies + expires: ?i64 = null, // -1? says google + priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00 + sameParty: ?bool = null, + sourceScheme: ?CookieSourceScheme = null, + // sourcePort: Temporary ability and it will be removed from CDP + partitionKey: ?CookiePartitionKey = null, +}; + +fn setCookie(cmd: anytype) !void { + const params = (try cmd.params( + CdpCookie, + )) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + try setCdpCookie(&bc.session.cookie_jar, params); + + try cmd.sendResult(.{ .success = true }, .{}); +} + fn setCookies(cmd: anytype) !void { const params = (try cmd.params(struct { - cookies: []const struct { - name: []const u8, - value: []const u8, - url: ?[]const u8 = null, - domain: ?[]const u8 = null, - path: ?[]const u8 = null, - secure: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 - httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 - sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies - expires: ?i64 = null, // -1? says google - priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00 - sameParty: ?bool = null, - sourceScheme: ?CookieSourceScheme = null, - // sourcePort: Temporary ability and it will be removed from CDP - partitionKey: ?CookiePartitionKey = null, - }, + cookies: []const CdpCookie, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; for (params.cookies) |param| { - if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) { - return error.NotYetImplementedParams; - } - if (param.name.len == 0) return error.InvalidParams; - if (param.value.len == 0) return error.InvalidParams; - - var arena = std.heap.ArenaAllocator.init(bc.session.cookie_jar.allocator); - errdefer arena.deinit(); - const a = arena.allocator(); - - // NOTE: The param.url can affect the default domain, path, source port, and source scheme. - const domain = try percentEncodedDomain(a, param.url, param.domain) orelse return error.InvalidParams; - - const cookie = Cookie{ - .arena = arena, - .name = try a.dupe(u8, param.name), - .value = try a.dupe(u8, param.value), - .path = if (param.path) |path| try a.dupe(u8, path) else "/", // Chrome does not actually take the path from the url and just defaults to "/". - .domain = domain, - .expires = param.expires, - .secure = param.secure, - .http_only = param.httpOnly, - .same_site = switch (param.sameSite) { - .Strict => .strict, - .Lax => .lax, - .None => .none, - }, - }; - try bc.session.cookie_jar.add(cookie, std.time.timestamp()); + try setCdpCookie(&bc.session.cookie_jar, param); } - return cmd.sendResult(null, .{}); + try cmd.sendResult(null, .{}); +} + +fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { + if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) { + return error.NotYetImplementedParams; + } + if (param.name.len == 0) return error.InvalidParams; + if (param.value.len == 0) return error.InvalidParams; + + var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + + // NOTE: The param.url can affect the default domain, path, source port, and source scheme. + const domain = try percentEncodedDomain(a, param.url, param.domain) orelse return error.InvalidParams; + + const cookie = Cookie{ + .arena = arena, + .name = try a.dupe(u8, param.name), + .value = try a.dupe(u8, param.value), + .path = if (param.path) |path| try a.dupe(u8, path) else "/", // Chrome does not actually take the path from the url and just defaults to "/". + .domain = domain, + .expires = param.expires, + .secure = param.secure, + .http_only = param.httpOnly, + .same_site = switch (param.sameSite) { + .Strict => .strict, + .Lax => .lax, + .None => .none, + }, + }; + try cookie_jar.add(cookie, std.time.timestamp()); } // Upsert a header into the headers array. From b18d9d38c7449308c6882c7e7d394ad55c5d0647 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:26:58 +0200 Subject: [PATCH 04/15] clearRetainingCapacity --- src/browser/storage/cookie.zig | 2 +- src/cdp/domains/network.zig | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index c76ba308c..a73c94481 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -32,7 +32,7 @@ pub const Jar = struct { self.cookies.deinit(self.allocator); } - pub fn clear(self: *Jar) void { + pub fn clearRetainingCapacity(self: *Jar) void { for (self.cookies.items) |c| { c.deinit(); } diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 7273a83ba..f9754958b 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -29,6 +29,7 @@ pub fn processMessage(cmd: anytype) !void { setCacheDisabled, setExtraHTTPHeaders, deleteCookies, + clearBrowserCookies, setCookie, setCookies, }, cmd.input.action) orelse return error.UnknownMethod; @@ -39,6 +40,7 @@ pub fn processMessage(cmd: anytype) !void { .setCacheDisabled => return cmd.sendResult(null, .{}), .setExtraHTTPHeaders => return setExtraHTTPHeaders(cmd), .deleteCookies => return deleteCookies(cmd), + .clearBrowserCookies => return clearBrowserCookies(cmd), .setCookie => return setCookie(cmd), .setCookies => return setCookies(cmd), } @@ -123,6 +125,15 @@ fn deleteCookies(cmd: anytype) !void { return cmd.sendResult(null, .{}); } +fn clearBrowserCookies(cmd: anytype) !void { + _ = (try cmd.params(struct {})) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + bc.session.cookie_jar.clearRetainingCapacity(); + + return cmd.sendResult(null, .{}); +} + const SameSite = enum { Strict, Lax, From ee2df8cf7f7d839af06161773ebfa8cccf1d0570 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Wed, 11 Jun 2025 15:57:14 +0200 Subject: [PATCH 05/15] lower case domain --- src/browser/storage/cookie.zig | 10 +++++++- src/cdp/domains/network.zig | 46 ++++++++++++++++++---------------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index a73c94481..55cbc6c68 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -372,7 +372,7 @@ pub const Cookie = struct { if (std.mem.endsWith(u8, host, value) == false) { return error.InvalidDomain; } - domain = value; // TODO to lower case: https://www.rfc-editor.org/rfc/rfc6265#section-5.2.3 + domain = value; // Domain is made lower case after it has relocated to the arena }, .secure => secure = true, .@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue, @@ -406,6 +406,7 @@ pub const Cookie = struct { } else blk: { break :blk try aa.dupe(u8, host); }; + _ = toLower(owned_domain); var normalized_expires: ?i64 = null; if (max_age) |ma| { @@ -470,6 +471,13 @@ fn trimRight(str: []const u8) []const u8 { return std.mem.trimLeft(u8, str, &std.ascii.whitespace); } +pub fn toLower(str: []u8) []u8 { + for (str, 0..) |c, i| { + str[i] = std.ascii.toLower(c); + } + return str; +} + const testing = @import("../../testing.zig"); test "cookie: findSecondLevelDomain" { const cases = [_]struct { []const u8, []const u8 }{ diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index f9754958b..55cff2461 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -160,27 +160,6 @@ fn isHostChar(c: u8) bool { }; } -// Note: Chrome does not apply rules like removing a leading `.` from the domain. -fn percentEncodedDomain(allocator: Allocator, default_url: ?[]const u8, domain: ?[]const u8) !?[]const u8 { - if (domain) |domain_| { - return try allocator.dupe(u8, domain_); - } else if (default_url) |url| { - const uri = std.Uri.parse(url) catch return error.InvalidParams; - - switch (uri.host orelse return error.InvalidParams) { - .raw => |str| { - var list = std.ArrayList(u8).init(allocator); - try list.ensureTotalCapacity(str.len); // Expect no precents needed - try std.Uri.Component.percentEncode(list.writer(), str, isHostChar); - return list.items; // @memory retains memory used before growing - }, - .percent_encoded => |str| { - return try allocator.dupe(u8, str); - }, - } - } else return null; -} - const CdpCookie = struct { name: []const u8, value: []const u8, @@ -254,6 +233,31 @@ fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { try cookie_jar.add(cookie, std.time.timestamp()); } +// Note: Chrome does not apply rules like removing a leading `.` from the domain. +fn percentEncodedDomain(allocator: Allocator, default_url: ?[]const u8, domain: ?[]const u8) !?[]const u8 { + const toLower = @import("../../browser/storage/cookie.zig").toLower; + if (domain) |domain_| { + const output = try allocator.dupe(u8, domain_); + return toLower(output); + } else if (default_url) |url| { + const uri = std.Uri.parse(url) catch return error.InvalidParams; + + var output: []u8 = undefined; + switch (uri.host orelse return error.InvalidParams) { + .raw => |str| { + var list = std.ArrayList(u8).init(allocator); + try list.ensureTotalCapacity(str.len); // Expect no precents needed + try std.Uri.Component.percentEncode(list.writer(), str, isHostChar); + output = list.items; // @memory retains memory used before growing + }, + .percent_encoded => |str| { + output = try allocator.dupe(u8, str); + }, + } + return toLower(output); + } else return null; +} + // Upsert a header into the headers array. // returns true if the header was added, false if it was updated fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: std.http.Header) bool { From 2fc1b31deece31aa6fc847e4bafabe493ca3d0c4 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Wed, 11 Jun 2025 17:30:19 +0200 Subject: [PATCH 06/15] storage cookies --- src/browser/storage/cookie.zig | 2 +- src/cdp/cdp.zig | 1 + src/cdp/domains/network.zig | 121 ++-------------- src/cdp/domains/storage.zig | 255 +++++++++++++++++++++++++++++++++ 4 files changed, 265 insertions(+), 114 deletions(-) create mode 100644 src/cdp/domains/storage.zig diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index 55cbc6c68..aa93ba21b 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -263,8 +263,8 @@ pub const Cookie = struct { arena: ArenaAllocator, name: []const u8, value: []const u8, - path: []const u8, domain: []const u8, + path: []const u8, expires: ?i64, secure: bool = false, http_only: bool = false, diff --git a/src/cdp/cdp.zig b/src/cdp/cdp.zig index 30b05499d..ed3388a4e 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -203,6 +203,7 @@ pub fn CDPT(comptime TypeProvider: type) type { asUint(u56, "Browser") => return @import("domains/browser.zig").processMessage(command), asUint(u56, "Runtime") => return @import("domains/runtime.zig").processMessage(command), asUint(u56, "Network") => return @import("domains/network.zig").processMessage(command), + asUint(u56, "Storage") => return @import("domains/storage.zig").processMessage(command), else => {}, }, 8 => switch (@as(u64, @bitCast(domain[0..8].*))) { diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 55cff2461..c7ad0c609 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -17,10 +17,11 @@ // along with this program. If not, see . const std = @import("std"); +const Allocator = std.mem.Allocator; + const Notification = @import("../../notification.zig").Notification; const log = @import("../../log.zig"); - -const Allocator = std.mem.Allocator; +const CdpStorage = @import("storage.zig"); pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { @@ -79,13 +80,7 @@ fn setExtraHTTPHeaders(cmd: anytype) !void { return cmd.sendResult(null, .{}); } -const CookiePartitionKey = struct { - topLevelSite: []const u8, - hasCrossSiteAncestor: bool, -}; - const Cookie = @import("../../browser/storage/storage.zig").Cookie; -const CookieJar = @import("../../browser/storage/storage.zig").CookieJar; fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, path: ?[]const u8) bool { if (!std.mem.eql(u8, cookie.name, name)) return false; @@ -116,7 +111,7 @@ fn deleteCookies(cmd: anytype) !void { while (index > 0) { index -= 1; const cookie = &cookies.items[index]; - const domain = try percentEncodedDomain(cmd.arena, params.url, params.domain); + const domain = try CdpStorage.percentEncodedDomain(cmd.arena, params.url, params.domain); // TBD does chrome take the path from the url as default? (unlike setCookies) if (cookieMatches(cookie, params.name, domain, params.path)) { cookies.swapRemove(index).deinit(); @@ -134,130 +129,30 @@ fn clearBrowserCookies(cmd: anytype) !void { return cmd.sendResult(null, .{}); } -const SameSite = enum { - Strict, - Lax, - None, -}; -const CookiePriority = enum { - Low, - Medium, - High, -}; -const CookieSourceScheme = enum { - Unset, - NonSecure, - Secure, -}; - -fn isHostChar(c: u8) bool { - return switch (c) { - 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, - '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, - ':' => true, - '[', ']' => true, - else => false, - }; -} - -const CdpCookie = struct { - name: []const u8, - value: []const u8, - url: ?[]const u8 = null, - domain: ?[]const u8 = null, - path: ?[]const u8 = null, - secure: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 - httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 - sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies - expires: ?i64 = null, // -1? says google - priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00 - sameParty: ?bool = null, - sourceScheme: ?CookieSourceScheme = null, - // sourcePort: Temporary ability and it will be removed from CDP - partitionKey: ?CookiePartitionKey = null, -}; - fn setCookie(cmd: anytype) !void { const params = (try cmd.params( - CdpCookie, + CdpStorage.CdpCookie, )) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - try setCdpCookie(&bc.session.cookie_jar, params); + try CdpStorage.setCdpCookie(&bc.session.cookie_jar, params); try cmd.sendResult(.{ .success = true }, .{}); } fn setCookies(cmd: anytype) !void { const params = (try cmd.params(struct { - cookies: []const CdpCookie, + cookies: []const CdpStorage.CdpCookie, })) orelse return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; for (params.cookies) |param| { - try setCdpCookie(&bc.session.cookie_jar, param); + try CdpStorage.setCdpCookie(&bc.session.cookie_jar, param); } try cmd.sendResult(null, .{}); } -fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { - if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) { - return error.NotYetImplementedParams; - } - if (param.name.len == 0) return error.InvalidParams; - if (param.value.len == 0) return error.InvalidParams; - - var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator); - errdefer arena.deinit(); - const a = arena.allocator(); - - // NOTE: The param.url can affect the default domain, path, source port, and source scheme. - const domain = try percentEncodedDomain(a, param.url, param.domain) orelse return error.InvalidParams; - - const cookie = Cookie{ - .arena = arena, - .name = try a.dupe(u8, param.name), - .value = try a.dupe(u8, param.value), - .path = if (param.path) |path| try a.dupe(u8, path) else "/", // Chrome does not actually take the path from the url and just defaults to "/". - .domain = domain, - .expires = param.expires, - .secure = param.secure, - .http_only = param.httpOnly, - .same_site = switch (param.sameSite) { - .Strict => .strict, - .Lax => .lax, - .None => .none, - }, - }; - try cookie_jar.add(cookie, std.time.timestamp()); -} - -// Note: Chrome does not apply rules like removing a leading `.` from the domain. -fn percentEncodedDomain(allocator: Allocator, default_url: ?[]const u8, domain: ?[]const u8) !?[]const u8 { - const toLower = @import("../../browser/storage/cookie.zig").toLower; - if (domain) |domain_| { - const output = try allocator.dupe(u8, domain_); - return toLower(output); - } else if (default_url) |url| { - const uri = std.Uri.parse(url) catch return error.InvalidParams; - - var output: []u8 = undefined; - switch (uri.host orelse return error.InvalidParams) { - .raw => |str| { - var list = std.ArrayList(u8).init(allocator); - try list.ensureTotalCapacity(str.len); // Expect no precents needed - try std.Uri.Component.percentEncode(list.writer(), str, isHostChar); - output = list.items; // @memory retains memory used before growing - }, - .percent_encoded => |str| { - output = try allocator.dupe(u8, str); - }, - } - return toLower(output); - } else return null; -} - // Upsert a header into the headers array. // returns true if the header was added, false if it was updated fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: std.http.Header) bool { diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig new file mode 100644 index 000000000..654d976be --- /dev/null +++ b/src/cdp/domains/storage.zig @@ -0,0 +1,255 @@ +// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +const log = @import("../../log.zig"); +const Cookie = @import("../../browser/storage/storage.zig").Cookie; +const CookieJar = @import("../../browser/storage/storage.zig").CookieJar; + +pub fn processMessage(cmd: anytype) !void { + const action = std.meta.stringToEnum(enum { + clearCookies, + setCookies, + getCookies, + }, cmd.input.action) orelse return error.UnknownMethod; + + switch (action) { + .clearCookies => return clearCookies(cmd), + .getCookies => return getCookies(cmd), + .setCookies => return setCookies(cmd), + } +} + +fn clearCookies(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const params = (try cmd.params(struct { + browserContextId: ?[]const u8, + })) orelse return error.InvalidParams; + + if (params.browserContextId) |browser_context_id| { + if (std.mem.eql(u8, browser_context_id, bc.id) == false) { + return error.UnknownBrowserContextId; + } + } + + bc.session.cookie_jar.clearRetainingCapacity(); + + return cmd.sendResult(null, .{}); +} + +fn getCookies(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const params = (try cmd.params(struct { + browserContextId: ?[]const u8, + })) orelse return error.InvalidParams; + + if (params.browserContextId) |browser_context_id| { + if (std.mem.eql(u8, browser_context_id, bc.id) == false) { + return error.UnknownBrowserContextId; + } + } + const cookies = CookieWriter{ .cookies = bc.session.cookie_jar.cookies.items }; + try cmd.sendResult(.{ .cookies = cookies }, .{}); +} + +fn setCookies(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const params = (try cmd.params(struct { + cookies: []const CdpCookie, + browserContextId: ?[]const u8, + })) orelse return error.InvalidParams; + + if (params.browserContextId) |browser_context_id| { + if (std.mem.eql(u8, browser_context_id, bc.id) == false) { + return error.UnknownBrowserContextId; + } + } + + for (params.cookies) |param| { + try setCdpCookie(&bc.session.cookie_jar, param); + } + + try cmd.sendResult(null, .{}); +} + +pub const SameSite = enum { + Strict, + Lax, + None, +}; +pub const CookiePriority = enum { + Low, + Medium, + High, +}; +pub const CookieSourceScheme = enum { + Unset, + NonSecure, + Secure, +}; + +pub const CookiePartitionKey = struct { + topLevelSite: []const u8, + hasCrossSiteAncestor: bool, +}; + +pub const CdpCookie = struct { + name: []const u8, + value: []const u8, + url: ?[]const u8 = null, + domain: ?[]const u8 = null, + path: ?[]const u8 = null, + secure: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 + httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 + sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies + expires: ?i64 = null, // -1? says google + priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00 + sameParty: ?bool = null, + sourceScheme: ?CookieSourceScheme = null, + // sourcePort: Temporary ability and it will be removed from CDP + partitionKey: ?CookiePartitionKey = null, +}; + +pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { + if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) { + return error.NotYetImplementedParams; + } + if (param.name.len == 0) return error.InvalidParams; + if (param.value.len == 0) return error.InvalidParams; + + var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator); + errdefer arena.deinit(); + const a = arena.allocator(); + + // NOTE: The param.url can affect the default domain, path, source port, and source scheme. + const domain = try percentEncodedDomain(a, param.url, param.domain) orelse return error.InvalidParams; + + const cookie = Cookie{ + .arena = arena, + .name = try a.dupe(u8, param.name), + .value = try a.dupe(u8, param.value), + .path = if (param.path) |path| try a.dupe(u8, path) else "/", // Chrome does not actually take the path from the url and just defaults to "/". + .domain = domain, + .expires = param.expires, + .secure = param.secure, + .http_only = param.httpOnly, + .same_site = switch (param.sameSite) { + .Strict => .strict, + .Lax => .lax, + .None => .none, + }, + }; + try cookie_jar.add(cookie, std.time.timestamp()); +} + +// Note: Chrome does not apply rules like removing a leading `.` from the domain. +pub fn percentEncodedDomain(allocator: Allocator, default_url: ?[]const u8, domain: ?[]const u8) !?[]const u8 { + const toLower = @import("../../browser/storage/cookie.zig").toLower; + if (domain) |domain_| { + const output = try allocator.dupe(u8, domain_); + return toLower(output); + } else if (default_url) |url| { + const uri = std.Uri.parse(url) catch return error.InvalidParams; + + var output: []u8 = undefined; + switch (uri.host orelse return error.InvalidParams) { + .raw => |str| { + var list = std.ArrayList(u8).init(allocator); + try list.ensureTotalCapacity(str.len); // Expect no precents needed + try std.Uri.Component.percentEncode(list.writer(), str, isHostChar); + output = list.items; // @memory retains memory used before growing + }, + .percent_encoded => |str| { + output = try allocator.dupe(u8, str); + }, + } + return toLower(output); + } else return null; +} + +fn isHostChar(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, + ':' => true, + '[', ']' => true, + else => false, + }; +} + +pub const CookieWriter = struct { + cookies: []const Cookie, + + pub fn jsonStringify(self: *const CookieWriter, w: anytype) !void { + self.writeCookies(w) catch |err| { + // The only error our jsonStringify method can return is @TypeOf(w).Error. + log.err(.cdp, "json stringify", .{ .err = err }); + return error.OutOfMemory; + }; + } + + fn writeCookies(self: CookieWriter, w: anytype) !void { + try w.beginArray(); + for (self.cookies) |*cookie| { + try writeCookie(cookie, w); + } + try w.endArray(); + } + + fn writeCookie(cookie: *const Cookie, w: anytype) !void { + try w.beginObject(); + { + try w.objectField("name"); + try w.write(cookie.name); + + try w.objectField("value"); + try w.write(cookie.value); + + try w.objectField("domain"); + try w.write(cookie.domain); + + try w.objectField("path"); + try w.write(cookie.path); + + try w.objectField("expires"); + try w.write(cookie.expires orelse -1); + + // TODO size + + try w.objectField("httpOnly"); + try w.write(cookie.http_only); + + try w.objectField("secure"); + try w.write(cookie.secure); + + // TODO session + + try w.objectField("sameSite"); + switch (cookie.same_site) { + .none => try w.write("None"), + .lax => try w.write("Lax"), + .strict => try w.write("Strict"), + } + + // TODO experimentals + } + try w.endObject(); + } +}; From 12e0efa63fe306e984b5fcb9326fa6254ac92863 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Thu, 12 Jun 2025 13:57:06 +0200 Subject: [PATCH 07/15] Network.getCookies --- src/browser/storage/cookie.zig | 235 ++++++++++++--------------------- src/cdp/domains/network.zig | 41 +++++- src/cdp/domains/storage.zig | 125 +++++++++++------- 3 files changed, 201 insertions(+), 200 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index aa93ba21b..db7a29a22 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -66,87 +66,33 @@ pub const Jar = struct { } } - pub fn forRequest(self: *Jar, target_uri: *const Uri, writer: anytype, opts: LookupOpts) !void { - const target_path = target_uri.path.percent_encoded; - const target_host = (target_uri.host orelse return error.InvalidURI).percent_encoded; - - const same_site = try areSameSite(opts.origin_uri, target_host); - const is_secure = std.mem.eql(u8, target_uri.scheme, "https"); - - var i: usize = 0; - var cookies = self.cookies.items; - const navigation = opts.navigation; - const request_time = opts.request_time orelse std.time.timestamp(); - - var first = true; - while (i < cookies.len) { - const cookie = &cookies[i]; - - if (isCookieExpired(cookie, request_time)) { - cookie.deinit(); - _ = self.cookies.swapRemove(i); - // don't increment i ! - continue; + pub fn removeExpired(self: *Jar, request_time: ?i64) void { + if (self.cookies.items.len == 0) return; + const time = request_time orelse std.time.timestamp(); + var i: usize = self.cookies.items.len - 1; + while (i > 0) { + defer i -= 1; + const cookie = &self.cookies.items[i]; + if (isCookieExpired(cookie, time)) { + self.cookies.swapRemove(i).deinit(); } - i += 1; + } + } - if (is_secure == false and cookie.secure) { - // secure cookie can only be sent over HTTPs - continue; - } + pub fn forRequest(self: *Jar, target_uri: *const Uri, writer: anytype, opts: LookupOpts) !void { + const target = PreparedUri{ + .host = (target_uri.host orelse return error.InvalidURI).percent_encoded, + .path = target_uri.path.percent_encoded, + .secure = std.mem.eql(u8, target_uri.scheme, "https"), + }; + const same_site = try areSameSite(opts.origin_uri, target.host); - if (same_site == false) { - // If we aren't on the "same site" (matching 2nd level domain - // taking into account public suffix list), then the cookie - // can only be sent if cookie.same_site == .none, or if - // we're navigating to (as opposed to, say, loading an image) - // and cookie.same_site == .lax - switch (cookie.same_site) { - .strict => continue, - .lax => if (navigation == false) continue, - .none => {}, - } - } + removeExpired(self, opts.request_time); - { - const domain = cookie.domain; - if (domain[0] == '.') { - // When a Set-Cookie header has a Domain attribute - // Then we will _always_ prefix it with a dot, extending its - // availability to all subdomains (yes, setting the Domain - // attributes EXPANDS the domains which the cookie will be - // sent to, to always include all subdomains). - if (std.mem.eql(u8, target_host, domain[1..]) == false and std.mem.endsWith(u8, target_host, domain) == false) { - continue; - } - } else if (std.mem.eql(u8, target_host, domain) == false) { - // When the Domain attribute isn't specific, then the cookie - // is only sent on an exact match. - continue; - } - } + var first = true; + for (self.cookies.items) |*cookie| { + if (!cookie.appliesTo(&target, same_site, opts.navigation)) continue; - { - const path = cookie.path; - if (path[path.len - 1] == '/') { - // If our cookie has a trailing slash, we can only match is - // the target path is a perfix. I.e., if our path is - // /doc/ we can only match /doc/* - if (std.mem.startsWith(u8, target_path, path) == false) { - continue; - } - } else { - // Our cookie path is something like /hello - if (std.mem.startsWith(u8, target_path, path) == false) { - // The target path has to either be /hello (it isn't) - continue; - } else if (target_path.len < path.len or (target_path.len > path.len and target_path[path.len] != '/')) { - // Or it has to be something like /hello/* (it isn't) - // it isn't! - continue; - } - } - } // we have a match! if (first) { first = false; @@ -180,44 +126,6 @@ pub const Jar = struct { } }; -// pub const CookieList = struct { -// _cookies: std.ArrayListUnmanaged(*const Cookie) = .{}, - -// pub fn deinit(self: *CookieList, allocator: Allocator) void { -// self._cookies.deinit(allocator); -// } - -// pub fn cookies(self: *const CookieList) []*const Cookie { -// return self._cookies.items; -// } - -// pub fn len(self: *const CookieList) usize { -// return self._cookies.items.len; -// } - -// pub fn write(self: *const CookieList, writer: anytype) !void { -// const all = self._cookies.items; -// if (all.len == 0) { -// return; -// } -// try writeCookie(all[0], writer); -// for (all[1..]) |cookie| { -// try writer.writeAll("; "); -// try writeCookie(cookie, writer); -// } -// } - -// fn writeCookie(cookie: *const Cookie, writer: anytype) !void { -// if (cookie.name.len > 0) { -// try writer.writeAll(cookie.name); -// try writer.writeByte('='); -// } -// if (cookie.value.len > 0) { -// try writer.writeAll(cookie.value); -// } -// } -// }; - fn isCookieExpired(cookie: *const Cookie, now: i64) bool { const ce = cookie.expires orelse return false; return ce <= now; @@ -447,6 +355,71 @@ pub const Cookie = struct { const value = trim(str[sep + 1 .. key_value_end]); return .{ name, value, rest }; } + + pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, navigation: bool) bool { + if (url.secure == false and self.secure) { + // secure cookie can only be sent over HTTPs + return false; + } + + if (same_site == false) { + // If we aren't on the "same site" (matching 2nd level domain + // taking into account public suffix list), then the cookie + // can only be sent if cookie.same_site == .none, or if + // we're navigating to (as opposed to, say, loading an image) + // and cookie.same_site == .lax + switch (self.same_site) { + .strict => return false, + .lax => if (navigation == false) return false, + .none => {}, + } + } + + { + if (self.domain[0] == '.') { + // When a Set-Cookie header has a Domain attribute + // Then we will _always_ prefix it with a dot, extending its + // availability to all subdomains (yes, setting the Domain + // attributes EXPANDS the domains which the cookie will be + // sent to, to always include all subdomains). + if (std.mem.eql(u8, url.host, self.domain[1..]) == false and std.mem.endsWith(u8, url.host, self.domain) == false) { + return false; + } + } else if (std.mem.eql(u8, url.host, self.domain) == false) { + // When the Domain attribute isn't specific, then the cookie + // is only sent on an exact match. + return false; + } + } + + { + if (self.path[self.path.len - 1] == '/') { + // If our cookie has a trailing slash, we can only match is + // the target path is a perfix. I.e., if our path is + // /doc/ we can only match /doc/* + if (std.mem.startsWith(u8, url.path, self.path) == false) { + return false; + } + } else { + // Our cookie path is something like /hello + if (std.mem.startsWith(u8, url.path, self.path) == false) { + // The target path has to either be /hello (it isn't) + return false; + } else if (url.path.len < self.path.len or (url.path.len > self.path.len and url.path[self.path.len] != '/')) { + // Or it has to be something like /hello/* (it isn't) + // it isn't! + return false; + } + } + } + return true; + } +}; + +pub const PreparedUri = struct { + host: []const u8, // Percent encoded, lower case + path: []const u8, // Percent encoded + secure: bool, // True if scheme is https }; fn defaultPath(allocator: Allocator, document_path: []const u8) ![]const u8 { @@ -675,40 +648,6 @@ test "Jar: forRequest" { // the 'global2' cookie } -// test "CookieList: write" { -// var arr: std.ArrayListUnmanaged(u8) = .{}; -// defer arr.deinit(testing.allocator); - -// var cookie_list = CookieList{}; -// defer cookie_list.deinit(testing.allocator); - -// const c1 = try Cookie.parse(testing.allocator, &test_uri, "cookie_name=cookie_value"); -// defer c1.deinit(); -// { -// try cookie_list._cookies.append(testing.allocator, &c1); -// try cookie_list.write(arr.writer(testing.allocator)); -// try testing.expectEqual("cookie_name=cookie_value", arr.items); -// } - -// const c2 = try Cookie.parse(testing.allocator, &test_uri, "x84"); -// defer c2.deinit(); -// { -// arr.clearRetainingCapacity(); -// try cookie_list._cookies.append(testing.allocator, &c2); -// try cookie_list.write(arr.writer(testing.allocator)); -// try testing.expectEqual("cookie_name=cookie_value; x84", arr.items); -// } - -// const c3 = try Cookie.parse(testing.allocator, &test_uri, "nope="); -// defer c3.deinit(); -// { -// arr.clearRetainingCapacity(); -// try cookie_list._cookies.append(testing.allocator, &c3); -// try cookie_list.write(arr.writer(testing.allocator)); -// try testing.expectEqual("cookie_name=cookie_value; x84; nope=", arr.items); -// } -// } - test "Cookie: parse key=value" { try expectError(error.Empty, null, ""); try expectError(error.InvalidByteSequence, null, &.{ 'a', 30, '=', 'b' }); diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index c7ad0c609..9c0fe27b0 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -33,6 +33,7 @@ pub fn processMessage(cmd: anytype) !void { clearBrowserCookies, setCookie, setCookies, + getCookies, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -44,6 +45,7 @@ pub fn processMessage(cmd: anytype) !void { .clearBrowserCookies => return clearBrowserCookies(cmd), .setCookie => return setCookie(cmd), .setCookies => return setCookies(cmd), + .getCookies => return getCookies(cmd), } } @@ -82,6 +84,7 @@ fn setExtraHTTPHeaders(cmd: anytype) !void { const Cookie = @import("../../browser/storage/storage.zig").Cookie; +// Only matches the cookie on provided parameters fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, path: ?[]const u8) bool { if (!std.mem.eql(u8, cookie.name, name)) return false; @@ -91,10 +94,15 @@ fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, p if (path) |path_| { if (!std.mem.eql(u8, cookie.path, path_)) return false; } - return true; } +// Only matches the cookie on provided parameters +fn cookieAppliesTo(cookie: *const Cookie, domain: []const u8, path: []const u8) bool { + if (!std.mem.eql(u8, cookie.domain, domain)) return false; + return std.mem.startsWith(u8, path, cookie.path); +} + fn deleteCookies(cmd: anytype) !void { const params = (try cmd.params(struct { name: []const u8, @@ -107,11 +115,13 @@ fn deleteCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const cookies = &bc.session.cookie_jar.cookies; + const uri = if (params.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null; + var index = cookies.items.len; while (index > 0) { index -= 1; const cookie = &cookies.items[index]; - const domain = try CdpStorage.percentEncodedDomain(cmd.arena, params.url, params.domain); + const domain = try CdpStorage.percentEncodedDomainOrHost(cmd.arena, uri, params.domain); // TBD does chrome take the path from the url as default? (unlike setCookies) if (cookieMatches(cookie, params.name, domain, params.path)) { cookies.swapRemove(index).deinit(); @@ -153,6 +163,33 @@ fn setCookies(cmd: anytype) !void { try cmd.sendResult(null, .{}); } +fn getCookies(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + const params = (try cmd.params(struct { + urls: []const []const u8, + })) orelse return error.InvalidParams; + + var urls = try std.ArrayListUnmanaged(CdpStorage.PreparedUri).initCapacity(cmd.arena, params.urls.len); + for (params.urls) |url| { + const uri = std.Uri.parse(url) catch return error.InvalidParams; + + const host_component = uri.host orelse return error.InvalidParams; + const host = CdpStorage.toLower(try CdpStorage.percentEncode(cmd.arena, host_component, CdpStorage.isHostChar)); + + var path: []const u8 = try CdpStorage.percentEncode(cmd.arena, uri.path, CdpStorage.isPathChar); + if (path.len == 0) path = "/"; + + const secure = std.mem.eql(u8, uri.scheme, "https"); + + urls.appendAssumeCapacity(.{ .host = host, .path = path, .secure = secure }); + } + + var jar = &bc.session.cookie_jar; + jar.removeExpired(null); + const writer = CdpStorage.CookieWriter{ .cookies = jar.cookies.items, .urls = urls.items }; + try cmd.sendResult(.{ .cookies = writer }, .{}); +} + // Upsert a header into the headers array. // returns true if the header was added, false if it was updated fn putAssumeCapacity(headers: *std.ArrayListUnmanaged(std.http.Header), extra: std.http.Header) bool { diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index 654d976be..2efd1c35b 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -22,6 +22,8 @@ const Allocator = std.mem.Allocator; const log = @import("../../log.zig"); const Cookie = @import("../../browser/storage/storage.zig").Cookie; const CookieJar = @import("../../browser/storage/storage.zig").CookieJar; +pub const PreparedUri = @import("../../browser/storage/cookie.zig").PreparedUri; +pub const toLower = @import("../../browser/storage/cookie.zig").toLower; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { @@ -65,6 +67,7 @@ fn getCookies(cmd: anytype) !void { return error.UnknownBrowserContextId; } } + bc.session.cookie_jar.removeExpired(null); const cookies = CookieWriter{ .cookies = bc.session.cookie_jar.cookies.items }; try cmd.sendResult(.{ .cookies = cookies }, .{}); } @@ -139,7 +142,8 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { const a = arena.allocator(); // NOTE: The param.url can affect the default domain, path, source port, and source scheme. - const domain = try percentEncodedDomain(a, param.url, param.domain) orelse return error.InvalidParams; + const uri = if (param.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null; + const domain = try percentEncodedDomainOrHost(a, uri, param.domain) orelse return error.InvalidParams; const cookie = Cookie{ .arena = arena, @@ -160,31 +164,32 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { } // Note: Chrome does not apply rules like removing a leading `.` from the domain. -pub fn percentEncodedDomain(allocator: Allocator, default_url: ?[]const u8, domain: ?[]const u8) !?[]const u8 { - const toLower = @import("../../browser/storage/cookie.zig").toLower; +pub fn percentEncodedDomainOrHost(allocator: Allocator, default_url: ?std.Uri, domain: ?[]const u8) !?[]const u8 { if (domain) |domain_| { const output = try allocator.dupe(u8, domain_); return toLower(output); } else if (default_url) |url| { - const uri = std.Uri.parse(url) catch return error.InvalidParams; - - var output: []u8 = undefined; - switch (uri.host orelse return error.InvalidParams) { - .raw => |str| { - var list = std.ArrayList(u8).init(allocator); - try list.ensureTotalCapacity(str.len); // Expect no precents needed - try std.Uri.Component.percentEncode(list.writer(), str, isHostChar); - output = list.items; // @memory retains memory used before growing - }, - .percent_encoded => |str| { - output = try allocator.dupe(u8, str); - }, - } + const host = url.host orelse return error.InvalidParams; + const output = try percentEncode(allocator, host, isHostChar); // TODO remove subdomains return toLower(output); } else return null; } -fn isHostChar(c: u8) bool { +pub fn percentEncode(arena: Allocator, component: std.Uri.Component, comptime isValidChar: fn (u8) bool) ![]u8 { + switch (component) { + .raw => |str| { + var list = std.ArrayList(u8).init(arena); + try list.ensureTotalCapacity(str.len); // Expect no precents needed + try std.Uri.Component.percentEncode(list.writer(), str, isValidChar); + return list.items; // @memory retains memory used before growing + }, + .percent_encoded => |str| { + return try arena.dupe(u8, str); + }, + } +} + +pub fn isHostChar(c: u8) bool { return switch (c) { 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, @@ -194,8 +199,18 @@ fn isHostChar(c: u8) bool { }; } +pub fn isPathChar(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, + '/', ':', '@' => true, + else => false, + }; +} + pub const CookieWriter = struct { cookies: []const Cookie, + urls: ?[]const PreparedUri = null, pub fn jsonStringify(self: *const CookieWriter, w: anytype) !void { self.writeCookies(w) catch |err| { @@ -207,49 +222,59 @@ pub const CookieWriter = struct { fn writeCookies(self: CookieWriter, w: anytype) !void { try w.beginArray(); - for (self.cookies) |*cookie| { - try writeCookie(cookie, w); + if (self.urls) |urls| { + for (self.cookies) |*cookie| { + for (urls) |*url| { + if (cookie.appliesTo(url, false, false)) { // TBD same_site, should we compare to the pages url? + try writeCookie(cookie, w); + break; + } + } + } + } else { + for (self.cookies) |*cookie| { + try writeCookie(cookie, w); + } } try w.endArray(); } +}; +pub fn writeCookie(cookie: *const Cookie, w: anytype) !void { + try w.beginObject(); + { + try w.objectField("name"); + try w.write(cookie.name); - fn writeCookie(cookie: *const Cookie, w: anytype) !void { - try w.beginObject(); - { - try w.objectField("name"); - try w.write(cookie.name); - - try w.objectField("value"); - try w.write(cookie.value); - - try w.objectField("domain"); - try w.write(cookie.domain); + try w.objectField("value"); + try w.write(cookie.value); - try w.objectField("path"); - try w.write(cookie.path); + try w.objectField("domain"); + try w.write(cookie.domain); - try w.objectField("expires"); - try w.write(cookie.expires orelse -1); + try w.objectField("path"); + try w.write(cookie.path); - // TODO size + try w.objectField("expires"); + try w.write(cookie.expires orelse -1); - try w.objectField("httpOnly"); - try w.write(cookie.http_only); + // TODO size - try w.objectField("secure"); - try w.write(cookie.secure); + try w.objectField("httpOnly"); + try w.write(cookie.http_only); - // TODO session + try w.objectField("secure"); + try w.write(cookie.secure); - try w.objectField("sameSite"); - switch (cookie.same_site) { - .none => try w.write("None"), - .lax => try w.write("Lax"), - .strict => try w.write("Strict"), - } + // TODO session - // TODO experimentals + try w.objectField("sameSite"); + switch (cookie.same_site) { + .none => try w.write("None"), + .lax => try w.write("Lax"), + .strict => try w.write("Strict"), } - try w.endObject(); + + // TODO experimentals } -}; + try w.endObject(); +} From 205f43cb70a584a5639783042746da75baf575c5 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:45:57 +0200 Subject: [PATCH 08/15] wip --- src/browser/storage/cookie.zig | 2 +- src/cdp/domains/storage.zig | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index db7a29a22..0c7949d50 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -312,7 +312,7 @@ pub const Cookie = struct { @memcpy(s[1..], d); break :blk s; } else blk: { - break :blk try aa.dupe(u8, host); + break :blk try aa.dupe(u8, host); // Sjors: Should subdomains be removed from host? }; _ = toLower(owned_domain); diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index 2efd1c35b..78deed9d8 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2024 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -68,8 +68,8 @@ fn getCookies(cmd: anytype) !void { } } bc.session.cookie_jar.removeExpired(null); - const cookies = CookieWriter{ .cookies = bc.session.cookie_jar.cookies.items }; - try cmd.sendResult(.{ .cookies = cookies }, .{}); + const writer = CookieWriter{ .cookies = bc.session.cookie_jar.cookies.items }; + try cmd.sendResult(.{ .cookies = writer }, .{}); } fn setCookies(cmd: anytype) !void { @@ -143,7 +143,7 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { // NOTE: The param.url can affect the default domain, path, source port, and source scheme. const uri = if (param.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null; - const domain = try percentEncodedDomainOrHost(a, uri, param.domain) orelse return error.InvalidParams; + const domain = try percentEncodedDomainOrHost(a, uri, param.domain) orelse return error.InvalidParams; // TODO Domain needs to be prefixed with a dot if is explicitely set const cookie = Cookie{ .arena = arena, From 7bcf03c4ec7caec6e22d13b02ca27ef170fa23ee Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Fri, 13 Jun 2025 12:37:19 +0200 Subject: [PATCH 09/15] refactor path / domain parsing --- src/browser/storage/cookie.zig | 146 +++++++++++++++++++++------------ src/cdp/domains/storage.zig | 2 +- src/runtime/js.zig | 2 +- 3 files changed, 94 insertions(+), 56 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index 0c7949d50..ca96531dc 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -207,9 +207,6 @@ pub const Cookie = struct { // this check is necessary, `std.mem.minMax` asserts len > 0 return error.Empty; } - - const host = (uri.host orelse return error.InvalidURI).percent_encoded; - { const min, const max = std.mem.minMax(u8, str); if (min < 32 or max > 126) { @@ -254,34 +251,10 @@ pub const Cookie = struct { samesite, }, std.ascii.lowerString(&scrap, key_string)) orelse continue; - var value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]); + const value = if (sep == attribute.len) "" else trim(attribute[sep + 1 ..]); switch (key) { - .path => { - // path attribute value either begins with a '/' or we - // ignore it and use the "default-path" algorithm - if (value.len > 0 and value[0] == '/') { - path = value; - } - }, - .domain => { - if (value.len == 0) { - continue; - } - if (value[0] == '.') { - // leading dot is ignored - value = value[1..]; - } - - if (std.mem.indexOfScalarPos(u8, value, 0, '.') == null and std.ascii.eqlIgnoreCase("localhost", value) == false) { - // can't set a cookie for a TLD - return error.InvalidDomain; - } - - if (std.mem.endsWith(u8, host, value) == false) { - return error.InvalidDomain; - } - domain = value; // Domain is made lower case after it has relocated to the arena - }, + .path => path = value, + .domain => domain = value, .secure => secure = true, .@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue, .expires => expires = DateTime.parse(value, .rfc822) catch continue, @@ -301,20 +274,9 @@ pub const Cookie = struct { const aa = arena.allocator(); const owned_name = try aa.dupe(u8, cookie_name); const owned_value = try aa.dupe(u8, cookie_value); - const owned_path = if (path) |p| - try aa.dupe(u8, p) - else - try defaultPath(aa, uri.path.percent_encoded); - - const owned_domain = if (domain) |d| blk: { - const s = try aa.alloc(u8, d.len + 1); - s[0] = '.'; - @memcpy(s[1..], d); - break :blk s; - } else blk: { - break :blk try aa.dupe(u8, host); // Sjors: Should subdomains be removed from host? - }; - _ = toLower(owned_domain); + const owned_path = try parse_path(aa, uri.path, path); + const host = uri.host orelse return error.InvalidURI; + const owned_domain = try parse_domain(aa, host, domain); var normalized_expires: ?i64 = null; if (max_age) |ma| { @@ -339,6 +301,92 @@ pub const Cookie = struct { }; } + pub fn parse_path(arena: Allocator, url_path: std.Uri.Component, explicit_path: ?[]const u8) ![]const u8 { + // path attribute value either begins with a '/' or we + // ignore it and use the "default-path" algorithm + if (explicit_path) |path| { + if (path.len > 0 and path[0] == '/') { + return try arena.dupe(u8, path); + } + } + + // default-path + const either = url_path.percent_encoded; + if (either.len == 0 or (either.len == 1 and either[0] == '/')) { + return "/"; + } + + var owned_path: []const u8 = try percentEncode(arena, url_path, isPathChar); + const last = std.mem.lastIndexOfScalar(u8, owned_path[1..], '/') orelse { + return "/"; + }; + return try arena.dupe(u8, owned_path[0 .. last + 1]); + } + + pub fn parse_domain(arena: Allocator, url_host: std.Uri.Component, explicit_domain: ?[]const u8) ![]const u8 { + const encoded_host = try percentEncode(arena, url_host, isHostChar); + _ = toLower(encoded_host); + + if (explicit_domain) |domain| { + if (domain.len > 0) { + const no_leading_dot = if (domain[0] == '.') domain[1..] else domain; + + var list = std.ArrayList(u8).init(arena); + try list.ensureTotalCapacity(no_leading_dot.len + 1); // Expect no precents needed + list.appendAssumeCapacity('.'); + try std.Uri.Component.percentEncode(list.writer(), no_leading_dot, isHostChar); + var owned_domain: []u8 = list.items; // @memory retains memory used before growing + _ = toLower(owned_domain); + + if (std.mem.indexOfScalarPos(u8, owned_domain, 1, '.') == null and std.mem.eql(u8, "localhost", owned_domain[1..]) == false) { + // can't set a cookie for a TLD + return error.InvalidDomain; + } + if (std.mem.endsWith(u8, encoded_host, owned_domain[1..]) == false) { + return error.InvalidDomain; + } + return owned_domain; + } + } + + return encoded_host; // default-domain + } + + // TODO when getting cookeis Note: Chrome does not apply rules like removing a leading `.` from the domain. + + pub fn percentEncode(arena: Allocator, component: std.Uri.Component, comptime isValidChar: fn (u8) bool) ![]u8 { + switch (component) { + .raw => |str| { + var list = std.ArrayList(u8).init(arena); + try list.ensureTotalCapacity(str.len); // Expect no precents needed + try std.Uri.Component.percentEncode(list.writer(), str, isValidChar); + return list.items; // @memory retains memory used before growing + }, + .percent_encoded => |str| { + return try arena.dupe(u8, str); + }, + } + } + + pub fn isHostChar(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, + ':' => true, + '[', ']' => true, + else => false, + }; + } + + pub fn isPathChar(c: u8) bool { + return switch (c) { + 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, + '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, + '/', ':', '@' => true, + else => false, + }; + } + fn parseNameValue(str: []const u8) !struct { []const u8, []const u8, []const u8 } { const key_value_end = std.mem.indexOfScalarPos(u8, str, 0, ';') orelse str.len; const rest = if (key_value_end == str.len) "" else str[key_value_end + 1 ..]; @@ -422,16 +470,6 @@ pub const PreparedUri = struct { secure: bool, // True if scheme is https }; -fn defaultPath(allocator: Allocator, document_path: []const u8) ![]const u8 { - if (document_path.len == 0 or (document_path.len == 1 and document_path[0] == '/')) { - return "/"; - } - const last = std.mem.lastIndexOfScalar(u8, document_path[1..], '/') orelse { - return "/"; - }; - return try allocator.dupe(u8, document_path[0 .. last + 1]); -} - fn trim(str: []const u8) []const u8 { return std.mem.trim(u8, str, &std.ascii.whitespace); } diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index 78deed9d8..f4983c3f8 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -249,7 +249,7 @@ pub fn writeCookie(cookie: *const Cookie, w: anytype) !void { try w.write(cookie.value); try w.objectField("domain"); - try w.write(cookie.domain); + try w.write(cookie.domain); // Should we hide a leading dot? try w.objectField("path"); try w.write(cookie.path); diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 083b86e3b..2ed89594d 100644 --- a/src/runtime/js.zig +++ b/src/runtime/js.zig @@ -400,7 +400,7 @@ pub fn Env(comptime State: type, comptime WebApis: type) type { }; // For a Page we only create one HandleScope, it is stored in the main World (enter==true). A page can have multple contexts, 1 for each World. - // The main Context/Scope that enters and holds the HandleScope should therefore always be created first. Following other worlds for this page + // The main Context that enters and holds the HandleScope should therefore always be created first. Following other worlds for this page // like isolated Worlds, will thereby place their objects on the main page's HandleScope. Note: In the furure the number of context will multiply multiple frames support var handle_scope: ?v8.HandleScope = null; if (enter) { From 165c26acb8033e2abcb46fcdea875b3730cfda34 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Fri, 13 Jun 2025 13:35:38 +0200 Subject: [PATCH 10/15] Cleaning up crumbles --- src/browser/storage/cookie.zig | 29 ++++++++++++------- src/cdp/domains/network.zig | 24 ++++++++-------- src/cdp/domains/storage.zig | 52 +++------------------------------- 3 files changed, 35 insertions(+), 70 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index ca96531dc..90a239074 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -274,9 +274,8 @@ pub const Cookie = struct { const aa = arena.allocator(); const owned_name = try aa.dupe(u8, cookie_name); const owned_value = try aa.dupe(u8, cookie_value); - const owned_path = try parse_path(aa, uri.path, path); - const host = uri.host orelse return error.InvalidURI; - const owned_domain = try parse_domain(aa, host, domain); + const owned_path = try parsePath(aa, uri, path); + const owned_domain = try parseDomain(aa, uri, domain); var normalized_expires: ?i64 = null; if (max_age) |ma| { @@ -301,7 +300,7 @@ pub const Cookie = struct { }; } - pub fn parse_path(arena: Allocator, url_path: std.Uri.Component, explicit_path: ?[]const u8) ![]const u8 { + pub fn parsePath(arena: Allocator, uri: ?*const std.Uri, explicit_path: ?[]const u8) ![]const u8 { // path attribute value either begins with a '/' or we // ignore it and use the "default-path" algorithm if (explicit_path) |path| { @@ -311,6 +310,8 @@ pub const Cookie = struct { } // default-path + const url_path = (uri orelse return "/").path; + const either = url_path.percent_encoded; if (either.len == 0 or (either.len == 1 and either[0] == '/')) { return "/"; @@ -323,9 +324,14 @@ pub const Cookie = struct { return try arena.dupe(u8, owned_path[0 .. last + 1]); } - pub fn parse_domain(arena: Allocator, url_host: std.Uri.Component, explicit_domain: ?[]const u8) ![]const u8 { - const encoded_host = try percentEncode(arena, url_host, isHostChar); - _ = toLower(encoded_host); + pub fn parseDomain(arena: Allocator, uri: ?*const std.Uri, explicit_domain: ?[]const u8) ![]const u8 { + var encoded_host: ?[]const u8 = null; + if (uri) |uri_| { + const uri_host = uri_.host orelse return error.InvalidURI; + const host = try percentEncode(arena, uri_host, isHostChar); + _ = toLower(host); + encoded_host = host; + } if (explicit_domain) |domain| { if (domain.len > 0) { @@ -342,14 +348,17 @@ pub const Cookie = struct { // can't set a cookie for a TLD return error.InvalidDomain; } - if (std.mem.endsWith(u8, encoded_host, owned_domain[1..]) == false) { - return error.InvalidDomain; + if (encoded_host) |host| { + if (std.mem.endsWith(u8, host, owned_domain[1..]) == false) { + return error.InvalidDomain; + } } + return owned_domain; } } - return encoded_host; // default-domain + return encoded_host orelse return error.InvalidDomain; // default-domain } // TODO when getting cookeis Note: Chrome does not apply rules like removing a leading `.` from the domain. diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 9c0fe27b0..e0678186b 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -116,14 +116,18 @@ fn deleteCookies(cmd: anytype) !void { const cookies = &bc.session.cookie_jar.cookies; const uri = if (params.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null; + const uri_ptr = if (uri) |u| &u else null; var index = cookies.items.len; while (index > 0) { index -= 1; const cookie = &cookies.items[index]; - const domain = try CdpStorage.percentEncodedDomainOrHost(cmd.arena, uri, params.domain); - // TBD does chrome take the path from the url as default? (unlike setCookies) - if (cookieMatches(cookie, params.name, domain, params.path)) { + const domain = try Cookie.parseDomain(cmd.arena, uri_ptr, params.domain); + const path = try Cookie.parsePath(cmd.arena, uri_ptr, params.path); + + // We do not want to use Cookie.appliesTo here. As a Cookie with a shorter path would match. + // Similar to deduplicating with areCookiesEqual, except domain and path are optional. + if (cookieMatches(cookie, params.name, domain, path)) { cookies.swapRemove(index).deinit(); } } @@ -173,15 +177,11 @@ fn getCookies(cmd: anytype) !void { for (params.urls) |url| { const uri = std.Uri.parse(url) catch return error.InvalidParams; - const host_component = uri.host orelse return error.InvalidParams; - const host = CdpStorage.toLower(try CdpStorage.percentEncode(cmd.arena, host_component, CdpStorage.isHostChar)); - - var path: []const u8 = try CdpStorage.percentEncode(cmd.arena, uri.path, CdpStorage.isPathChar); - if (path.len == 0) path = "/"; - - const secure = std.mem.eql(u8, uri.scheme, "https"); - - urls.appendAssumeCapacity(.{ .host = host, .path = path, .secure = secure }); + urls.appendAssumeCapacity(.{ + .host = try Cookie.parseDomain(cmd.arena, &uri, null), + .path = try Cookie.parsePath(cmd.arena, &uri, null), + .secure = std.mem.eql(u8, uri.scheme, "https"), + }); } var jar = &bc.session.cookie_jar; diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index f4983c3f8..033c67b79 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -23,7 +23,6 @@ const log = @import("../../log.zig"); const Cookie = @import("../../browser/storage/storage.zig").Cookie; const CookieJar = @import("../../browser/storage/storage.zig").CookieJar; pub const PreparedUri = @import("../../browser/storage/cookie.zig").PreparedUri; -pub const toLower = @import("../../browser/storage/cookie.zig").toLower; pub fn processMessage(cmd: anytype) !void { const action = std.meta.stringToEnum(enum { @@ -143,13 +142,15 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { // NOTE: The param.url can affect the default domain, path, source port, and source scheme. const uri = if (param.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null; - const domain = try percentEncodedDomainOrHost(a, uri, param.domain) orelse return error.InvalidParams; // TODO Domain needs to be prefixed with a dot if is explicitely set + const uri_ptr = if (uri) |*u| u else null; + const domain = try Cookie.parseDomain(a, uri_ptr, param.domain); + const path = try Cookie.parsePath(a, uri_ptr, param.path); const cookie = Cookie{ .arena = arena, .name = try a.dupe(u8, param.name), .value = try a.dupe(u8, param.value), - .path = if (param.path) |path| try a.dupe(u8, path) else "/", // Chrome does not actually take the path from the url and just defaults to "/". + .path = path, .domain = domain, .expires = param.expires, .secure = param.secure, @@ -163,51 +164,6 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { try cookie_jar.add(cookie, std.time.timestamp()); } -// Note: Chrome does not apply rules like removing a leading `.` from the domain. -pub fn percentEncodedDomainOrHost(allocator: Allocator, default_url: ?std.Uri, domain: ?[]const u8) !?[]const u8 { - if (domain) |domain_| { - const output = try allocator.dupe(u8, domain_); - return toLower(output); - } else if (default_url) |url| { - const host = url.host orelse return error.InvalidParams; - const output = try percentEncode(allocator, host, isHostChar); // TODO remove subdomains - return toLower(output); - } else return null; -} - -pub fn percentEncode(arena: Allocator, component: std.Uri.Component, comptime isValidChar: fn (u8) bool) ![]u8 { - switch (component) { - .raw => |str| { - var list = std.ArrayList(u8).init(arena); - try list.ensureTotalCapacity(str.len); // Expect no precents needed - try std.Uri.Component.percentEncode(list.writer(), str, isValidChar); - return list.items; // @memory retains memory used before growing - }, - .percent_encoded => |str| { - return try arena.dupe(u8, str); - }, - } -} - -pub fn isHostChar(c: u8) bool { - return switch (c) { - 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, - '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, - ':' => true, - '[', ']' => true, - else => false, - }; -} - -pub fn isPathChar(c: u8) bool { - return switch (c) { - 'A'...'Z', 'a'...'z', '0'...'9', '-', '.', '_', '~' => true, - '!', '$', '&', '\'', '(', ')', '*', '+', ',', ';', '=' => true, - '/', ':', '@' => true, - else => false, - }; -} - pub const CookieWriter = struct { cookies: []const Cookie, urls: ?[]const PreparedUri = null, From 3373100cc82e3bff5ec9ff9c8ebf98c6cce3480f Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Fri, 13 Jun 2025 15:41:08 +0200 Subject: [PATCH 11/15] CDP.Storage cookies tests --- src/browser/storage/cookie.zig | 2 - src/cdp/domains/storage.zig | 84 ++++++++++++++++++++++++++++++---- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index 90a239074..1abd37e1c 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -361,8 +361,6 @@ pub const Cookie = struct { return encoded_host orelse return error.InvalidDomain; // default-domain } - // TODO when getting cookeis Note: Chrome does not apply rules like removing a leading `.` from the domain. - pub fn percentEncode(arena: Allocator, component: std.Uri.Component, comptime isValidChar: fn (u8) bool) ![]u8 { switch (component) { .raw => |str| { diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index 033c67b79..cc26437b1 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -41,7 +41,7 @@ pub fn processMessage(cmd: anytype) !void { fn clearCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { - browserContextId: ?[]const u8, + browserContextId: ?[]const u8 = null, })) orelse return error.InvalidParams; if (params.browserContextId) |browser_context_id| { @@ -58,7 +58,7 @@ fn clearCookies(cmd: anytype) !void { fn getCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { - browserContextId: ?[]const u8, + browserContextId: ?[]const u8 = null, })) orelse return error.InvalidParams; if (params.browserContextId) |browser_context_id| { @@ -75,7 +75,7 @@ fn setCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { cookies: []const CdpCookie, - browserContextId: ?[]const u8, + browserContextId: ?[]const u8 = null, })) orelse return error.InvalidParams; if (params.browserContextId) |browser_context_id| { @@ -118,7 +118,7 @@ pub const CdpCookie = struct { url: ?[]const u8 = null, domain: ?[]const u8 = null, path: ?[]const u8 = null, - secure: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 + secure: ?bool = null, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies expires: ?i64 = null, // -1? says google @@ -140,11 +140,13 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { errdefer arena.deinit(); const a = arena.allocator(); - // NOTE: The param.url can affect the default domain, path, source port, and source scheme. + // NOTE: The param.url can affect the default domain, (NOT path), secure, source port, and source scheme. const uri = if (param.url) |url| std.Uri.parse(url) catch return error.InvalidParams else null; const uri_ptr = if (uri) |*u| u else null; const domain = try Cookie.parseDomain(a, uri_ptr, param.domain); - const path = try Cookie.parsePath(a, uri_ptr, param.path); + const path = if (param.path == null) "/" else try Cookie.parsePath(a, null, param.path); + + const secure = if (param.secure) |s| s else if (uri) |uri_| std.mem.eql(u8, uri_.scheme, "https") else false; const cookie = Cookie{ .arena = arena, @@ -153,7 +155,7 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { .path = path, .domain = domain, .expires = param.expires, - .secure = param.secure, + .secure = secure, .http_only = param.httpOnly, .same_site = switch (param.sameSite) { .Strict => .strict, @@ -181,7 +183,7 @@ pub const CookieWriter = struct { if (self.urls) |urls| { for (self.cookies) |*cookie| { for (urls) |*url| { - if (cookie.appliesTo(url, false, false)) { // TBD same_site, should we compare to the pages url? + if (cookie.appliesTo(url, true, true)) { // TBD same_site, should we compare to the pages url? try writeCookie(cookie, w); break; } @@ -234,3 +236,69 @@ pub fn writeCookie(cookie: *const Cookie, w: anytype) !void { } try w.endObject(); } + +const testing = @import("../testing.zig"); + +test "cdp.Storage: cookies" { + var ctx = testing.context(); + defer ctx.deinit(); + _ = try ctx.loadBrowserContext(.{ .id = "BID-S" }); + + // Initially empty + try ctx.processMessage(.{ + .id = 3, + .method = "Storage.getCookies", + .params = .{ .browserContextId = "BID-S" }, + }); + try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 3 }); + + // Has cookies after setting them + try ctx.processMessage(.{ + .id = 4, + .method = "Storage.setCookies", + .params = .{ + .cookies = &[_]CdpCookie{ + .{ .name = "test", .value = "value", .domain = "example.com", .path = "/mango" }, + .{ .name = "test2", .value = "value2", .url = "https://car.example.com/pancakes" }, + }, + .browserContextId = "BID-S", + }, + }); + try ctx.expectSentResult(null, .{ .id = 4 }); + try ctx.processMessage(.{ + .id = 5, + .method = "Storage.getCookies", + .params = .{ .browserContextId = "BID-S" }, + }); + try ctx.expectSentResult(.{ + .cookies = &[_]ResCookie{ + .{ .name = "test", .value = "value", .domain = ".example.com", .path = "/mango" }, + .{ .name = "test2", .value = "value2", .domain = "car.example.com", .path = "/", .secure = true }, // No Pancakes! + }, + }, .{ .id = 5 }); + + // Empty after clearing cookies + try ctx.processMessage(.{ + .id = 6, + .method = "Storage.clearCookies", + .params = .{ .browserContextId = "BID-S" }, + }); + try ctx.expectSentResult(null, .{ .id = 6 }); + try ctx.processMessage(.{ + .id = 7, + .method = "Storage.getCookies", + .params = .{ .browserContextId = "BID-S" }, + }); + try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 7 }); +} + +pub const ResCookie = struct { + name: []const u8, + value: []const u8, + domain: []const u8, + path: []const u8 = "/", + expires: i32 = -1, + httpOnly: bool = false, + secure: bool = false, + sameSite: []const u8 = "None", +}; From 65edb8524556dc865e952fe4a3aafbe1676a9ff6 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Fri, 13 Jun 2025 17:34:41 +0200 Subject: [PATCH 12/15] CDP Network cookie tests --- src/cdp/domains/network.zig | 100 +++++++++++++++++++++++++++++++----- 1 file changed, 86 insertions(+), 14 deletions(-) diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index e0678186b..2cee435df 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -89,7 +89,9 @@ fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, p if (!std.mem.eql(u8, cookie.name, name)) return false; if (domain) |domain_| { - if (!std.mem.eql(u8, cookie.domain, domain_)) return false; + const c_no_dot = if (std.mem.startsWith(u8, cookie.domain, ".")) cookie.domain[1..] else cookie.domain; + const d_no_dot = if (std.mem.startsWith(u8, domain_, ".")) domain_[1..] else domain_; + if (!std.mem.eql(u8, c_no_dot, d_no_dot)) return false; } if (path) |path_| { if (!std.mem.eql(u8, cookie.path, path_)) return false; @@ -97,20 +99,15 @@ fn cookieMatches(cookie: *const Cookie, name: []const u8, domain: ?[]const u8, p return true; } -// Only matches the cookie on provided parameters -fn cookieAppliesTo(cookie: *const Cookie, domain: []const u8, path: []const u8) bool { - if (!std.mem.eql(u8, cookie.domain, domain)) return false; - return std.mem.startsWith(u8, path, cookie.path); -} - fn deleteCookies(cmd: anytype) !void { const params = (try cmd.params(struct { name: []const u8, url: ?[]const u8 = null, domain: ?[]const u8 = null, path: ?[]const u8 = null, - // partitionKey: ?CookiePartitionKey, + partitionKey: ?CdpStorage.CookiePartitionKey = null, })) orelse return error.InvalidParams; + if (params.partitionKey != null) return error.NotYetImplementedParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const cookies = &bc.session.cookie_jar.cookies; @@ -135,11 +132,8 @@ fn deleteCookies(cmd: anytype) !void { } fn clearBrowserCookies(cmd: anytype) !void { - _ = (try cmd.params(struct {})) orelse return error.InvalidParams; - const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; bc.session.cookie_jar.clearRetainingCapacity(); - return cmd.sendResult(null, .{}); } @@ -170,11 +164,15 @@ fn setCookies(cmd: anytype) !void { fn getCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; const params = (try cmd.params(struct { - urls: []const []const u8, + urls: ?[]const []const u8 = null, })) orelse return error.InvalidParams; - var urls = try std.ArrayListUnmanaged(CdpStorage.PreparedUri).initCapacity(cmd.arena, params.urls.len); - for (params.urls) |url| { + // If not specified, use the URLs of the page and all of its subframes. TODO subframes + const page_url = if (bc.session.page) |*page| page.url.raw else null; // @speed: avoid repasing the URL + const param_urls = params.urls orelse &[_][]const u8{page_url orelse return error.InvalidParams}; + + var urls = try std.ArrayListUnmanaged(CdpStorage.PreparedUri).initCapacity(cmd.arena, param_urls.len); + for (param_urls) |url| { const uri = std.Uri.parse(url) catch return error.InvalidParams; urls.appendAssumeCapacity(.{ @@ -354,3 +352,77 @@ test "cdp.network setExtraHTTPHeaders" { try ctx.processMessage(.{ .id = 5, .method = "Target.attachToTarget", .params = .{ .targetId = bc.target_id.? } }); try testing.expectEqual(bc.cdp.extra_headers.items.len, 0); } + +test "cdp.Network: cookies" { + const ResCookie = CdpStorage.ResCookie; + const CdpCookie = CdpStorage.CdpCookie; + + var ctx = testing.context(); + defer ctx.deinit(); + _ = try ctx.loadBrowserContext(.{ .id = "BID-S" }); + + // Initially empty + try ctx.processMessage(.{ + .id = 3, + .method = "Network.getCookies", + .params = .{ .urls = &[_][]const u8{"https://example.com/pancakes"} }, + }); + try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 3 }); + + // Has cookies after setting them + try ctx.processMessage(.{ + .id = 4, + .method = "Network.setCookie", + .params = CdpCookie{ .name = "test3", .value = "valuenot3", .url = "https://car.example.com/defnotpancakes" }, + }); + try ctx.expectSentResult(null, .{ .id = 4 }); + try ctx.processMessage(.{ + .id = 5, + .method = "Network.setCookies", + .params = .{ + .cookies = &[_]CdpCookie{ + .{ .name = "test3", .value = "value3", .url = "https://car.example.com/pan/cakes" }, + .{ .name = "test4", .value = "value4", .domain = "example.com", .path = "/mango" }, + }, + }, + }); + try ctx.expectSentResult(null, .{ .id = 5 }); + try ctx.processMessage(.{ + .id = 6, + .method = "Network.getCookies", + .params = .{ .urls = &[_][]const u8{"https://car.example.com/pan/cakes"} }, + }); + try ctx.expectSentResult(.{ + .cookies = &[_]ResCookie{ + .{ .name = "test3", .value = "value3", .domain = "car.example.com", .path = "/", .secure = true }, // No Pancakes! + }, + }, .{ .id = 6 }); + + // deleteCookies + try ctx.processMessage(.{ + .id = 7, + .method = "Network.deleteCookies", + .params = .{ .name = "test3", .domain = "car.example.com" }, + }); + try ctx.expectSentResult(null, .{ .id = 7 }); + try ctx.processMessage(.{ + .id = 8, + .method = "Storage.getCookies", + .params = .{ .browserContextId = "BID-S" }, + }); + // Just the untouched test4 should be in the result + try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{.{ .name = "test4", .value = "value4", .domain = ".example.com", .path = "/mango" }} }, .{ .id = 8 }); + + // Empty after clearBrowserCookies + try ctx.processMessage(.{ + .id = 9, + .method = "Network.clearBrowserCookies", + }); + try ctx.expectSentResult(null, .{ .id = 9 }); + try ctx.processMessage(.{ + .id = 10, + .method = "Storage.getCookies", + .params = .{ .browserContextId = "BID-S" }, + }); + try ctx.expectSentResult(.{ .cookies = &[_]ResCookie{} }, .{ .id = 10 }); +} From a9cee84c3daeef3fe7da8b68c172e328480205c7 Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Mon, 16 Jun 2025 11:54:53 +0200 Subject: [PATCH 13/15] JS may not set/get HttpOnly cookies --- src/browser/html/document.zig | 8 +++++++- src/browser/page.zig | 3 ++- src/browser/storage/cookie.zig | 33 ++++++++++++++++++++++++++++----- src/browser/xhr/xhr.zig | 1 + src/cdp/domains/storage.zig | 7 +++---- 5 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/browser/html/document.zig b/src/browser/html/document.zig index 68483a56a..ed406ea55 100644 --- a/src/browser/html/document.zig +++ b/src/browser/html/document.zig @@ -81,7 +81,7 @@ pub const HTMLDocument = struct { pub fn get_cookie(_: *parser.DocumentHTML, page: *Page) ![]const u8 { var buf: std.ArrayListUnmanaged(u8) = .{}; - try page.cookie_jar.forRequest(&page.url.uri, buf.writer(page.arena), .{ .navigation = true }); + try page.cookie_jar.forRequest(&page.url.uri, buf.writer(page.arena), .{ .navigation = true, .is_http = false }); return buf.items; } @@ -90,6 +90,10 @@ pub const HTMLDocument = struct { // outlives the page's arena. const c = try Cookie.parse(page.cookie_jar.allocator, &page.url.uri, cookie_str); errdefer c.deinit(); + if (c.http_only) { + c.deinit(); + return ""; // HttpOnly cookies cannot be set from JS + } try page.cookie_jar.add(c, std.time.timestamp()); return cookie_str; } @@ -333,6 +337,8 @@ test "Browser.HTML.Document" { .{ "document.cookie = 'name=Oeschger; SameSite=None; Secure'", "name=Oeschger; SameSite=None; Secure" }, .{ "document.cookie = 'favorite_food=tripe; SameSite=None; Secure'", "favorite_food=tripe; SameSite=None; Secure" }, .{ "document.cookie", "name=Oeschger; favorite_food=tripe" }, + .{ "document.cookie = 'IgnoreMy=Ghost; HttpOnly'", null }, // "" should be returned, but the framework overrules it atm + .{ "document.cookie", "name=Oeschger; favorite_food=tripe" }, }, .{}); try runner.testCases(&.{ diff --git a/src/browser/page.zig b/src/browser/page.zig index 368a3c5a7..8786f2298 100644 --- a/src/browser/page.zig +++ b/src/browser/page.zig @@ -217,7 +217,7 @@ pub const Page = struct { { // block exists to limit the lifetime of the request, which holds // onto a connection - var request = try self.newHTTPRequest(opts.method, &self.url, .{ .navigation = true }); + var request = try self.newHTTPRequest(opts.method, &self.url, .{ .navigation = true, .is_http = true }); defer request.deinit(); request.body = opts.body; @@ -513,6 +513,7 @@ pub const Page = struct { var request = try self.newHTTPRequest(.GET, &url, .{ .origin_uri = &origin_url.uri, .navigation = false, + .is_http = true, }); defer request.deinit(); diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index 1abd37e1c..eb9788261 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -12,6 +12,7 @@ pub const LookupOpts = struct { request_time: ?i64 = null, origin_uri: ?*const Uri = null, navigation: bool = true, + is_http: bool, }; pub const Jar = struct { @@ -91,7 +92,7 @@ pub const Jar = struct { var first = true; for (self.cookies.items) |*cookie| { - if (!cookie.appliesTo(&target, same_site, opts.navigation)) continue; + if (!cookie.appliesTo(&target, same_site, opts.navigation, opts.is_http)) continue; // we have a match! if (first) { @@ -411,7 +412,12 @@ pub const Cookie = struct { return .{ name, value, rest }; } - pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, navigation: bool) bool { + pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool, navigation: bool, is_http: bool) bool { + if (self.http_only and is_http == false) { + // http only cookies can be accessed from Javascript + return false; + } + if (url.secure == false and self.secure) { // secure cookie can only be sent over HTTPs return false; @@ -581,7 +587,7 @@ test "Jar: forRequest" { { // test with no cookies - try expectCookies("", &jar, test_uri, .{}); + try expectCookies("", &jar, test_uri, .{ .is_http = true }); } try jar.add(try Cookie.parse(testing.allocator, &test_uri, "global1=1"), now); @@ -595,97 +601,114 @@ test "Jar: forRequest" { try jar.add(try Cookie.parse(testing.allocator, &test_uri_2, "domain1=9;domain=test.lightpanda.io"), now); // nothing fancy here - try expectCookies("global1=1; global2=2", &jar, test_uri, .{}); - try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .origin_uri = &test_uri, .navigation = false }); + try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .is_http = true }); + try expectCookies("global1=1; global2=2", &jar, test_uri, .{ .origin_uri = &test_uri, .navigation = false, .is_http = true }); // We have a cookie where Domain=lightpanda.io // This should _not_ match xyxlightpanda.io try expectCookies("", &jar, try std.Uri.parse("http://anothersitelightpanda.io/"), .{ .origin_uri = &test_uri, + .is_http = true, }); // matching path without trailing / try expectCookies("global1=1; global2=2; path1=3", &jar, try std.Uri.parse("http://lightpanda.io/about"), .{ .origin_uri = &test_uri, + .is_http = true, }); // incomplete prefix path try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/abou"), .{ .origin_uri = &test_uri, + .is_http = true, }); // path doesn't match try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/aboutus"), .{ .origin_uri = &test_uri, + .is_http = true, }); // path doesn't match cookie directory try expectCookies("global1=1; global2=2", &jar, try std.Uri.parse("http://lightpanda.io/docs"), .{ .origin_uri = &test_uri, + .is_http = true, }); // exact directory match try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/"), .{ .origin_uri = &test_uri, + .is_http = true, }); // sub directory match try expectCookies("global1=1; global2=2; path2=4", &jar, try std.Uri.parse("http://lightpanda.io/docs/more"), .{ .origin_uri = &test_uri, + .is_http = true, }); // secure try expectCookies("global1=1; global2=2; secure=5", &jar, try std.Uri.parse("https://lightpanda.io/"), .{ .origin_uri = &test_uri, + .is_http = true, }); // navigational cross domain, secure try expectCookies("global1=1; global2=2; secure=5; sitenone=6; sitelax=7", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{ .origin_uri = &(try std.Uri.parse("https://example.com/")), + .is_http = true, }); // navigational cross domain, insecure try expectCookies("global1=1; global2=2; sitelax=7", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{ .origin_uri = &(try std.Uri.parse("https://example.com/")), + .is_http = true, }); // non-navigational cross domain, insecure try expectCookies("", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{ .origin_uri = &(try std.Uri.parse("https://example.com/")), .navigation = false, + .is_http = true, }); // non-navigational cross domain, secure try expectCookies("sitenone=6", &jar, try std.Uri.parse("https://lightpanda.io/x/"), .{ .origin_uri = &(try std.Uri.parse("https://example.com/")), .navigation = false, + .is_http = true, }); // non-navigational same origin try expectCookies("global1=1; global2=2; sitelax=7; sitestrict=8", &jar, try std.Uri.parse("http://lightpanda.io/x/"), .{ .origin_uri = &(try std.Uri.parse("https://lightpanda.io/")), .navigation = false, + .is_http = true, }); // exact domain match + suffix try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://test.lightpanda.io/"), .{ .origin_uri = &test_uri, + .is_http = true, }); // domain suffix match + suffix try expectCookies("global2=2; domain1=9", &jar, try std.Uri.parse("http://1.test.lightpanda.io/"), .{ .origin_uri = &test_uri, + .is_http = true, }); // non-matching domain try expectCookies("global2=2", &jar, try std.Uri.parse("http://other.lightpanda.io/"), .{ .origin_uri = &test_uri, + .is_http = true, }); const l = jar.cookies.items.len; try expectCookies("global1=1", &jar, test_uri, .{ .request_time = now + 100, .origin_uri = &test_uri, + .is_http = true, }); try testing.expectEqual(l - 1, jar.cookies.items.len); diff --git a/src/browser/xhr/xhr.zig b/src/browser/xhr/xhr.zig index 37f87beed..e7b116309 100644 --- a/src/browser/xhr/xhr.zig +++ b/src/browser/xhr/xhr.zig @@ -475,6 +475,7 @@ pub const XMLHttpRequest = struct { try self.cookie_jar.forRequest(&self.url.?.uri, arr.writer(self.arena), .{ .navigation = false, .origin_uri = &self.origin_url.uri, + .is_http = true, }); if (arr.items.len > 0) { diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index cc26437b1..c2fb0d13b 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -133,8 +133,6 @@ pub fn setCdpCookie(cookie_jar: *CookieJar, param: CdpCookie) !void { if (param.priority != .Medium or param.sameParty != null or param.sourceScheme != null or param.partitionKey != null) { return error.NotYetImplementedParams; } - if (param.name.len == 0) return error.InvalidParams; - if (param.value.len == 0) return error.InvalidParams; var arena = std.heap.ArenaAllocator.init(cookie_jar.allocator); errdefer arena.deinit(); @@ -183,7 +181,7 @@ pub const CookieWriter = struct { if (self.urls) |urls| { for (self.cookies) |*cookie| { for (urls) |*url| { - if (cookie.appliesTo(url, true, true)) { // TBD same_site, should we compare to the pages url? + if (cookie.appliesTo(url, true, true, true)) { // TBD same_site, should we compare to the pages url? try writeCookie(cookie, w); break; } @@ -223,7 +221,8 @@ pub fn writeCookie(cookie: *const Cookie, w: anytype) !void { try w.objectField("secure"); try w.write(cookie.secure); - // TODO session + try w.objectField("session"); + try w.write(cookie.expires == null); try w.objectField("sameSite"); switch (cookie.same_site) { From ea5e3967f151da56aadffb6a18d9a9172b513ccd Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Tue, 17 Jun 2025 12:12:35 +0200 Subject: [PATCH 14/15] expires dashes and f64 --- src/browser/storage/cookie.zig | 44 +++++++++++++++++++++++----------- src/cdp/domains/storage.zig | 4 ++-- 2 files changed, 32 insertions(+), 16 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index eb9788261..ebc324942 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -129,7 +129,7 @@ pub const Jar = struct { fn isCookieExpired(cookie: *const Cookie, now: i64) bool { const ce = cookie.expires orelse return false; - return ce <= now; + return ce <= @as(f64, @floatFromInt(now)); } fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool { @@ -174,7 +174,7 @@ pub const Cookie = struct { value: []const u8, domain: []const u8, path: []const u8, - expires: ?i64, + expires: ?f64, secure: bool = false, http_only: bool = false, same_site: SameSite = .none, @@ -226,7 +226,7 @@ pub const Cookie = struct { var secure: ?bool = null; var max_age: ?i64 = null; var http_only: ?bool = null; - var expires: ?DateTime = null; + var expires: ?[]const u8 = null; var same_site: ?Cookie.SameSite = null; var it = std.mem.splitScalar(u8, rest, ';'); @@ -258,7 +258,7 @@ pub const Cookie = struct { .domain => domain = value, .secure => secure = true, .@"max-age" => max_age = std.fmt.parseInt(i64, value, 10) catch continue, - .expires => expires = DateTime.parse(value, .rfc822) catch continue, + .expires => expires = value, .httponly => http_only = true, .samesite => { same_site = std.meta.stringToEnum(Cookie.SameSite, std.ascii.lowerString(&scrap, value)) orelse continue; @@ -278,13 +278,25 @@ pub const Cookie = struct { const owned_path = try parsePath(aa, uri, path); const owned_domain = try parseDomain(aa, uri, domain); - var normalized_expires: ?i64 = null; + var normalized_expires: ?f64 = null; if (max_age) |ma| { - normalized_expires = std.time.timestamp() + ma; + normalized_expires = @floatFromInt(std.time.timestamp() + ma); } else { // max age takes priority over expires - if (expires) |e| { - normalized_expires = e.sub(DateTime.now(), .seconds); + if (expires) |expires_| { + var exp_dt = DateTime.parse(expires_, .rfc822) catch null; + if (exp_dt == null) { + if ((expires_.len > 11 and expires_[7] == '-' and expires_[11] == '-')) { + // Replace dashes and try again + const output = try aa.dupe(u8, expires_); + output[7] = ' '; + output[11] = ' '; + exp_dt = DateTime.parse(output, .rfc822) catch null; + } + } + if (exp_dt) |dt| { + normalized_expires = @floatFromInt(dt.unix(.seconds)); + } else std.debug.print("Invalid cookie expires value: {s}\n", .{expires_}); } } @@ -838,7 +850,8 @@ test "Cookie: parse expires" { try expectAttribute(.{ .expires = null }, null, "b;expires=13.22"); try expectAttribute(.{ .expires = null }, null, "b;expires=33"); - try expectAttribute(.{ .expires = 1918798080 - std.time.timestamp() }, null, "b;expires=Wed, 21 Oct 2030 07:28:00 GMT"); + try expectAttribute(.{ .expires = 1918798080 }, null, "b;expires=Wed, 21 Oct 2030 07:28:00 GMT"); + try expectAttribute(.{ .expires = 1784275395 }, null, "b;expires=Fri, 17-Jul-2026 08:03:15 GMT"); // max-age has priority over expires try expectAttribute(.{ .expires = std.time.timestamp() + 10 }, null, "b;Max-Age=10; expires=Wed, 21 Oct 2030 07:28:00 GMT"); } @@ -858,7 +871,7 @@ test "Cookie: parse all" { .http_only = true, .secure = true, .domain = ".lightpanda.io", - .expires = std.time.timestamp() + 30, + .expires = @floatFromInt(std.time.timestamp() + 30), }, "https://lightpanda.io/cms/users", "user-id=9000; HttpOnly; Max-Age=30; Secure; path=/; Domain=lightpanda.io"); try expectCookie(.{ @@ -869,7 +882,7 @@ test "Cookie: parse all" { .secure = false, .domain = ".localhost", .same_site = .lax, - .expires = std.time.timestamp() + 7200, + .expires = @floatFromInt(std.time.timestamp() + 7200), }, "http://localhost:8000/login", "app_session=123; Max-Age=7200; path=/; domain=localhost; httponly; samesite=lax"); } @@ -896,7 +909,7 @@ const ExpectedCookie = struct { value: []const u8, path: []const u8, domain: []const u8, - expires: ?i64 = null, + expires: ?f64 = null, secure: bool = false, http_only: bool = false, same_site: Cookie.SameSite = .lax, @@ -915,7 +928,7 @@ fn expectCookie(expected: ExpectedCookie, url: []const u8, set_cookie: []const u try testing.expectEqual(expected.path, cookie.path); try testing.expectEqual(expected.domain, cookie.domain); - try testing.expectDelta(expected.expires, cookie.expires, 2); + try testing.expectDelta(expected.expires, cookie.expires, 2.0); } fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8) !void { @@ -925,7 +938,10 @@ fn expectAttribute(expected: anytype, url: ?[]const u8, set_cookie: []const u8) inline for (@typeInfo(@TypeOf(expected)).@"struct".fields) |f| { if (comptime std.mem.eql(u8, f.name, "expires")) { - try testing.expectDelta(expected.expires, cookie.expires, 1); + switch (@typeInfo(@TypeOf(expected.expires))) { + .int, .comptime_int => try testing.expectDelta(@as(f64, @floatFromInt(expected.expires)), cookie.expires, 1.0), + else => try testing.expectDelta(expected.expires, cookie.expires, 1.0), + } } else { try testing.expectEqual(@field(expected, f.name), @field(cookie, f.name)); } diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index c2fb0d13b..c9c013239 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -121,7 +121,7 @@ pub const CdpCookie = struct { secure: ?bool = null, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 httpOnly: bool = false, // default: https://www.rfc-editor.org/rfc/rfc6265#section-5.3 sameSite: SameSite = .None, // default: https://datatracker.ietf.org/doc/html/draft-west-first-party-cookies - expires: ?i64 = null, // -1? says google + expires: ?f64 = null, // -1? says google priority: CookiePriority = .Medium, // default: https://datatracker.ietf.org/doc/html/draft-west-cookie-priority-00 sameParty: ?bool = null, sourceScheme: ?CookieSourceScheme = null, @@ -296,7 +296,7 @@ pub const ResCookie = struct { value: []const u8, domain: []const u8, path: []const u8 = "/", - expires: i32 = -1, + expires: f64 = -1, httpOnly: bool = false, secure: bool = false, sameSite: []const u8 = "None", From 91261dbfd6871483ab695e71e44b2a1a1583d8fc Mon Sep 17 00:00:00 2001 From: sjorsdonkers <72333389+sjorsdonkers@users.noreply.github.com> Date: Tue, 17 Jun 2025 18:13:06 +0200 Subject: [PATCH 15/15] handle no params --- src/cdp/domains/network.zig | 6 +++--- src/cdp/domains/storage.zig | 10 ++++------ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/cdp/domains/network.zig b/src/cdp/domains/network.zig index 2cee435df..7a5cd02be 100644 --- a/src/cdp/domains/network.zig +++ b/src/cdp/domains/network.zig @@ -132,6 +132,7 @@ fn deleteCookies(cmd: anytype) !void { } fn clearBrowserCookies(cmd: anytype) !void { + if (try cmd.params(struct {}) != null) return error.InvalidParams; const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; bc.session.cookie_jar.clearRetainingCapacity(); return cmd.sendResult(null, .{}); @@ -161,11 +162,10 @@ fn setCookies(cmd: anytype) !void { try cmd.sendResult(null, .{}); } +const GetCookiesParam = struct { urls: ?[]const []const u8 = null }; fn getCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const params = (try cmd.params(struct { - urls: ?[]const []const u8 = null, - })) orelse return error.InvalidParams; + const params = (try cmd.params(GetCookiesParam)) orelse GetCookiesParam{}; // If not specified, use the URLs of the page and all of its subframes. TODO subframes const page_url = if (bc.session.page) |*page| page.url.raw else null; // @speed: avoid repasing the URL diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig index c9c013239..f26bdfef5 100644 --- a/src/cdp/domains/storage.zig +++ b/src/cdp/domains/storage.zig @@ -38,11 +38,11 @@ pub fn processMessage(cmd: anytype) !void { } } +const BrowserContextParam = struct { browserContextId: ?[]const u8 = null }; + fn clearCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const params = (try cmd.params(struct { - browserContextId: ?[]const u8 = null, - })) orelse return error.InvalidParams; + const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{}; if (params.browserContextId) |browser_context_id| { if (std.mem.eql(u8, browser_context_id, bc.id) == false) { @@ -57,9 +57,7 @@ fn clearCookies(cmd: anytype) !void { fn getCookies(cmd: anytype) !void { const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; - const params = (try cmd.params(struct { - browserContextId: ?[]const u8 = null, - })) orelse return error.InvalidParams; + const params = (try cmd.params(BrowserContextParam)) orelse BrowserContextParam{}; if (params.browserContextId) |browser_context_id| { if (std.mem.eql(u8, browser_context_id, bc.id) == false) {