Merge pull request #3293 from lightpanda-io/nikneym/samesite-strict-bypass

compute site-for-cookies from ancestor chain of a `Frame`
This commit is contained in:
Karl Seguin authored and GitHub committed 2026-09-02 07:54:24 +08:00
commit eea50fe98a
21 files changed
+373 -83

No files matched your search

+85 -15
View File
@@ -43,6 +43,7 @@ const EventTarget = @import("webapi/EventTarget.zig");
const Element = @import("webapi/Element.zig");
const HtmlElement = @import("webapi/element/Html.zig");
const Window = @import("webapi/Window.zig");
const Cookie = @import("webapi/storage/Cookie.zig");
const Location = @import("webapi/Location.zig");
const Document = @import("webapi/Document.zig");
const ShadowRoot = @import("webapi/ShadowRoot.zig");
@@ -603,12 +604,29 @@ fn referrerSource(self: *const Frame) [:0]const u8 {
var frame = self;
while (std.mem.startsWith(u8, frame.url, "about:")) {
// about:blank and about:srcdoc documents aren't valid referrer sources,
// use the parents
// use the parents.
frame = frame.parent orelse return frame.url;
}
return frame.url;
}
// RFC 6265bis "site for cookies" for SameSite checks on requests this frame does.
pub fn siteForCookies(self: *const Frame) Cookie.SiteForCookies {
const own_url = self.referrerSource();
const own_host = URL.getHostname(own_url);
var frame: *const Frame = self;
while (frame.parent) |parent| : (frame = parent) {
const parent_host = URL.getHostname(parent.url);
if (parent_host.len == 0) {
continue;
}
if (Cookie.areHostsSameSite(parent_host, own_host) == false) {
return .none;
}
}
return .{ .url = own_url };
}
pub fn getTitle(self: *Frame) !?[]const u8 {
if (self.window._document.is(Document.HTMLDocument)) |html_doc| {
return try html_doc.getTitle(self);
@@ -841,7 +859,7 @@ pub fn navigate(self: *Frame, request_url: [:0]const u8, opts: NavigateOpts) !vo
.skip_cache = self.parent == null,
.throttle = self.parent == null,
.cookie_jar = &session.cookie_jar,
.cookie_origin = opts.initiator_url orelse self.url,
.cookie_origin = opts.initiator_url orelse .{ .url = self.url },
.resource_type = .document,
.notification = self._session.notification,
.header_callback = frameHeaderDoneCallback,
@@ -1039,8 +1057,20 @@ fn scheduleNavigationWithArena(originator: *Frame, arena: *lp.Arena, request_url
nav_opts.referer = try referrer.compute(arena.allocator(), originator.referrer_policy, referrer_source, resolved_url);
nav_opts.referrer_policy = originator.referrer_policy;
}
if (nav_opts.initiator_url == null) {
nav_opts.initiator_url = try arena.dupeZ(u8, referrer_source);
}
if (nav_opts.initiator_url == null) {
if (target.parent) |parent| {
// A subframe navigation's SameSite initiator is the frame's whole
// ancestor chain, not the document that triggered the navigation.
if (std.mem.startsWith(u8, parent.referrerSource(), "http")) {
nav_opts.initiator_url = switch (parent.siteForCookies()) {
.none => .none,
.url => |u| .{ .url = try arena.dupeZ(u8, u) },
};
}
} else if (std.mem.startsWith(u8, referrer_source, "http")) {
// Top-level navigation.
nav_opts.initiator_url = .{ .url = try arena.dupeZ(u8, referrer_source) };
}
}
if (nav_opts.initiator_origin == null) {
@@ -1981,17 +2011,15 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void {
self.child_frames_sorted = false;
// Iframe's initial src request carries the parent's URL as Referer
// (subject to the parent's Referrer-Policy) and as the SameSite
// initiator. When this frame is itself an about: document, the nearest
// ancestor's URL is the referrer source. Parent frame outlives this
// navigate() call, so the slice is safe; navigate dupes what it keeps.
// (subject to the parent's Referrer-Policy). The SameSite initiator is
// the parent's ancestor chain.
const referrer_source = self.referrerSource();
const parent_url: ?[:0]const u8 = if (std.mem.startsWith(u8, referrer_source, "http")) referrer_source else null;
const initiator_url: ?Cookie.SiteForCookies = if (std.mem.startsWith(u8, referrer_source, "http")) self.siteForCookies() else null;
new_frame.navigate(url, .{
.reason = .initialFrameNavigation,
.referer = try referrer.compute(self.call_arena, self.referrer_policy, referrer_source, url),
.referrer_policy = self.referrer_policy,
.initiator_url = parent_url,
.initiator_url = initiator_url,
.initiator_origin = self.origin,
}) catch |err| {
// extra defensive..maybe navigate added a new frame, and the index it
@@ -2346,7 +2374,7 @@ pub fn loadExternalStylesheet(self: *Frame, link: *Element.Html.Link, href: []co
.frame_id = self._frame_id,
.loader_id = self._loader_id,
.cookie_jar = &session.cookie_jar,
.cookie_origin = self.url,
.cookie_origin = self.siteForCookies(),
.resource_type = .stylesheet,
.notification = session.notification,
.shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own
@@ -3362,11 +3390,14 @@ pub const NavigateOpts = struct {
// can recompute the header. null (e.g. a CDP-supplied referrer) leaves
// the Referer untouched across redirects.
referrer_policy: ?referrer.Policy = null,
// The URL of the document that initiated this navigation, used as the
// "site for cookies" when computing SameSite. Distinct from `referer`
// The "site for cookies" of the document that initiated this navigation,
// used when computing SameSite: for a subframe, the whole ancestor
// chain's site (or .none when that chain crosses sites); for a top-level
// navigation, the initiating document's URL. Distinct from `referer`
// because a Referrer-Policy can suppress the Referer header without
// affecting SameSite (which always considers the real initiator).
initiator_url: ?[:0]const u8 = null,
// affecting SameSite (which always considers the real initiator). null
// means browser-initiated, which cookie lookup treats as same-site.
initiator_url: ?Cookie.SiteForCookies = null,
initiator_origin: ?[]const u8 = null,
force: bool = false,
kind: NavigationKind = .{ .push = null },
@@ -3801,6 +3832,45 @@ test "Page: isSameOrigin" {
try testing.expectEqual(false, frame.isSameOrigin("//origin.com/foo"));
}
test "Frame: siteForCookies" {
var top: Frame = undefined;
top.parent = null;
top.url = "http://attacker.example/attacker-nested";
var middle: Frame = undefined;
middle.parent = ⊤
middle.url = "http://victim.example/nested-middle";
var inner: Frame = undefined;
inner.parent = &middle;
inner.url = "http://victim.example/inner";
// A top-level document is its own site.
try testing.expectEqual("http://attacker.example/attacker-nested", top.siteForCookies().url);
// Cross-site with the top-level document: no site for cookies — for the
// directly-embedded frame and for the same-site-with-parent frame nested
// under it alike.
try testing.expectEqual(true, middle.siteForCookies() == .none);
try testing.expectEqual(true, inner.siteForCookies() == .none);
// A fully same-site chain (subdomains included) keeps its site.
top.url = "http://victim.example/";
middle.url = "http://sub.victim.example/nested-middle";
try testing.expectEqual("http://victim.example/inner", inner.siteForCookies().url);
try testing.expectEqual("http://sub.victim.example/nested-middle", middle.siteForCookies().url);
// about: documents inherit their creator's origin: transparent as an
// ancestor, and judged through their nearest real ancestor themselves.
middle.url = "about:blank";
try testing.expectEqual("http://victim.example/inner", inner.siteForCookies().url);
try testing.expectEqual("http://victim.example/", middle.siteForCookies().url);
top.url = "http://attacker.example/";
try testing.expectEqual(true, inner.siteForCookies() == .none);
try testing.expectEqual("http://attacker.example/", middle.siteForCookies().url);
}
test "Frame: static immediate meta refresh navigates" {
const page = try testing.pageTest("fixtures/meta_refresh.html", .{});
defer page.close();
+3 -3
View File
@@ -137,7 +137,7 @@ pub fn preloadScript(self: *ScriptManager, element: ?*Element.Html, url: []const
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.cookie_origin = frame.siteForCookies(),
.resource_type = .script,
.notification = frame._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
@@ -353,7 +353,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.cookie_origin = frame.siteForCookies(),
.resource_type = .script,
.notification = frame._session.notification,
.shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own
@@ -402,7 +402,7 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.cookie_jar = &frame._session.cookie_jar,
.cookie_origin = frame.url,
.cookie_origin = frame.siteForCookies(),
.resource_type = .script,
.notification = frame._session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
+11 -2
View File
@@ -27,6 +27,7 @@ const Session = @import("Session.zig");
const Frame = @import("Frame.zig");
const ImportMap = @import("ImportMap.zig");
const WorkerGlobalScope = @import("webapi/WorkerGlobalScope.zig");
const Cookie = @import("webapi/storage/Cookie.zig");
const Element = @import("webapi/Element.zig");
@@ -78,6 +79,14 @@ pub const Owner = union(enum) {
inline else => |g| g.makeRequest(req),
};
}
// `Execution.siteForCookies` ditto.
pub fn siteForCookies(self: Owner) Cookie.SiteForCookies {
return switch (self) {
.frame => |frame| frame.siteForCookies(),
.worker => |worker| worker.site_for_cookies,
};
}
};
owner: Owner,
@@ -265,7 +274,7 @@ pub fn preloadImport(self: *ScriptManagerBase, url: [:0]const u8, referrer: []co
.frame_id = owner.frameId(),
.loader_id = owner.loaderId(),
.cookie_jar = &session.cookie_jar,
.cookie_origin = owner.url(),
.cookie_origin = owner.siteForCookies(),
.resource_type = .script,
.notification = session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
@@ -460,7 +469,7 @@ pub fn getAsyncImport(self: *ScriptManagerBase, url: [:0]const u8, cb: ImportAsy
.loader_id = owner.loaderId(),
.resource_type = .script,
.cookie_jar = &session.cookie_jar,
.cookie_origin = owner.url(),
.cookie_origin = owner.siteForCookies(),
.notification = session.notification,
.start_callback = if (log.enabled(.http, .debug)) Script.startCallback else null,
.header_callback = Script.headerCallback,
+1 -1
View File
@@ -82,7 +82,7 @@ pub fn image(frame: *Frame, img: *Element.Html.Image, src: []const u8) !void {
.frame_id = frame._frame_id,
.loader_id = frame._loader_id,
.cookie_jar = &session.cookie_jar,
.cookie_origin = frame.url,
.cookie_origin = frame.siteForCookies(),
.resource_type = .image,
.notification = session.notification,
.headers_only = true,
+9
View File
@@ -37,6 +37,7 @@ const HttpClient = @import("../../network/HttpClient.zig");
const EventManagerBase = @import("../EventManagerBase.zig");
const Console = @import("../webapi/Console.zig");
const Cookie = @import("../webapi/storage/Cookie.zig");
const Event = @import("../webapi/Event.zig");
const EventTarget = @import("../webapi/EventTarget.zig");
const Performance = @import("../webapi/Performance.zig");
@@ -132,6 +133,14 @@ pub fn origin(self: *const Execution) ?[]const u8 {
};
}
// a Worker inherits its creating document's; `Frame` walks its ancestor chain.
pub fn siteForCookies(self: *const Execution) Cookie.SiteForCookies {
return switch (self.js.global) {
.frame => |frame| frame.siteForCookies(),
.worker => |worker| worker.site_for_cookies,
};
}
// HttpClient.Owner of the current global (Frame or WGS). Used by code
// that needs to register an in-flight network operation against the
// owning scope without caring whether it's a Frame or a Worker — e.g.
+53 -1
View File
@@ -294,7 +294,8 @@ pub fn getCookie(self: *Document, frame: *Frame) ![]const u8 {
var aw: std.Io.Writer.Allocating = .init(frame.local_arena);
try frame._session.cookie_jar.forRequest(frame.url, &aw.writer, .{
.is_http = false,
.is_navigation = true,
.is_navigation = false,
.origin_url = frame.siteForCookies(),
});
return aw.written();
}
@@ -314,6 +315,10 @@ pub fn setCookie(self: *Document, cookie_str: []const u8, frame: *Frame) ![]cons
c.deinit();
return ""; // HttpOnly cookies cannot be set from JS
}
if (c.same_site != .none and frame.siteForCookies() == .none) {
c.deinit();
return ""; // SameSite cookies cannot be set from a cross-site context.
}
try frame._session.cookie_jar.add(c, lp.datetime.timestamp(.real), false);
return cookie_str;
}
@@ -1641,6 +1646,53 @@ test "WebApi: Document.evaluate" {
try testing.htmlRunner("xpath/document_evaluate.html", .{});
}
test "Document: cookie access from a cross-site frame" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
const doc = frame.document;
const jar = &frame._session.cookie_jar;
defer jar.clearRetainingCapacity();
// victim.example embedded by attacker.example: the ancestor chain is
// cross-site, so the frame has no site for cookies.
var top: Frame = undefined;
top.parent = null;
top.url = "https://attacker.example/";
frame.url = "https://victim.example/inner";
frame.parent = ⊤
defer frame.parent = null;
try jar.populateFromResponse("https://victim.example/", "strict=1; SameSite=Strict");
try jar.populateFromResponse("https://victim.example/", "lax=2; SameSite=Lax");
try jar.populateFromResponse("https://victim.example/", "default=3");
try jar.populateFromResponse("https://victim.example/", "none=4; SameSite=None; Secure");
// Reads: only SameSite=None is visible from a cross-site context. Lax
// gets no navigation exception for script access.
try testing.expectEqual("none=4", try doc.getCookie(frame));
// Writes: SameSite=None is stored, everything else (including the Lax
// default for an unspecified attribute) is silently dropped.
_ = try doc.setCookie("set_strict=5; SameSite=Strict", frame);
_ = try doc.setCookie("set_lax=6; SameSite=Lax", frame);
_ = try doc.setCookie("set_default=7", frame);
_ = try doc.setCookie("set_none=8; SameSite=None; Secure", frame);
try testing.expectEqual("none=4; set_none=8", try doc.getCookie(frame));
// The same jar seen from a same-site chain: everything applies, and the
// dropped writes really were dropped rather than hidden.
top.url = "https://victim.example/";
try testing.expectEqual("strict=1; lax=2; default=3; none=4; set_none=8", try doc.getCookie(frame));
_ = try doc.setCookie("set_strict=5; SameSite=Strict", frame);
_ = try doc.setCookie("set_default=7", frame);
try testing.expectEqual("strict=1; lax=2; default=3; none=4; set_none=8; set_strict=5; set_default=7", try doc.getCookie(frame));
// Back in the cross-site context, the newly written cookies obey the
// same visibility rules.
top.url = "https://attacker.example/";
try testing.expectEqual("none=4; set_none=8", try doc.getCookie(frame));
}
test "Document: isRelaxableTo" {
// Pure opt-in (relax to self) is always allowed, including IP hosts.
try testing.expectEqual(true, isRelaxableTo("a.example.com", "a.example.com"));
@@ -108,7 +108,7 @@ pub fn init(frame: *Frame, url: [:0]const u8, name: []const u8, worker_type: Wor
.loader_id = self._loader_id,
.resource_type = .script,
.cookie_jar = &session.cookie_jar,
.cookie_origin = owned_url,
.cookie_origin = proto.site_for_cookies,
.notification = session.notification,
.header_callback = httpHeaderCallback,
.data_callback = httpDataCallback,
+1 -1
View File
@@ -107,7 +107,7 @@ pub fn init(url: []const u8, options: ?WorkerOptions, frame: *Frame) !*Worker {
.loader_id = self._loader_id,
.resource_type = .script,
.cookie_jar = &session.cookie_jar,
.cookie_origin = resolved_url,
.cookie_origin = frame.siteForCookies(),
.notification = session.notification,
.header_callback = httpHeaderCallback,
.data_callback = httpDataCallback,
+8 -1
View File
@@ -46,6 +46,7 @@ const ErrorEvent = @import("event/ErrorEvent.zig");
const Fetch = @import("net/Fetch.zig");
const idb = @import("storage/idb/idb.zig");
const CookieStore = @import("storage/CookieStore.zig");
const Cookie = @import("storage/Cookie.zig");
const MessagePort = @import("MessagePort.zig");
const SharedWorkerGlobalScope = @import("SharedWorkerGlobalScope.zig");
const DedicatedWorkerGlobalScope = @import("DedicatedWorkerGlobalScope.zig");
@@ -78,6 +79,8 @@ local_arena: Allocator,
url: [:0]const u8,
// Same-origin constraint: a worker's origin is inherited from its parent frame.
origin: ?[]const u8 = null,
// Inherited from the creating frame, like origin.
site_for_cookies: Cookie.SiteForCookies,
buf: [1024]u8 = undefined, // same size as frame.buf
// Document charset (matches Page.charset). Workers default to UTF-8.
charset: []const u8 = "UTF-8",
@@ -148,6 +151,10 @@ pub fn init(
.url = url,
.arena = arena,
.origin = frame.origin,
.site_for_cookies = switch (frame.siteForCookies()) {
.none => .none,
.url => |u| .{ .url = try arena.dupeZ(u8, u) },
},
.js = undefined,
._call_arena = call_arena,
._local_arena = local_arena,
@@ -423,7 +430,7 @@ fn importScript(self: *WorkerGlobalScope, arena: Allocator, url: [:0]const u8) !
.document_frame_id = self._frame._frame_id,
.loader_id = self._loader_id,
.cookie_jar = &session.cookie_jar,
.cookie_origin = self.url,
.cookie_origin = self.site_for_cookies,
.resource_type = .script,
.notification = session.notification,
.shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own
+1 -1
View File
@@ -183,7 +183,7 @@ fn connect(self: *EventSource) !void {
.frame_id = exec.frameId(),
.loader_id = exec.loaderId(),
.cookie_jar = if (cookie_support) &session.cookie_jar else null,
.cookie_origin = exec.url.*,
.cookie_origin = exec.siteForCookies(),
.resource_type = .eventsource,
.streaming = true,
.notification = session.notification,
+1 -1
View File
@@ -104,7 +104,7 @@ pub fn init(input: Input, options: ?InitOpts, exec: *const Execution) !js.Promis
.body = request._body,
.resource_type = .fetch,
.cookie_jar = cookie_jar,
.cookie_origin = exec.url.*,
.cookie_origin = exec.siteForCookies(),
.redirect = switch (request._redirect) {
.follow => .follow,
.manual => .manual,
+1 -1
View File
@@ -259,7 +259,7 @@ fn connect(self: *WebSocket, protocols: [][]const u8) !void {
try exec.session.cookie_jar.forRequest(resolved_url, &buf.writer, .{
.is_http = true,
.is_navigation = false,
.origin_url = exec.url.*,
.origin_url = exec.siteForCookies(),
});
if (buf.written().len > 0) {
try buf.writer.writeByte(0);
+1 -1
View File
@@ -320,7 +320,7 @@ pub fn send(self: *XMLHttpRequest, body_: ?BodyInit, exec_: *const Execution) !v
.loader_id = exec.loaderId(),
.body = self._request_body,
.cookie_jar = if (cookie_support) &session.cookie_jar else null,
.cookie_origin = exec.url.*,
.cookie_origin = exec.siteForCookies(),
.resource_type = .xhr,
.timeout_ms = self._timeout,
.notification = session.notification,
+82 -27
View File
@@ -451,6 +451,15 @@ pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool,
return true;
}
// Kept distinct from a plain URL so "the initiator has no site for cookies"
// (.none) can't be confused with "there is no initiating document".
pub const SiteForCookies = union(enum) {
// Matches no site, used for a frame whose ancestor chain contains a cross-site document.
none,
// Same-site when the target host is same-site with this URL's host.
url: [:0]const u8,
};
pub const Jar = struct {
allocator: Allocator,
cookies: std.ArrayList(Cookie),
@@ -575,11 +584,11 @@ pub const Jar = struct {
request_time: ?u64 = null,
is_navigation: bool = true,
prefix: ?[]const u8 = null,
origin_url: ?[:0]const u8 = null,
origin_url: ?SiteForCookies = null,
};
pub fn forRequest(self: *Jar, target_url: [:0]const u8, writer: anytype, opts: LookupOpts) !void {
const target = PreparedUri.init(target_url);
const same_site = try areSameSite(opts.origin_url, target.host);
const same_site = areSameSite(opts.origin_url, target.host);
removeExpired(self, opts.request_time);
@@ -641,15 +650,19 @@ fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool {
return true;
}
fn areSameSite(origin_url_: ?[:0]const u8, target_host: []const u8) !bool {
const origin_url = origin_url_ orelse return true;
const origin_host = URL.getHostname(origin_url);
pub fn areSameSite(maybe_origin_url: ?SiteForCookies, target_host: []const u8) bool {
const origin_url = switch (maybe_origin_url orelse return true) {
.none => return false,
.url => |url| url,
};
return areHostsSameSite(URL.getHostname(origin_url), target_host);
}
// common case
pub fn areHostsSameSite(target_host: []const u8, origin_host: []const u8) bool {
// Common case.
if (std.mem.eql(u8, target_host, origin_host)) {
return true;
}
return std.mem.eql(u8, findSecondLevelDomain(target_host), findSecondLevelDomain(origin_host));
}
@@ -869,112 +882,112 @@ test "Jar: forRequest" {
// nothing fancy here
try expectCookies("global1=1; global2=2", &jar, test_url, .{ .is_http = true });
try expectCookies("global1=1; global2=2", &jar, test_url, .{ .origin_url = test_url, .is_navigation = false, .is_http = true });
try expectCookies("global1=1; global2=2", &jar, test_url, .{ .origin_url = .{ .url = test_url }, .is_navigation = false, .is_http = true });
// We have a cookie where Domain=lightpanda.io
// This should _not_ match xyxlightpanda.io
try expectCookies("", &jar, "http://anothersitelightpanda.io/", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// matching path without trailing /
try expectCookies("global1=1; global2=2; path1=3", &jar, "http://lightpanda.io/about", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// incomplete prefix path
try expectCookies("global1=1; global2=2", &jar, "http://lightpanda.io/abou", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// path doesn't match
try expectCookies("global1=1; global2=2", &jar, "http://lightpanda.io/aboutus", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// path doesn't match cookie directory
try expectCookies("global1=1; global2=2", &jar, "http://lightpanda.io/docs", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// exact directory match
try expectCookies("global1=1; global2=2; path2=4", &jar, "http://lightpanda.io/docs/", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// sub directory match
try expectCookies("global1=1; global2=2; path2=4", &jar, "http://lightpanda.io/docs/more", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// secure
try expectCookies("global1=1; global2=2; secure=5", &jar, "https://lightpanda.io/", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// navigational cross domain, secure
try expectCookies("global1=1; global2=2; secure=5; sitenone=6; sitelax=7", &jar, "https://lightpanda.io/x/", .{
.origin_url = "https://example.com/",
.origin_url = .{ .url = "https://example.com/" },
.is_http = true,
});
// navigational cross domain, insecure
try expectCookies("global1=1; global2=2; sitelax=7", &jar, "http://lightpanda.io/x/", .{
.origin_url = "https://example.com/",
.origin_url = .{ .url = "https://example.com/" },
.is_http = true,
});
// non-navigational cross domain, insecure
try expectCookies("", &jar, "http://lightpanda.io/x/", .{
.origin_url = "https://example.com/",
.origin_url = .{ .url = "https://example.com/" },
.is_http = true,
.is_navigation = false,
});
// non-navigational cross domain, secure
try expectCookies("sitenone=6", &jar, "https://lightpanda.io/x/", .{
.origin_url = "https://example.com/",
.origin_url = .{ .url = "https://example.com/" },
.is_http = true,
.is_navigation = false,
});
// non-navigational same origin
try expectCookies("global1=1; global2=2; sitelax=7; sitestrict=8", &jar, "http://lightpanda.io/x/", .{
.origin_url = "https://lightpanda.io/",
.origin_url = .{ .url = "https://lightpanda.io/" },
.is_http = true,
.is_navigation = false,
});
// exact domain match + suffix
try expectCookies("global2=2; domain1=9", &jar, "http://test.lightpanda.io/", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// domain suffix match + suffix
try expectCookies("global2=2; domain1=9", &jar, "http://1.test.lightpanda.io/", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
// non-matching domain
try expectCookies("global2=2", &jar, "http://other.lightpanda.io/", .{
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
const l = jar.cookies.items.len;
try expectCookies("global1=1", &jar, test_url, .{
.request_time = now + 100,
.origin_url = test_url,
.origin_url = .{ .url = test_url },
.is_http = true,
});
try testing.expectEqual(l - 1, jar.cookies.items.len);
@@ -1001,13 +1014,13 @@ test "Jar: forRequest SameSite=Strict on cross-site navigation" {
// Same-site navigation: cookie included.
try expectCookies("sid=STRICT_COOKIE", &jar, "http://victim.example/transfer", .{
.origin_url = victim_url,
.origin_url = .{ .url = victim_url },
.is_http = true,
});
// Cross-site navigation from attacker.test: cookie excluded.
try expectCookies("", &jar, "http://victim.example/transfer", .{
.origin_url = "http://attacker.test/strict-form",
.origin_url = .{ .url = "http://attacker.test/strict-form" },
.is_http = true,
});
@@ -1017,6 +1030,48 @@ test "Jar: forRequest SameSite=Strict on cross-site navigation" {
});
}
test "Jar: forRequest with a null site-for-cookies" {
const expectCookies = struct {
fn expect(expected: []const u8, jar: *Jar, target_url: [:0]const u8, opts: Jar.LookupOpts) !void {
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
try jar.forRequest(target_url, &aw.writer, opts);
try testing.expectEqual(expected, aw.written());
}
}.expect;
var jar = Jar.init(testing.allocator, null);
defer jar.deinit();
const now = lp.datetime.timestamp(.real);
const victim_url: [:0]const u8 = "https://victim.example/";
try jar.add(try Cookie.parse(testing.allocator, victim_url, "strict=1; Path=/; SameSite=Strict"), now, true);
try jar.add(try Cookie.parse(testing.allocator, victim_url, "lax=2; Path=/; SameSite=Lax"), now, true);
try jar.add(try Cookie.parse(testing.allocator, victim_url, "none=3; Path=/; SameSite=None; Secure"), now, true);
// .none is the site-for-cookies of a frame whose ancestor chain contains
// a cross-site document. Even though the target here is the cookies' own
// site, the request is cross-site: Strict is withheld.
try expectCookies("lax=2; none=3", &jar, victim_url, .{
.origin_url = .none,
.is_http = true,
});
// Sub-resources from such a frame only carry SameSite=None cookies.
try expectCookies("none=3", &jar, victim_url, .{
.origin_url = .none,
.is_http = true,
.is_navigation = false,
});
// Sanity: a same-site initiator still gets everything.
try expectCookies("strict=1; lax=2; none=3", &jar, victim_url, .{
.origin_url = .{ .url = victim_url },
.is_http = true,
.is_navigation = false,
});
}
test "Cookie: parse key=value" {
try expectError(error.Empty, null, "");
try expectError(error.InvalidByteSequence, null, &.{ 'a', 30, '=', 'b' });
+85 -3
View File
@@ -23,6 +23,7 @@ const js = @import("../../js/js.zig");
const URL = @import("../../URL.zig");
const Notification = @import("../../../Notification.zig");
const Frame = @import("../../Frame.zig");
const Cookie = @import("Cookie.zig");
const EventTarget = @import("../EventTarget.zig");
const CookieChangeEvent = @import("../event/CookieChangeEvent.zig");
@@ -66,7 +67,7 @@ fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !vo
// CookieStore exposes only cookies that script would see for the
// current document — same filter as `match` (HttpOnly hidden,
// same-site treated as first-party against the document URL).
// SameSite judged against the global's site-for-cookies).
const doc_url = exec.url.*;
const target = Cookie.PreparedUri.init(doc_url);
if (target.host.len == 0) {
@@ -84,7 +85,8 @@ fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !vo
.http_only = data.http_only,
.same_site = data.same_site,
};
if (!probe.appliesTo(&target, true, true, false)) {
const same_site = Cookie.areSameSite(exec.siteForCookies(), target.host);
if (!probe.appliesTo(&target, same_site, false, false)) {
return;
}
@@ -377,11 +379,12 @@ fn matchCookies(
// we're matching
const normalized_name: ?[]const u8 = if (name) |n| std.mem.trim(u8, n, " \t") else null;
const same_site = Cookie.areSameSite(exec.siteForCookies(), target.host);
var items: std.ArrayList(CookieListItem) = .empty;
for (session.cookie_jar.cookies.items) |*cookie| {
// CookieStore exposes only cookies that script would see for the
// current document. HttpOnly cookies stay hidden.
if (cookie.appliesTo(&target, true, true, false) == false) {
if (cookie.appliesTo(&target, same_site, false, false) == false) {
continue;
}
if (normalized_name) |n| {
@@ -488,6 +491,10 @@ fn storeCookie(exec: *const Execution, init_: CookieInit, is_delete: bool) !void
}
}
if (init.sameSite != .none and Cookie.areSameSite(exec.siteForCookies(), URL.getHostname(url)) == false) {
return error.SameSiteBlocked;
}
const is_https = URL.isSecure(url);
// Per spec, SameSite=None requires Secure. CookieStore additionally
// marks any cookie written from an HTTPS document as Secure.
@@ -612,3 +619,78 @@ const testing = @import("../../../testing.zig");
test "WebApi: CookieStore" {
try testing.htmlRunner("cookie_store.html", .{});
}
test "CookieStore: cross-site frame" {
defer testing.test_session.closeAllPages();
const frame = try testing.createFrame();
const exec = &frame.js.execution;
const jar = &frame._session.cookie_jar;
defer jar.clearRetainingCapacity();
// victim.example embedded by attacker.example: the ancestor chain is
// cross-site, so the frame has no site for cookies.
var top: Frame = undefined;
top.parent = null;
top.url = "https://attacker.example/";
frame.url = "https://victim.example/inner";
frame.parent = ⊤
defer frame.parent = null;
try jar.populateFromResponse("https://victim.example/", "strict=1; SameSite=Strict");
try jar.populateFromResponse("https://victim.example/", "lax=2; SameSite=Lax");
try jar.populateFromResponse("https://victim.example/", "none=3; SameSite=None; Secure");
// getAll(): only SameSite=None is visible from a cross-site context.
{
const items = try matchCookies(exec, null, null, false);
try testing.expectEqual(1, items.len);
try testing.expectEqual("none", items[0].name.str());
}
// set(): the default (Strict) and Lax are rejected, None is stored.
// delete() is an expiring Strict set, so it is rejected too.
try std.testing.expectError(error.SameSiteBlocked, storeCookie(exec, .{ .name = "set_strict", .value = "4" }, false));
try std.testing.expectError(error.SameSiteBlocked, storeCookie(exec, .{ .name = "set_lax", .value = "5", .sameSite = .lax }, false));
try storeCookie(exec, .{ .name = "set_none", .value = "6", .sameSite = .none }, false);
try std.testing.expectError(error.SameSiteBlocked, storeCookie(exec, .{ .name = "none", .value = "", .expires = 0 }, true));
{
const items = try matchCookies(exec, null, null, false);
try testing.expectEqual(2, items.len);
try testing.expectEqual("none", items[0].name.str());
try testing.expectEqual("set_none", items[1].name.str());
}
// The same jar seen from a same-site chain: everything applies, and the
// rejected writes really were rejected rather than hidden.
top.url = "https://victim.example/";
{
const items = try matchCookies(exec, null, null, false);
try testing.expectEqual(4, items.len);
try testing.expectEqual("strict", items[0].name.str());
try testing.expectEqual("lax", items[1].name.str());
try testing.expectEqual("none", items[2].name.str());
try testing.expectEqual("set_none", items[3].name.str());
}
try storeCookie(exec, .{ .name = "set_strict", .value = "4" }, false);
try storeCookie(exec, .{ .name = "none", .value = "", .expires = 0 }, true);
{
// The jar swap-removes on delete, so only check membership here.
const items = try matchCookies(exec, null, null, false);
try testing.expectEqual(4, items.len);
var has_set_strict = false;
for (items) |item| {
try testing.expectEqual(false, std.mem.eql(u8, "none", item.name.str()));
if (std.mem.eql(u8, "set_strict", item.name.str())) has_set_strict = true;
}
try testing.expectEqual(true, has_set_strict);
}
// Back in the cross-site context, the Strict cookie written same-site is
// hidden again.
top.url = "https://attacker.example/";
{
const items = try matchCookies(exec, null, null, false);
try testing.expectEqual(1, items.len);
try testing.expectEqual("set_none", items[0].name.str());
}
}
+21 -15
View File
@@ -28,7 +28,8 @@ const Driver = @import("../server/Driver.zig");
const URL = @import("../browser/URL.zig");
const referrer = @import("../browser/referrer.zig");
const WebSocket = @import("../browser/webapi/net/WebSocket.zig");
const CookieJar = @import("../browser/webapi/storage/Cookie.zig").Jar;
const Cookie = @import("../browser/webapi/storage/Cookie.zig");
const CookieJar = Cookie.Jar;
const http = @import("http.zig");
const Network = @import("Network.zig");
@@ -605,7 +606,12 @@ pub fn newRequest(self: *Client, req: Request, owner: ?*Owner) anyerror!*Transfe
// These are all small, so duping them into the transfer's arena is
// cheap and can solve some nasty UAF.
owned.url = try arena.dupeZ(u8, req.url);
owned.cookie_origin = try arena.dupeZ(u8, req.cookie_origin);
if (req.cookie_origin) |cookie_origin| {
owned.cookie_origin = switch (cookie_origin) {
.none => .none,
.url => |url| .{ .url = try arena.dupeZ(u8, url) },
};
}
if (req.credentials) |c| {
owned.credentials = try arena.dupeZ(u8, c);
}
@@ -1774,7 +1780,7 @@ pub const Request = struct {
url: [:0]const u8,
body: ?[]const u8 = null,
cookie_jar: ?*CookieJar,
cookie_origin: [:0]const u8,
cookie_origin: ?Cookie.SiteForCookies,
resource_type: ResourceType,
redirect: RedirectMode = .follow,
referrer_policy: ?referrer.Policy = null,
@@ -3800,7 +3806,7 @@ fn testTransfer(arena: *lp.Arena) Transfer {
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = noopShutdown,
@@ -3978,7 +3984,7 @@ test "HttpClient: fulfillIntercepted survives a done_callback that tears down th
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = noopShutdown,
@@ -4063,7 +4069,7 @@ test "HttpClient: kill during done_callback does not also fire shutdown_callback
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .xhr,
.notification = undefined,
.shutdown_callback = Ctx.shutdownCallback,
@@ -4149,7 +4155,7 @@ test "HttpClient: kill during a non-terminal callback defers shutdown_callback"
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .xhr,
.notification = undefined,
.shutdown_callback = Ctx.shutdownCallback,
@@ -4210,7 +4216,7 @@ test "HttpClient: aborting a robots-parked transfer unlinks it from the gate" {
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = noopShutdown,
@@ -4278,7 +4284,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" {
.url = "http://example.com/start",
.body = "payload",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = noopShutdown,
@@ -4322,7 +4328,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" {
.url = "http://example.com/start",
.body = "payload",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = noopShutdown,
@@ -4390,7 +4396,7 @@ test "HttpClient: fulfillIntercepted delivers a 3xx without a Location as the re
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = noopShutdown,
@@ -4458,7 +4464,7 @@ test "HttpClient: abortParked survives an error_callback that tears down the own
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = noopShutdown,
@@ -4536,7 +4542,7 @@ test "HttpClient: abort survives an error_callback that tears down the owner" {
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .xhr,
.notification = undefined,
.shutdown_callback = noopShutdown,
@@ -4573,7 +4579,7 @@ test "HttpClient: abort survives an error_callback that tears down the owner" {
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .xhr,
.notification = undefined,
.shutdown_callback = noopShutdown,
@@ -4637,7 +4643,7 @@ test "HttpClient: throttled navigations wait for their per-host slot" {
.method = .GET,
.url = url,
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = noopShutdown,
+1 -1
View File
@@ -117,7 +117,7 @@ fn fetchThenResume(self: *RobotsGate, robots_url: [:0]const u8, transfer: *Trans
.loader_id = transfer.req.loader_id,
.notification = transfer.req.notification,
.cookie_jar = null,
.cookie_origin = owned_url,
.cookie_origin = .{ .url = owned_url },
.ctx = robots_ctx,
.header_callback = RobotsContext.headerCallback,
.data_callback = RobotsContext.dataCallback,
+1 -1
View File
@@ -109,7 +109,7 @@ fn makeTestTransfer(arena: *lp.Arena, client: *HttpClient, id: u32) !*Transfer {
.method = .GET,
.url = "http://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = HttpClient.noopShutdown,
+1 -1
View File
@@ -247,7 +247,7 @@ test "signRequest: adds headers with correct names" {
.method = .GET,
.url = "https://example.com/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .document,
.notification = undefined,
.shutdown_callback = @import("HttpClient.zig").noopShutdown,
+1 -1
View File
@@ -1541,7 +1541,7 @@ test "cdp: syncRequest short-circuits after disconnect" {
.method = .GET,
.url = "http://127.0.0.1:9582/",
.cookie_jar = null,
.cookie_origin = "",
.cookie_origin = .none,
.resource_type = .fetch,
.notification = undefined,
.shutdown_callback = HttpClient.noopShutdown,
+5 -5
View File
@@ -1174,7 +1174,7 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" {
.method = .GET,
.url = "https://blocked.test/script.js",
.cookie_jar = null,
.cookie_origin = "https://blocked.test/",
.cookie_origin = .{ .url = "https://blocked.test/" },
.resource_type = .script,
.notification = bc.session.notification,
.ctx = &error_context,
@@ -1200,7 +1200,7 @@ test "cdp.Network: setBlockedURLs blocks requests with inspector reason" {
.method = .GET,
.url = "http://127.0.0.1:9582/redirect-no-fragment",
.cookie_jar = null,
.cookie_origin = "http://127.0.0.1:9582/",
.cookie_origin = .{ .url = "http://127.0.0.1:9582/" },
.resource_type = .script,
.notification = bc.session.notification,
.ctx = &error_context,
@@ -1241,7 +1241,7 @@ test "cdp.Network: POST body exposed as postData" {
.url = "http://127.0.0.1:9582/echo_body",
.body = body,
.cookie_jar = null,
.cookie_origin = "http://127.0.0.1:9582/",
.cookie_origin = .{ .url = "http://127.0.0.1:9582/" },
.resource_type = .fetch,
.notification = bc.session.notification,
.shutdown_callback = HttpClient.noopShutdown,
@@ -1303,7 +1303,7 @@ const EchoDriver = struct {
.url = "http://127.0.0.1:9582/echo_body",
.body = body,
.cookie_jar = null,
.cookie_origin = "http://127.0.0.1:9582/",
.cookie_origin = .{ .url = "http://127.0.0.1:9582/" },
.resource_type = .fetch,
.notification = bc.session.notification,
.ctx = &driver,
@@ -1506,7 +1506,7 @@ test "cdp.Network: redirect hop precedes Fetch pause and carries redirectRespons
.method = .GET,
.url = start_url,
.cookie_jar = null,
.cookie_origin = start_url,
.cookie_origin = .{ .url = start_url },
.resource_type = .script,
.notification = bc.session.notification,
.ctx = &callback_context,