From 30ee41fd0e1a1bffe3538de9288dc28b08fb326a 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] Network.getCookies --- src/browser/storage/cookie.zig | 235 ++++++++++++--------------------- src/cdp/domains/network.zig | 41 +++++- src/cdp/domains/storage.zig | 145 +++++++++++--------- 3 files changed, 211 insertions(+), 210 deletions(-) diff --git a/src/browser/storage/cookie.zig b/src/browser/storage/cookie.zig index aa93ba21..db7a29a2 100644 --- a/src/browser/storage/cookie.zig +++ b/src/browser/storage/cookie.zig @@ -66,87 +66,33 @@ pub const Jar = struct { } } + 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(); + } + } + } + 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 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); - 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(); + removeExpired(self, opts.request_time); var first = true; - while (i < cookies.len) { - const cookie = &cookies[i]; + for (self.cookies.items) |*cookie| { + if (!cookie.appliesTo(&target, same_site, opts.navigation)) continue; - if (isCookieExpired(cookie, request_time)) { - cookie.deinit(); - _ = self.cookies.swapRemove(i); - // don't increment i ! - continue; - } - i += 1; - - if (is_secure == false and cookie.secure) { - // secure cookie can only be sent over HTTPs - continue; - } - - 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 => {}, - } - } - - { - 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; - } - } - - { - 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 c7ad0c60..9c0fe27b 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 654d976b..2efd1c35 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(); } - - 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(); - } }; +pub 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(); +}