diff --git a/src/browser/html/document.zig b/src/browser/html/document.zig index 68483a56a..f1305bb83 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'", "" }, + .{ "document.cookie", "name=Oeschger; favorite_food=tripe" }, }, .{}); try runner.testCases(&.{ diff --git a/src/browser/page.zig b/src/browser/page.zig index 396022dba..23f90ea2b 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; @@ -498,6 +498,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 bdd93e3f7..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 { @@ -32,6 +33,13 @@ pub const Jar = struct { self.cookies.deinit(self.allocator); } + pub fn clearRetainingCapacity(self: *Jar) void { + for (self.cookies.items) |c| { + c.deinit(); + } + self.cookies.clearRetainingCapacity(); + } + pub fn add( self: *Jar, cookie: Cookie, @@ -59,87 +67,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, opts.is_http)) 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; @@ -173,44 +127,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; @@ -256,12 +172,12 @@ 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, - http_only: bool, - same_site: SameSite, + secure: bool = false, + http_only: bool = false, + same_site: SameSite = .none, const SameSite = enum { strict, @@ -292,9 +208,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) { @@ -339,34 +252,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; - }, + .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, @@ -386,19 +275,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 = 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); - }; + 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| { @@ -423,6 +301,100 @@ pub const Cookie = struct { }; } + 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| { + if (path.len > 0 and path[0] == '/') { + return try arena.dupe(u8, path); + } + } + + // 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 "/"; + } + + 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 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) { + 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 (encoded_host) |host| { + if (std.mem.endsWith(u8, host, owned_domain[1..]) == false) { + return error.InvalidDomain; + } + } + + return owned_domain; + } + } + + return encoded_host orelse return error.InvalidDomain; // default-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 ..]; @@ -439,17 +411,77 @@ pub const Cookie = struct { const value = trim(str[sep + 1 .. key_value_end]); return .{ name, value, rest }; } -}; -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 "/"; + 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; + } + + 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; } - const last = std.mem.lastIndexOfScalar(u8, document_path[1..], '/') orelse { - return "/"; - }; - return try allocator.dupe(u8, document_path[0 .. last + 1]); -} +}; + +pub const PreparedUri = struct { + host: []const u8, // Percent encoded, lower case + path: []const u8, // Percent encoded + secure: bool, // True if scheme is https +}; fn trim(str: []const u8) []const u8 { return std.mem.trim(u8, str, &std.ascii.whitespace); @@ -463,6 +495,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 }{ @@ -548,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); @@ -562,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); @@ -660,40 +716,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/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/cdp.zig b/src/cdp/cdp.zig index 3b0005f4a..332c670c8 100644 --- a/src/cdp/cdp.zig +++ b/src/cdp/cdp.zig @@ -204,6 +204,7 @@ pub fn CDPT(comptime TypeProvider: type) type { asUint("Browser") => return @import("domains/browser.zig").processMessage(command), asUint("Runtime") => return @import("domains/runtime.zig").processMessage(command), asUint("Network") => return @import("domains/network.zig").processMessage(command), + asUint("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 a161f589c..2cee435df 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 { @@ -28,6 +29,11 @@ pub fn processMessage(cmd: anytype) !void { disable, setCacheDisabled, setExtraHTTPHeaders, + deleteCookies, + clearBrowserCookies, + setCookie, + setCookies, + getCookies, }, cmd.input.action) orelse return error.UnknownMethod; switch (action) { @@ -35,6 +41,11 @@ pub fn processMessage(cmd: anytype) !void { .disable => return disable(cmd), .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), + .getCookies => return getCookies(cmd), } } @@ -71,6 +82,112 @@ fn setExtraHTTPHeaders(cmd: anytype) !void { return cmd.sendResult(null, .{}); } +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; + + if (domain) |domain_| { + 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; + } + 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: ?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; + + 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 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(); + } + } + return cmd.sendResult(null, .{}); +} + +fn clearBrowserCookies(cmd: anytype) !void { + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + bc.session.cookie_jar.clearRetainingCapacity(); + return cmd.sendResult(null, .{}); +} + +fn setCookie(cmd: anytype) !void { + const params = (try cmd.params( + CdpStorage.CdpCookie, + )) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + 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 CdpStorage.CdpCookie, + })) orelse return error.InvalidParams; + + const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded; + for (params.cookies) |param| { + try CdpStorage.setCdpCookie(&bc.session.cookie_jar, param); + } + + 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 = null, + })) orelse return error.InvalidParams; + + // 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(.{ + .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; + 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 { @@ -235,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 }); +} diff --git a/src/cdp/domains/storage.zig b/src/cdp/domains/storage.zig new file mode 100644 index 000000000..c2fb0d13b --- /dev/null +++ b/src/cdp/domains/storage.zig @@ -0,0 +1,303 @@ +// Copyright (C) 2023-2025 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 const PreparedUri = @import("../../browser/storage/cookie.zig").PreparedUri; + +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 = null, + })) 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 = null, + })) 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.removeExpired(null); + const writer = CookieWriter{ .cookies = bc.session.cookie_jar.cookies.items }; + try cmd.sendResult(.{ .cookies = writer }, .{}); +} + +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 = null, + })) 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 = 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 + 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; + } + + 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, (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 = 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, + .name = try a.dupe(u8, param.name), + .value = try a.dupe(u8, param.value), + .path = path, + .domain = domain, + .expires = param.expires, + .secure = secure, + .http_only = param.httpOnly, + .same_site = switch (param.sameSite) { + .Strict => .strict, + .Lax => .lax, + .None => .none, + }, + }; + try cookie_jar.add(cookie, std.time.timestamp()); +} + +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| { + // 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(); + if (self.urls) |urls| { + for (self.cookies) |*cookie| { + for (urls) |*url| { + if (cookie.appliesTo(url, true, true, true)) { // 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); + + try w.objectField("value"); + try w.write(cookie.value); + + try w.objectField("domain"); + try w.write(cookie.domain); // Should we hide a leading dot? + + 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); + + try w.objectField("session"); + try w.write(cookie.expires == null); + + 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(); +} + +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", +}; diff --git a/src/runtime/js.zig b/src/runtime/js.zig index 86619c6a4..137a2c10f 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) { @@ -2475,13 +2475,9 @@ fn Caller(comptime E: type, comptime State: type) type { else => @compileError(named_function.full_name ++ " setter with more than 3 parameters, why?"), } - if (@typeInfo(Setter).@"fn".return_type) |return_type| { - if (@typeInfo(return_type) == .error_union) { - _ = try @call(.auto, func, args); - return; - } - } - _ = @call(.auto, func, args); + // TODO: Do not set res for void type to allow default input return behavior, but how to allow get to return undefined? + const res = @call(.auto, func, args); + info.getReturnValue().set(try js_context.zigValueToJs(res)); } fn getIndex(self: *Self, comptime Struct: type, comptime named_function: NamedFunction, idx: u32, info: v8.PropertyCallbackInfo) !u8 {