From 87308b5e23924c0afe468c6c462474dee380e01a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 26 Aug 2026 16:10:41 +0300 Subject: [PATCH 1/4] compute site-for-cookies from ancestor chain of a `Frame` The site for cookies were computed from the immediate parent `Frame`, which would allow sending a cookie that's `SameSite=Strict` from 2 levels deep under. Directly from RFC6265bis, this PR essentially implements (except for step 4, we skip host-less ancestors): Given a Document (document), the following algorithm returns its "site for cookies": 1. Let top-document be the active document in document's navigable's top-level traversable. 2. Let top-origin be the origin of top-document's URI if top- document's sandboxed origin browsing context flag is set, and top-document's origin otherwise. 3. Let documents be a list consisting of the active documents of document's inclusive ancestor navigables. 4. For each item in documents: 1. Let origin be the origin of item's URI if item's sandboxed origin browsing context flag is set, and item's origin otherwise. 2. If origin is not same-site with top-origin, return an origin set to an opaque origin. 5. Return top-origin. --- src/browser/Frame.zig | 100 +++++++++++++--- src/browser/ScriptManager.zig | 6 +- src/browser/ScriptManagerBase.zig | 13 +- src/browser/frame/resource_load.zig | 2 +- src/browser/js/Execution.zig | 9 ++ .../webapi/SharedWorkerGlobalScope.zig | 2 +- src/browser/webapi/Worker.zig | 2 +- src/browser/webapi/WorkerGlobalScope.zig | 2 +- src/browser/webapi/net/EventSource.zig | 2 +- src/browser/webapi/net/Fetch.zig | 2 +- src/browser/webapi/net/WebSocket.zig | 2 +- src/browser/webapi/net/XMLHttpRequest.zig | 2 +- src/browser/webapi/storage/Cookie.zig | 113 +++++++++++++----- src/network/HttpClient.zig | 36 +++--- src/network/RobotsGate.zig | 2 +- src/network/SingleFlight.zig | 2 +- src/network/WebBotAuth.zig | 2 +- src/server/cdp/CDP.zig | 2 +- src/server/cdp/domains/network.zig | 8 +- 19 files changed, 231 insertions(+), 78 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 80957de4f..a1d2ef4bd 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -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(); diff --git a/src/browser/ScriptManager.zig b/src/browser/ScriptManager.zig index f491c471b..4345ca392 100644 --- a/src/browser/ScriptManager.zig +++ b/src/browser/ScriptManager.zig @@ -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, diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 37de84f2c..0b1fd0bc2 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -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| .{ .url = worker.url }, + }; + } }; 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, diff --git a/src/browser/frame/resource_load.zig b/src/browser/frame/resource_load.zig index 3462f662d..c9a161a00 100644 --- a/src/browser/frame/resource_load.zig +++ b/src/browser/frame/resource_load.zig @@ -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, diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig index 0435c484a..7d30f219b 100644 --- a/src/browser/js/Execution.zig +++ b/src/browser/js/Execution.zig @@ -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's is its own URL; `Frame` prefers its ancestor chain. +pub fn siteForCookies(self: *const Execution) Cookie.SiteForCookies { + return switch (self.js.global) { + .frame => |frame| frame.siteForCookies(), + .worker => |worker| .{ .url = worker.url }, + }; +} + // 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. diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index 7bf26cdc6..550c3061b 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -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 = .{ .url = owned_url }, .notification = session.notification, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index d6013a4b4..65c69bacc 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -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 = .{ .url = resolved_url }, .notification = session.notification, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 6326cda6c..78b2fdb04 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -423,7 +423,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 = .{ .url = self.url }, .resource_type = .script, .notification = session.notification, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own diff --git a/src/browser/webapi/net/EventSource.zig b/src/browser/webapi/net/EventSource.zig index 0fdc14c08..02b7926c4 100644 --- a/src/browser/webapi/net/EventSource.zig +++ b/src/browser/webapi/net/EventSource.zig @@ -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, diff --git a/src/browser/webapi/net/Fetch.zig b/src/browser/webapi/net/Fetch.zig index 944a1d2db..25c469dc4 100644 --- a/src/browser/webapi/net/Fetch.zig +++ b/src/browser/webapi/net/Fetch.zig @@ -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, diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index ad953a04f..1e5cd81ac 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -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); diff --git a/src/browser/webapi/net/XMLHttpRequest.zig b/src/browser/webapi/net/XMLHttpRequest.zig index dc88a5e57..21063d416 100644 --- a/src/browser/webapi/net/XMLHttpRequest.zig +++ b/src/browser/webapi/net/XMLHttpRequest.zig @@ -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, diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index f5e1281fd..a98468703 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -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,13 @@ pub const Jar = struct { request_time: ?u64 = null, is_navigation: bool = true, prefix: ?[]const u8 = null, - origin_url: ?[:0]const u8 = null, + // null means there is no initiating document (a browser-initiated + // request), which is treated as same-site with any target. + 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 +652,20 @@ 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); +fn areSameSite(maybe_origin_url: ?SiteForCookies, target_host: []const u8) bool { + // No initiating document (browser-initiated request). + 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 +885,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 +1017,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 +1033,49 @@ 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. (Lax still rides + // navigations; whether a subframe load should count as one is #240.) + 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' }); diff --git a/src/network/HttpClient.zig b/src/network/HttpClient.zig index f2c04fce3..953de939c 100644 --- a/src/network/HttpClient.zig +++ b/src/network/HttpClient.zig @@ -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, diff --git a/src/network/RobotsGate.zig b/src/network/RobotsGate.zig index ac52eb175..e1ad1784b 100644 --- a/src/network/RobotsGate.zig +++ b/src/network/RobotsGate.zig @@ -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, diff --git a/src/network/SingleFlight.zig b/src/network/SingleFlight.zig index 5beb92a34..d753cba7a 100644 --- a/src/network/SingleFlight.zig +++ b/src/network/SingleFlight.zig @@ -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, diff --git a/src/network/WebBotAuth.zig b/src/network/WebBotAuth.zig index e5ef72afd..dabe7df77 100644 --- a/src/network/WebBotAuth.zig +++ b/src/network/WebBotAuth.zig @@ -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, diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index 62a387e94..510ec3e67 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -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, diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index 4f26ec077..c6a830a91 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -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, @@ -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, From 9ddf6ad02a7950c5772da1cb399e76393f2d3239 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 28 Aug 2026 15:36:48 +0300 Subject: [PATCH 2/4] `Worker`: inherit site-for-cookies from the creating document --- src/browser/ScriptManagerBase.zig | 2 +- src/browser/js/Execution.zig | 4 ++-- src/browser/webapi/SharedWorkerGlobalScope.zig | 2 +- src/browser/webapi/Worker.zig | 2 +- src/browser/webapi/WorkerGlobalScope.zig | 9 ++++++++- 5 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 0b1fd0bc2..9bb1731d5 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -84,7 +84,7 @@ pub const Owner = union(enum) { pub fn siteForCookies(self: Owner) Cookie.SiteForCookies { return switch (self) { .frame => |frame| frame.siteForCookies(), - .worker => |worker| .{ .url = worker.url }, + .worker => |worker| worker.site_for_cookies, }; } }; diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig index 7d30f219b..118583214 100644 --- a/src/browser/js/Execution.zig +++ b/src/browser/js/Execution.zig @@ -133,11 +133,11 @@ pub fn origin(self: *const Execution) ?[]const u8 { }; } -// a Worker's is its own URL; `Frame` prefers its ancestor chain. +// 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| .{ .url = worker.url }, + .worker => |worker| worker.site_for_cookies, }; } diff --git a/src/browser/webapi/SharedWorkerGlobalScope.zig b/src/browser/webapi/SharedWorkerGlobalScope.zig index 550c3061b..2a38832dc 100644 --- a/src/browser/webapi/SharedWorkerGlobalScope.zig +++ b/src/browser/webapi/SharedWorkerGlobalScope.zig @@ -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 = .{ .url = owned_url }, + .cookie_origin = proto.site_for_cookies, .notification = session.notification, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, diff --git a/src/browser/webapi/Worker.zig b/src/browser/webapi/Worker.zig index 65c69bacc..85440d747 100644 --- a/src/browser/webapi/Worker.zig +++ b/src/browser/webapi/Worker.zig @@ -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 = .{ .url = resolved_url }, + .cookie_origin = frame.siteForCookies(), .notification = session.notification, .header_callback = httpHeaderCallback, .data_callback = httpDataCallback, diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index 78b2fdb04..330b16c19 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -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 = .{ .url = self.url }, + .cookie_origin = self.site_for_cookies, .resource_type = .script, .notification = session.notification, .shutdown_callback = HttpClient.noopShutdown, // syncRequest installs its own From add8a8c00355faa859b54fb1abb9b96d9bed57a1 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Fri, 28 Aug 2026 15:45:41 +0300 Subject: [PATCH 3/4] `Cookie`: getter/setter changes for site-for-cookies Also updates matchCookies/onCookieChanged from the hard-coded "same-site + navigation" to areSameSite(exec.siteForCookies(), host) and is_navigation=false. --- src/browser/webapi/Document.zig | 54 ++++++++++++- src/browser/webapi/storage/Cookie.zig | 2 +- src/browser/webapi/storage/CookieStore.zig | 88 +++++++++++++++++++++- 3 files changed, 139 insertions(+), 5 deletions(-) diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index a3fe037cf..290177129 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -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")); diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index a98468703..e6e17c8e0 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -652,7 +652,7 @@ fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool { return true; } -fn areSameSite(maybe_origin_url: ?SiteForCookies, target_host: []const u8) bool { +pub fn areSameSite(maybe_origin_url: ?SiteForCookies, target_host: []const u8) bool { // No initiating document (browser-initiated request). const origin_url = switch (maybe_origin_url orelse return true) { .none => return false, diff --git a/src/browser/webapi/storage/CookieStore.zig b/src/browser/webapi/storage/CookieStore.zig index 4a8e3ac3e..a1c9b4ea6 100644 --- a/src/browser/webapi/storage/CookieStore.zig +++ b/src/browser/webapi/storage/CookieStore.zig @@ -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()); + } +} From 07198b71ff78c727b900888fabd16dfd216a28ea Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 2 Sep 2026 07:28:25 +0800 Subject: [PATCH 4/4] fix comments --- src/browser/webapi/storage/Cookie.zig | 6 +----- src/server/cdp/domains/network.zig | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/browser/webapi/storage/Cookie.zig b/src/browser/webapi/storage/Cookie.zig index e6e17c8e0..0bcc72ed5 100644 --- a/src/browser/webapi/storage/Cookie.zig +++ b/src/browser/webapi/storage/Cookie.zig @@ -584,8 +584,6 @@ pub const Jar = struct { request_time: ?u64 = null, is_navigation: bool = true, prefix: ?[]const u8 = null, - // null means there is no initiating document (a browser-initiated - // request), which is treated as same-site with any target. origin_url: ?SiteForCookies = null, }; pub fn forRequest(self: *Jar, target_url: [:0]const u8, writer: anytype, opts: LookupOpts) !void { @@ -653,7 +651,6 @@ fn areCookiesEqual(a: *const Cookie, b: *const Cookie) bool { } pub fn areSameSite(maybe_origin_url: ?SiteForCookies, target_host: []const u8) bool { - // No initiating document (browser-initiated request). const origin_url = switch (maybe_origin_url orelse return true) { .none => return false, .url => |url| url, @@ -1054,8 +1051,7 @@ test "Jar: forRequest with a null site-for-cookies" { // .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. (Lax still rides - // navigations; whether a subframe load should count as one is #240.) + // site, the request is cross-site: Strict is withheld. try expectCookies("lax=2; none=3", &jar, victim_url, .{ .origin_url = .none, .is_http = true, diff --git a/src/server/cdp/domains/network.zig b/src/server/cdp/domains/network.zig index c6a830a91..b1d83797c 100644 --- a/src/server/cdp/domains/network.zig +++ b/src/server/cdp/domains/network.zig @@ -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,