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 {