Merge pull request #2562 from lightpanda-io/cookie-storage

Implement CookieStore web API
This commit is contained in:
Karl Seguin
2026-05-29 08:20:50 +08:00
committed by GitHub
13 changed files with 993 additions and 9 deletions

View File

@@ -56,6 +56,7 @@ const Allocator = std.mem.Allocator;
// proper isolation between different CDP clients while allowing a single client
// to receive events from all its tabs.
const ModelContextTool = @import("browser/webapi/ModelContext.zig").Tool;
const Cookie = @import("browser/webapi/storage/Cookie.zig");
const Notification = @This();
// Every event type (which are hard-coded), has a list of Listeners.
@@ -93,6 +94,7 @@ const EventListeners = struct {
runtime_console_message: List = .{},
model_context_tool_added: List = .{},
model_context_tool_removed: List = .{},
cookie_changed: List = .{},
};
const Events = union(enum) {
@@ -118,6 +120,7 @@ const Events = union(enum) {
runtime_console_message: *const ConsoleMessage,
model_context_tool_added: *const ModelContextToolEvent,
model_context_tool_removed: *const ModelContextToolEvent,
cookie_changed: *const CookieChanged,
};
const EventType = std.meta.FieldEnum(Events);
@@ -236,6 +239,23 @@ pub const ModelContextToolEvent = struct {
tool: *const ModelContextTool,
};
// Fired by Cookie.Jar after a mutation. Pointers and slices borrow from the
// dispatcher's stack — listeners must copy whatever they need to retain.
pub const CookieChanged = struct {
kind: Kind,
name: []const u8,
value: []const u8,
// Cookie domain in jar-canonical form (leading "." for explicit Domain
// attribute; bare host otherwise).
domain: []const u8,
path: []const u8,
secure: bool,
http_only: bool,
same_site: Cookie.SameSite,
pub const Kind = enum { changed, deleted };
};
pub const DialogResponse = struct {
accept: bool = false,
// Set when the CDP client sent a `promptText` with `accept: true`. Memory

View File

@@ -379,6 +379,11 @@ pub fn deinit(self: *Frame) void {
self._parse_state.deinit(self);
// Unregister CookieStore from session notifications before the JS
// context (and thus the scheduler) is destroyed, otherwise a late
// mutation could schedule a callback that never runs.
if (self.window._cookie_store) |cs| cs.detach();
const page = self._page;
if (self._queued_navigation) |qn| {

View File

@@ -112,7 +112,7 @@ pub fn init(self: *Session, browser: *Browser, notification: *Notification) !voi
.storage_shed = .{},
.browser = browser,
.notification = notification,
.cookie_jar = storage.Cookie.Jar.init(allocator),
.cookie_jar = storage.Cookie.Jar.init(allocator, notification),
// CLI defaults; LP.configureLoading can flip these per-session.
.subframe_loading_enabled = !browser.app.config.disableSubframes(),
.worker_loading_enabled = !browser.app.config.disableWorkers(),

View File

@@ -959,6 +959,8 @@ pub const PageJsApis = flattenTypes(&.{
@import("../webapi/streams/TransformStream.zig"),
@import("../webapi/Node.zig"),
@import("../webapi/storage/storage.zig"),
@import("../webapi/storage/CookieStore.zig"),
@import("../webapi/event/CookieChangeEvent.zig"),
@import("../webapi/URL.zig"),
@import("../webapi/Window.zig"),
@import("../webapi/Performance.zig"),
@@ -1038,6 +1040,8 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/ImageData.zig"),
@import("../webapi/Performance.zig"),
@import("../webapi/PerformanceObserver.zig"),
@import("../webapi/storage/CookieStore.zig"),
@import("../webapi/event/CookieChangeEvent.zig"),
});
// Master list of ALL JS APIs across all contexts.

View File

@@ -0,0 +1,216 @@
<!DOCTYPE html>
<script src="testing.js"></script>
<script id=interface>
testing.expectEqual('object', typeof cookieStore);
testing.expectEqual(cookieStore, window.cookieStore);
testing.expectEqual(true, cookieStore instanceof CookieStore);
testing.expectEqual(true, cookieStore instanceof EventTarget);
testing.expectEqual('function', typeof cookieStore.get);
testing.expectEqual('function', typeof cookieStore.getAll);
testing.expectEqual('function', typeof cookieStore.set);
testing.expectEqual('function', typeof cookieStore.delete);
</script>
<script id=set-and-get>
testing.async(async () => {
await cookieStore.set('user', 'lp');
const item = await cookieStore.get('user');
testing.expectEqual('user', item.name);
testing.expectEqual('lp', item.value);
testing.expectEqual('/', item.path);
testing.expectEqual('strict', item.sameSite);
testing.expectEqual(null, item.expires);
await cookieStore.delete('user');
testing.expectEqual(null, await cookieStore.get('user'));
});
</script>
<script id=set-options>
testing.async(async () => {
await cookieStore.set({ name: 'prefs', value: 'dark', sameSite: 'lax' });
const item = await cookieStore.get({ name: 'prefs' });
testing.expectEqual('prefs', item.name);
testing.expectEqual('dark', item.value);
testing.expectEqual('lax', item.sameSite);
await cookieStore.delete('prefs');
});
</script>
<script id=get-missing>
testing.async(async () => {
const item = await cookieStore.get('does-not-exist');
testing.expectEqual(null, item);
});
</script>
<script id=get-all>
testing.async(async () => {
await cookieStore.set('a', '1');
await cookieStore.set('b', '2');
const all = await cookieStore.getAll();
testing.expectEqual(true, all.length >= 2);
const names = all.map(c => c.name);
testing.expectEqual(true, names.includes('a'));
testing.expectEqual(true, names.includes('b'));
const justA = await cookieStore.getAll('a');
testing.expectEqual(1, justA.length);
testing.expectEqual('a', justA[0].name);
testing.expectEqual('1', justA[0].value);
await cookieStore.delete('a');
await cookieStore.delete('b');
});
</script>
<script id=delete-options>
testing.async(async () => {
await cookieStore.set('tmp', 'x');
await cookieStore.delete({ name: 'tmp' });
testing.expectEqual(null, await cookieStore.get('tmp'));
});
</script>
<script id=onchange-handler>
testing.expectEqual(null, cookieStore.onchange);
cookieStore.onchange = function() {};
testing.expectEqual('function', typeof cookieStore.onchange);
cookieStore.onchange = null;
testing.expectEqual(null, cookieStore.onchange);
</script>
<script id=cookie-change-event>
const ev = new CookieChangeEvent('change');
testing.expectEqual('change', ev.type);
testing.expectEqual(0, ev.changed.length);
testing.expectEqual(0, ev.deleted.length);
</script>
<script id=change-event-from-cookieStore-set>
testing.async(async () => {
const events = [];
const handler = (e) => events.push(e);
cookieStore.addEventListener('change', handler);
await cookieStore.set('ev-from-set', 'v1');
// Spec says change events are queued tasks — yield to the loop.
await new Promise(r => setTimeout(r, 0));
testing.expectEqual(1, events.length);
testing.expectEqual(1, events[0].changed.length);
testing.expectEqual('ev-from-set', events[0].changed[0].name);
testing.expectEqual('v1', events[0].changed[0].value);
testing.expectEqual(0, events[0].deleted.length);
await cookieStore.delete('ev-from-set');
await new Promise(r => setTimeout(r, 0));
testing.expectEqual(2, events.length);
testing.expectEqual(0, events[1].changed.length);
testing.expectEqual(1, events[1].deleted.length);
testing.expectEqual('ev-from-set', events[1].deleted[0].name);
cookieStore.removeEventListener('change', handler);
});
</script>
<script id=set-rejects-invalid-input>
testing.async(async () => {
const expectReject = async (label, init, valueArg) => {
try {
if (valueArg !== undefined) {
await cookieStore.set(init, valueArg);
} else {
await cookieStore.set(init);
}
testing.fail(`expected ${label} to reject`);
} catch (err) {
testing.expectEqual('TypeError', err.name);
}
};
// Empty name.
await expectReject('empty name', '', 'v');
// Forbidden chars in name.
await expectReject('name with =', 'a=b', 'v');
await expectReject('name with ;', 'a;b', 'v');
await expectReject('name with newline', 'a\nb', 'v');
// Forbidden chars in value.
await expectReject('value with ;', 'k', 'v;injected=1');
await expectReject('value with CR', 'k', 'v\rinjected');
// Forbidden chars in path / domain.
await expectReject('path with ;', { name: 'k', value: 'v', path: '/;evil' });
await expectReject('domain with newline', { name: 'k', value: 'v', domain: 'bad\n.example' });
// A clean call still works after the rejections.
await cookieStore.set('after-validation', 'ok');
const item = await cookieStore.get('after-validation');
testing.expectEqual('ok', item.value);
await cookieStore.delete('after-validation');
});
</script>
<script id=change-event-delete-reports-removed-value>
testing.async(async () => {
const events = [];
const handler = (e) => events.push(e);
cookieStore.addEventListener('change', handler);
await cookieStore.set('ev-delete-val', 'original');
await new Promise(r => setTimeout(r, 0));
// Sanity: the change event must report the value that was stored.
testing.expectEqual('original', events[0].changed[0].value);
await cookieStore.delete('ev-delete-val');
await new Promise(r => setTimeout(r, 0));
// The deleted CookieListItem should describe the cookie that was
// removed, so its value must be what the jar held ("original") —
// not the deletion payload's value ("").
testing.expectEqual(1, events[1].deleted.length);
testing.expectEqual('ev-delete-val', events[1].deleted[0].name);
testing.expectEqual('original', events[1].deleted[0].value);
cookieStore.removeEventListener('change', handler);
});
</script>
<script id=change-event-from-document-cookie>
testing.async(async () => {
const events = [];
const handler = (e) => events.push(e);
cookieStore.addEventListener('change', handler);
document.cookie = 'ev-from-doc=v1; path=/';
await new Promise(r => setTimeout(r, 0));
testing.expectEqual(1, events.length);
testing.expectEqual('ev-from-doc', events[0].changed[0].name);
testing.expectEqual('v1', events[0].changed[0].value);
document.cookie = 'ev-from-doc=DELETED; path=/; max-age=0';
await new Promise(r => setTimeout(r, 0));
testing.expectEqual(2, events.length);
testing.expectEqual('ev-from-doc', events[1].deleted[0].name);
cookieStore.removeEventListener('change', handler);
});
</script>
<script id=change-event-onchange-attribute>
testing.async(async () => {
let captured = null;
cookieStore.onchange = (e) => { captured = e; };
await cookieStore.set('ev-onchange', 'v1');
await new Promise(r => setTimeout(r, 0));
testing.expectEqual(true, captured !== null);
testing.expectEqual('ev-onchange', captured.changed[0].name);
cookieStore.onchange = null;
await cookieStore.delete('ev-onchange');
});
</script>

View File

@@ -80,6 +80,7 @@ pub const Type = union(enum) {
submit_event: *@import("event/SubmitEvent.zig"),
form_data_event: *@import("event/FormDataEvent.zig"),
close_event: *@import("event/CloseEvent.zig"),
cookie_change_event: *@import("event/CookieChangeEvent.zig"),
};
pub const Options = struct {
@@ -170,6 +171,7 @@ pub fn is(self: *Event, comptime T: type) ?*T {
.submit_event => |e| return if (T == @import("event/SubmitEvent.zig")) e else null,
.form_data_event => |e| return if (T == @import("event/FormDataEvent.zig")) e else null,
.close_event => |e| return if (T == @import("event/CloseEvent.zig")) e else null,
.cookie_change_event => |e| return if (T == @import("event/CookieChangeEvent.zig")) e else null,
.ui_event => |e| {
if (T == @import("event/UIEvent.zig")) {
return e;

View File

@@ -49,6 +49,7 @@ pub const Type = union(enum) {
file_reader: *@import("FileReader.zig"),
font_face_set: *@import("css/FontFaceSet.zig"),
websocket: *@import("net/WebSocket.zig"),
cookie_store: *@import("storage/CookieStore.zig"),
};
pub fn init(page: *Page) !*EventTarget {
@@ -159,6 +160,7 @@ pub fn format(self: *EventTarget, writer: *std.Io.Writer) !void {
.file_reader => writer.writeAll("<FileReader>"),
.font_face_set => writer.writeAll("<FontFaceSet>"),
.websocket => writer.writeAll("<WebSocket>"),
.cookie_store => writer.writeAll("<CookieStore>"),
};
}
@@ -181,6 +183,7 @@ pub fn toString(self: *EventTarget) []const u8 {
.file_reader => return "[object FileReader]",
.font_face_set => return "[object FontFaceSet]",
.websocket => return "[object WebSocket]",
.cookie_store => return "[object CookieStore]",
};
}

View File

@@ -42,6 +42,7 @@ const MessageEvent = @import("event/MessageEvent.zig");
const MessagePort = @import("MessagePort.zig");
const MediaQueryList = @import("css/MediaQueryList.zig");
const storage = @import("storage/storage.zig");
const CookieStore = @import("storage/CookieStore.zig");
const Element = @import("Element.zig");
const CSSStyleProperties = @import("css/CSSStyleProperties.zig");
const CustomElementRegistry = @import("CustomElementRegistry.zig");
@@ -53,6 +54,7 @@ const log = lp.log;
const IS_DEBUG = builtin.mode == .Debug;
const Allocator = std.mem.Allocator;
const Execution = js.Execution;
pub fn registerTypes() []const type {
return &.{ Window, CrossOriginWindow };
@@ -72,6 +74,7 @@ _screen: *Screen,
_visual_viewport: *VisualViewport,
_performance: Performance,
_storage_bucket: storage.Bucket = .{},
_cookie_store: ?*CookieStore = null,
_on_load: ?js.Function.Global = null,
_on_pageshow: ?js.Function.Global = null,
_on_popstate: ?js.Function.Global = null,
@@ -241,6 +244,14 @@ pub fn getSessionStorage(self: *Window) *storage.Lookup {
return &self._storage_bucket.session;
}
pub fn getCookieStore(self: *Window, exec: *Execution) !*CookieStore {
if (self._cookie_store) |cs| return cs;
const cs = try exec._factory.eventTarget(CookieStore{ ._proto = undefined });
try cs.attach(exec);
self._cookie_store = cs;
return cs;
}
pub fn getOrigin(self: *const Window) []const u8 {
return self._frame.origin orelse "null";
}
@@ -921,6 +932,7 @@ pub const JsApi = struct {
pub const performance = bridge.accessor(Window.getPerformance, null, .{});
pub const localStorage = bridge.accessor(Window.getLocalStorage, null, .{});
pub const sessionStorage = bridge.accessor(Window.getSessionStorage, null, .{});
pub const cookieStore = bridge.accessor(Window.getCookieStore, null, .{});
pub const origin = bridge.accessor(Window.getOrigin, null, .{});
pub const location = bridge.accessor(Window.getLocation, Window.setLocation, .{ .deletable = false });
pub const history = bridge.accessor(Window.getHistory, null, .{});

View File

@@ -44,6 +44,7 @@ const WorkerLocation = @import("WorkerLocation.zig");
const MessageEvent = @import("event/MessageEvent.zig");
const ErrorEvent = @import("event/ErrorEvent.zig");
const Fetch = @import("net/Fetch.zig");
const CookieStore = @import("storage/CookieStore.zig");
const builtin = @import("builtin");
const IS_DEBUG = builtin.mode == .Debug;
@@ -102,6 +103,7 @@ _on_rejection_handled: ?JS.Function.Global = null,
_on_unhandled_rejection: ?JS.Function.Global = null,
_on_message: ?JS.Function.Global = null,
_on_messageerror: ?JS.Function.Global = null,
_cookie_store: ?*CookieStore = null,
_location: WorkerLocation,
@@ -265,6 +267,13 @@ pub fn getLocation(self: *WorkerGlobalScope) *WorkerLocation {
return &self._location;
}
pub fn getCookieStore(self: *WorkerGlobalScope) !*CookieStore {
if (self._cookie_store) |cs| return cs;
const cs = try self._factory.eventTargetWithAllocator(self.arena, CookieStore{ ._proto = undefined });
self._cookie_store = cs;
return cs;
}
pub fn getOnError(self: *const WorkerGlobalScope) ?JS.Function.Global {
return self._on_error;
}
@@ -659,6 +668,7 @@ pub const JsApi = struct {
}
}.wrap, null, .{});
pub const location = bridge.accessor(WorkerGlobalScope.getLocation, null, .{});
pub const cookieStore = bridge.accessor(WorkerGlobalScope.getCookieStore, null, .{});
pub const onerror = bridge.accessor(WorkerGlobalScope.getOnError, WorkerGlobalScope.setOnError, .{});
pub const onrejectionhandled = bridge.accessor(WorkerGlobalScope.getOnRejectionHandled, WorkerGlobalScope.setOnRejectionHandled, .{});

View File

@@ -0,0 +1,137 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const Notification = @import("../../../Notification.zig");
const Event = @import("../Event.zig");
const Cookie = @import("../storage/Cookie.zig");
const CookieStore = @import("../storage/CookieStore.zig");
const String = lp.String;
const Allocator = std.mem.Allocator;
const Execution = js.Execution;
// https://developer.mozilla.org/en-US/docs/Web/API/CookieChangeEvent
const CookieChangeEvent = @This();
_proto: *Event,
_changed: []CookieStore.CookieListItem,
_deleted: []CookieStore.CookieListItem,
const CookieChangeEventOptions = struct {
changed: ?[]CookieStore.CookieListItem = null,
deleted: ?[]CookieStore.CookieListItem = null,
};
const Options = Event.inheritOptions(CookieChangeEvent, CookieChangeEventOptions);
pub fn init(typ: []const u8, _opts: ?Options, exec: *const Execution) !*CookieChangeEvent {
const arena = try exec.getArena(.tiny, "CookieChangeEvent");
errdefer exec.releaseArena(arena);
const type_string = try String.init(arena, typ, .{});
const opts = _opts orelse Options{};
const event = try exec._factory.event(
arena,
type_string,
CookieChangeEvent{
._proto = undefined,
._changed = opts.changed orelse &.{},
._deleted = opts.deleted orelse &.{},
},
);
Event.populatePrototypes(event, opts, false);
return event;
}
/// Internal constructor used by the change-notification dispatcher to
/// build a trusted "change" event from a single-cookie mutation snapshot.
/// The CookieListItem is materialised on the event's own arena.
pub fn initSingle(
kind: Notification.CookieChanged.Kind,
snapshot: Notification.CookieChanged,
exec: *const Execution,
) !*CookieChangeEvent {
const arena = try exec.getArena(.tiny, "CookieChangeEvent");
errdefer exec.releaseArena(arena);
const type_string = try String.init(arena, "change", .{});
const item = try arena.create(CookieStore.CookieListItem);
item.* = .{
.name = try String.init(arena, snapshot.name, .{}),
.value = try String.init(arena, snapshot.value, .{}),
.domain = if (snapshot.domain.len > 0 and snapshot.domain[0] == '.')
try String.init(arena, snapshot.domain[1..], .{})
else
null,
.path = try String.init(arena, snapshot.path, .{}),
.expires = null,
.secure = snapshot.secure,
.sameSite = switch (snapshot.same_site) {
.strict => .strict,
.lax => .lax,
.none => .none,
},
.partitioned = false,
};
const event = try exec._factory.event(
arena,
type_string,
CookieChangeEvent{
._proto = undefined,
._changed = if (kind == .changed) item[0..1] else &.{},
._deleted = if (kind == .deleted) item[0..1] else &.{},
},
);
Event.populatePrototypes(event, Options{}, true);
return event;
}
pub fn asEvent(self: *CookieChangeEvent) *Event {
return self._proto;
}
pub fn getChanged(self: *const CookieChangeEvent) []CookieStore.CookieListItem {
return self._changed;
}
pub fn getDeleted(self: *const CookieChangeEvent) []CookieStore.CookieListItem {
return self._deleted;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(CookieChangeEvent);
pub const Meta = struct {
pub const name = "CookieChangeEvent";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const constructor = bridge.constructor(CookieChangeEvent.init, .{});
pub const changed = bridge.accessor(CookieChangeEvent.getChanged, null, .{});
pub const deleted = bridge.accessor(CookieChangeEvent.getDeleted, null, .{});
};

View File

@@ -21,6 +21,7 @@ const lp = @import("lightpanda");
const URL = @import("../../URL.zig");
const DateTime = @import("../../../datetime.zig").DateTime;
const Notification = @import("../../../Notification.zig");
const public_suffix_list = @import("../../../data/public_suffix_list.zig").lookup;
const log = lp.log;
@@ -449,11 +450,16 @@ pub fn appliesTo(self: *const Cookie, url: *const PreparedUri, same_site: bool,
pub const Jar = struct {
allocator: Allocator,
cookies: std.ArrayList(Cookie),
// Optional notifier for cookie change events. When set, `add` dispatches
// `cookie_changed` after a real mutation. Session populates this; the
// standalone test construction leaves it null.
notification: ?*Notification = null,
pub fn init(allocator: Allocator) Jar {
pub fn init(allocator: Allocator, notification: ?*Notification) Jar {
return .{
.cookies = .{},
.allocator = allocator,
.notification = notification,
};
}
@@ -503,20 +509,48 @@ pub const Jar = struct {
return;
}
c.deinit();
if (is_expired) {
// Dispatch while c still points at the live old cookie,
// then free its arena, then remove from the array. After
// swapRemove, items[i] holds a different (still-live)
// entry, so deinit must happen before that.
self.dispatchChange(.deleted, c);
c.deinit();
_ = self.cookies.swapRemove(i);
} else {
// Free the old cookie's arena before overwriting the slot;
// after the assignment, c points at the new cookie.
c.deinit();
self.cookies.items[i] = cookie;
self.dispatchChange(.changed, &self.cookies.items[i]);
}
return;
}
if (!is_expired) {
try self.cookies.append(self.allocator, cookie);
self.dispatchChange(.changed, &self.cookies.items[self.cookies.items.len - 1]);
}
}
fn dispatchChange(
self: *Jar,
kind: Notification.CookieChanged.Kind,
cookie: *const Cookie,
) void {
const notification = self.notification orelse return;
notification.dispatch(.cookie_changed, &.{
.kind = kind,
.name = cookie.name,
.value = cookie.value,
.domain = cookie.domain,
.path = cookie.path,
.secure = cookie.secure,
.http_only = cookie.http_only,
.same_site = cookie.same_site,
});
}
pub fn removeExpired(self: *Jar, request_time: ?i64) void {
if (self.cookies.items.len == 0) return;
const time = request_time orelse std.time.timestamp();
@@ -691,7 +725,7 @@ test "Jar: add" {
const now = std.time.timestamp();
var jar = Jar.init(testing.allocator);
var jar = Jar.init(testing.allocator, null);
defer jar.deinit();
try expectCookies(&.{}, jar);
@@ -723,7 +757,7 @@ test "Jar: add" {
test "Jar: non-HTTP add must not replace or duplicate an HttpOnly cookie" {
const now = std.time.timestamp();
var jar = Jar.init(testing.allocator);
var jar = Jar.init(testing.allocator, null);
defer jar.deinit();
try jar.add(try Cookie.parse(testing.allocator, test_url, "session=REAL;Path=/;HttpOnly"), now, true);
@@ -740,7 +774,7 @@ test "Jar: non-HTTP add must not replace or duplicate an HttpOnly cookie" {
}
test "Jar: add limit" {
var jar = Jar.init(testing.allocator);
var jar = Jar.init(testing.allocator, null);
defer jar.deinit();
const now = std.time.timestamp();
@@ -802,7 +836,7 @@ test "Jar: forRequest" {
const now = std.time.timestamp();
var jar = Jar.init(testing.allocator);
var jar = Jar.init(testing.allocator, null);
defer jar.deinit();
const url2 = "http://test.lightpanda.io/";
@@ -948,7 +982,7 @@ test "Jar: forRequest SameSite=Strict on cross-site navigation" {
}
}.expect;
var jar = Jar.init(testing.allocator);
var jar = Jar.init(testing.allocator, null);
defer jar.deinit();
const victim_url: [:0]const u8 = "http://victim.example/";

View File

@@ -0,0 +1,538 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../../js/js.zig");
const URL = @import("../../URL.zig");
const Frame = @import("../../Frame.zig");
const Session = @import("../../Session.zig");
const Notification = @import("../../../Notification.zig");
const Cookie = @import("Cookie.zig");
const EventTarget = @import("../EventTarget.zig");
const CookieChangeEvent = @import("../event/CookieChangeEvent.zig");
const Allocator = std.mem.Allocator;
const Execution = js.Execution;
const String = lp.String;
pub fn registerTypes() []const type {
return &.{ CookieStore, CookieListItem };
}
// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore
const CookieStore = @This();
_proto: *EventTarget,
_on_change: ?js.Function.Global = null,
_exec: ?*Execution = null,
pub fn asEventTarget(self: *CookieStore) *EventTarget {
return self._proto;
}
/// Registers this CookieStore as a listener for jar-change notifications on
/// the given execution's session. Must be called once after construction by
/// the owning global (Window today; WorkerGlobalScope once wired up).
/// Idempotent on re-call.
pub fn attach(self: *CookieStore, exec: *Execution) !void {
if (self._exec != null) return;
self._exec = exec;
try exec.session.notification.register(.cookie_changed, self, onCookieChanged);
}
/// Removes this CookieStore from the notification list.
pub fn detach(self: *CookieStore) void {
const exec = self._exec orelse return;
exec.session.notification.unregisterAll(self);
self._exec = null;
}
fn onCookieChanged(ctx: *anyopaque, data: *const Notification.CookieChanged) !void {
const self: *CookieStore = @ptrCast(@alignCast(ctx));
const exec = self._exec orelse return;
// 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).
const doc_url = exec.url.*;
const target = Cookie.PreparedUri{
.host = URL.getHostname(doc_url),
.path = URL.getPathname(doc_url),
.secure = URL.isHTTPS(doc_url),
};
if (target.host.len == 0) return;
const probe = Cookie{
.arena = undefined,
.name = data.name,
.value = data.value,
.domain = data.domain,
.path = data.path,
.expires = null,
.secure = data.secure,
.http_only = data.http_only,
.same_site = data.same_site,
};
if (!probe.appliesTo(&target, true, true, false)) return;
// Per spec, `change` is dispatched as a queued task — never synchronously
// from the mutation site. We snapshot the notification fields onto a
// small page arena that the scheduled callback releases after dispatch.
const arena = try exec.getArena(.tiny, "CookieStore.change");
errdefer exec.releaseArena(arena);
const cb = try arena.create(ChangeCallback);
cb.* = .{
.cookie_store = self,
.exec = exec,
.arena = arena,
.kind = data.kind,
.name = try arena.dupe(u8, data.name),
.value = try arena.dupe(u8, data.value),
.domain = try arena.dupe(u8, data.domain),
.path = try arena.dupe(u8, data.path),
.secure = data.secure,
.same_site = data.same_site,
};
try exec.js.scheduler.add(cb, ChangeCallback.run, 0, .{
.name = "CookieStore.change",
.low_priority = false,
.finalizer = ChangeCallback.cancelled,
});
}
const ChangeCallback = struct {
cookie_store: *CookieStore,
// Execution is stored only to ensure we can release the arena.
// The CookieStore could have been detached in the meantime, and so its
// _exec pointer could have been reset.
exec: *Execution,
arena: Allocator,
kind: Notification.CookieChanged.Kind,
name: []const u8,
value: []const u8,
domain: []const u8,
path: []const u8,
secure: bool,
same_site: Cookie.SameSite,
fn cancelled(ctx: *anyopaque) void {
const self: *ChangeCallback = @ptrCast(@alignCast(ctx));
self.releaseArena();
}
fn releaseArena(self: *ChangeCallback) void {
self.exec.releaseArena(self.arena);
}
fn run(ctx: *anyopaque) !?u32 {
const self: *ChangeCallback = @ptrCast(@alignCast(ctx));
defer self.releaseArena();
const cs = self.cookie_store;
// We use the CookieStore's exec here instead of self.exec to detect if
// the store has been detached. In this case, we don't dispatch the
// event.
const exec = cs._exec orelse return null;
const target = cs.asEventTarget();
// Skip event construction when nobody is listening.
if (!exec.hasDirectListeners(target, "change", cs._on_change)) {
return null;
}
const event = try CookieChangeEvent.initSingle(self.kind, .{
.kind = self.kind,
.name = self.name,
.value = self.value,
.domain = self.domain,
.path = self.path,
.secure = self.secure,
.http_only = false,
.same_site = self.same_site,
}, exec);
try exec.dispatch(target, event.asEvent(), cs._on_change, .{
.context = "CookieStore.change",
});
return null;
}
};
pub fn getOnChange(self: *const CookieStore) ?js.Function.Global {
return self._on_change;
}
pub fn setOnChange(self: *CookieStore, setter: ?FunctionSetter) void {
const s = setter orelse {
self._on_change = null;
return;
};
self._on_change = switch (s) {
.func => |f| f,
.anything => null,
};
}
const FunctionSetter = union(enum) {
func: js.Function.Global,
anything: js.Value,
};
// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore/get
const GetOptions = struct {
name: ?[]const u8 = null,
url: ?[]const u8 = null,
};
const GetInput = union(enum) {
name: []const u8,
options: GetOptions,
};
// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore/set
const CookieInit = struct {
name: []const u8,
value: []const u8,
expires: ?f64 = null,
domain: ?[]const u8 = null,
path: []const u8 = "/",
sameSite: SameSite = .strict,
partitioned: bool = false,
};
const SetInput = union(enum) {
name: []const u8,
options: CookieInit,
};
// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore/delete
const DeleteOptions = struct {
name: []const u8,
domain: ?[]const u8 = null,
path: []const u8 = "/",
partitioned: bool = false,
};
const DeleteInput = union(enum) {
name: []const u8,
options: DeleteOptions,
};
const SameSite = enum {
strict,
lax,
none,
pub const js_enum_from_string = true;
};
pub fn get(_: *CookieStore, input: GetInput, exec: *const Execution) !js.Promise {
const local = exec.js.local.?;
const name: ?[]const u8, const url: ?[]const u8 = switch (input) {
.name => |n| .{ n, null },
.options => |o| .{ o.name, o.url },
};
const items = matchCookies(exec, name, url, true) catch |err| {
return local.rejectPromise(.{ .type_error = @errorName(err) });
};
if (items.len == 0) {
return local.resolvePromise(@as(?*CookieListItem, null));
}
return local.resolvePromise(items[0]);
}
pub fn getAll(_: *CookieStore, input: ?GetInput, exec: *const Execution) !js.Promise {
const local = exec.js.local.?;
const name: ?[]const u8, const url: ?[]const u8 = if (input) |inp| switch (inp) {
.name => |n| .{ n, null },
.options => |o| .{ o.name, o.url },
} else .{ null, null };
const items = matchCookies(exec, name, url, false) catch |err| {
return local.rejectPromise(.{ .type_error = @errorName(err) });
};
return local.resolvePromise(items);
}
pub fn set(_: *CookieStore, input: SetInput, value: ?[]const u8, exec: *const Execution) !js.Promise {
const local = exec.js.local.?;
const init: CookieInit = switch (input) {
.options => |o| o,
.name => |n| .{
.name = n,
.value = value orelse return local.rejectPromise(.{ .type_error = "value is required" }),
},
};
storeCookie(exec, init) catch |err| {
return local.rejectPromise(.{ .type_error = @errorName(err) });
};
return local.resolvePromise({});
}
pub fn delete(_: *CookieStore, input: DeleteInput, exec: *const Execution) !js.Promise {
const local = exec.js.local.?;
const opts: DeleteOptions = switch (input) {
.options => |o| o,
.name => |n| .{ .name = n },
};
// Deletion per spec is an expired set: write a cookie with the same
// name/path/domain but with Expires in the past, and the Jar will drop
// any existing match (or no-op if none).
storeCookie(exec, .{
.name = opts.name,
.value = "",
.expires = 0,
.domain = opts.domain,
.path = opts.path,
.sameSite = .strict,
.partitioned = opts.partitioned,
}) catch |err| {
return local.rejectPromise(.{ .type_error = @errorName(err) });
};
return local.resolvePromise({});
}
// Resolve the optional `url` per CookieStore.get/getAll spec. In a Window
// context, only the document's own URL is allowed (matches the cookie scope
// script already sees). In a Worker context, any same-origin URL is allowed.
fn resolveQueryUrl(exec: *const Execution, _override: ?[]const u8) ![:0]const u8 {
const current = exec.url.*;
const override = _override orelse return current;
const resolved = try URL.resolve(exec.call_arena, exec.base(), override, .{ .always_dupe = true });
if (!exec.isSameOrigin(resolved)) return error.SecurityError;
switch (exec.js.global) {
.frame => {
if (!std.mem.eql(u8, resolved, current)) return error.InvalidUrl;
},
.worker => {},
}
return resolved;
}
fn matchCookies(
exec: *const Execution,
name: ?[]const u8,
url: ?[]const u8,
first_only: bool,
) ![]*CookieListItem {
const session = exec.session;
const url_resolved = try resolveQueryUrl(exec, url);
const target = Cookie.PreparedUri{
.host = URL.getHostname(url_resolved),
.path = URL.getPathname(url_resolved),
.secure = URL.isHTTPS(url_resolved),
};
if (target.host.len == 0) return error.SecurityError;
session.cookie_jar.removeExpired(null);
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)) continue;
if (name) |n| {
if (!std.mem.eql(u8, cookie.name, n)) continue;
}
const item = try exec.arena.create(CookieListItem);
item.* = .{
.name = String.wrap(cookie.name),
.value = String.wrap(cookie.value),
.domain = if (cookie.domain.len > 0 and cookie.domain[0] == '.')
String.wrap(cookie.domain[1..])
else
null,
.path = String.wrap(cookie.path),
.expires = if (cookie.expires) |e| e * 1000.0 else null,
.secure = cookie.secure,
.sameSite = switch (cookie.same_site) {
.strict => .strict,
.lax => .lax,
.none => .none,
},
.partitioned = false,
};
try items.append(exec.call_arena, item);
if (first_only) break;
}
return items.items;
}
fn storeCookie(exec: *const Execution, init: CookieInit) !void {
const session = exec.session;
const url = exec.url.*;
// Reject inputs the cookie model can't represent. `=` is allowed in
// values but not in names; `;`/CR/LF/NUL break the cookie wire format
// everywhere and so are forbidden in every field.
if (init.name.len == 0) return error.InvalidCookieName;
if (std.mem.indexOfAny(u8, init.name, "=;\r\n\x00") != null) return error.InvalidCookieName;
if (std.mem.indexOfAny(u8, init.value, ";\r\n\x00") != null) return error.InvalidCookieValue;
if (std.mem.indexOfAny(u8, init.path, ";\r\n\x00") != null) return error.InvalidCookiePath;
if (init.domain) |d| {
if (std.mem.indexOfAny(u8, d, ";\r\n\x00") != null) return error.InvalidCookieDomain;
}
const is_https = URL.isHTTPS(url);
// Per spec, SameSite=None requires Secure. CookieStore additionally
// marks any cookie written from an HTTPS document as Secure.
const secure = is_https or init.sameSite == .none;
// Cookie-name-prefix rules — match Cookie.parse, case-insensitive to
// catch impersonation attempts (e.g. "__HoSt-").
// https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis#name-cookie-name-prefixes
if (std.ascii.startsWithIgnoreCase(init.name, "__Host-")) {
if (!is_https) return error.InvalidPrefixedCookie;
if (init.domain) |d| {
if (d.len > 0) return error.InvalidPrefixedCookie;
}
const effective_path = if (init.path.len > 0) init.path else "/";
if (!std.mem.eql(u8, effective_path, "/")) return error.InvalidPrefixedCookie;
} else if (std.ascii.startsWithIgnoreCase(init.name, "__Secure-")) {
if (!is_https) return error.InvalidPrefixedCookie;
}
var arena = std.heap.ArenaAllocator.init(session.cookie_jar.allocator);
errdefer arena.deinit();
const aa = arena.allocator();
const owned_name = try aa.dupe(u8, init.name);
const owned_value = try aa.dupe(u8, init.value);
const owned_path = try Cookie.parsePath(aa, url, init.path);
const owned_domain = try Cookie.parseDomain(aa, url, init.domain);
const cookie: Cookie = .{
.arena = arena,
.name = owned_name,
.value = owned_value,
.path = owned_path,
.domain = owned_domain,
// CookieStore.expires is a unix timestamp in milliseconds; Cookie tracks
// expiry in seconds. A timestamp at or before "now" deletes the cookie via
// the Jar's expiry path.
.expires = if (init.expires) |ms| ms / 1000.0 else null,
.secure = secure,
.http_only = false,
.same_site = switch (init.sameSite) {
.strict => .strict,
.lax => .lax,
.none => .none,
},
};
// CookieStore is a script API, so is_http = false.
try session.cookie_jar.add(cookie, std.time.timestamp(), false);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(CookieStore);
pub const Meta = struct {
pub const name = "CookieStore";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const get = bridge.function(CookieStore.get, .{ .dom_exception = true });
pub const getAll = bridge.function(CookieStore.getAll, .{ .dom_exception = true });
pub const set = bridge.function(CookieStore.set, .{ .dom_exception = true });
pub const delete = bridge.function(CookieStore.delete, .{ .dom_exception = true });
pub const onchange = bridge.accessor(CookieStore.getOnChange, CookieStore.setOnChange, .{});
};
// CookieListItem: per CookieStore.get / getAll return shape, documented inline on
// https://developer.mozilla.org/en-US/docs/Web/API/CookieStore
pub const CookieListItem = struct {
name: String,
value: String,
domain: ?String,
path: String,
expires: ?f64,
secure: bool,
sameSite: SameSite,
partitioned: bool,
fn getName(self: *const CookieListItem) String {
return self.name;
}
fn getValue(self: *const CookieListItem) String {
return self.value;
}
fn getDomain(self: *const CookieListItem) ?String {
return self.domain;
}
fn getPath(self: *const CookieListItem) String {
return self.path;
}
fn getExpires(self: *const CookieListItem) ?f64 {
return self.expires;
}
fn getSecure(self: *const CookieListItem) bool {
return self.secure;
}
fn getSameSite(self: *const CookieListItem) []const u8 {
return @tagName(self.sameSite);
}
fn getPartitioned(self: *const CookieListItem) bool {
return self.partitioned;
}
pub const JsApi = struct {
pub const bridge = js.Bridge(CookieListItem);
pub const Meta = struct {
pub const name = "CookieListItem";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const name = bridge.accessor(CookieListItem.getName, null, .{});
pub const value = bridge.accessor(CookieListItem.getValue, null, .{});
pub const domain = bridge.accessor(CookieListItem.getDomain, null, .{});
pub const path = bridge.accessor(CookieListItem.getPath, null, .{});
pub const expires = bridge.accessor(CookieListItem.getExpires, null, .{});
pub const secure = bridge.accessor(CookieListItem.getSecure, null, .{});
pub const sameSite = bridge.accessor(CookieListItem.getSameSite, null, .{});
pub const partitioned = bridge.accessor(CookieListItem.getPartitioned, null, .{});
};
};
const testing = @import("../../../testing.zig");
test "WebApi: CookieStore" {
try testing.htmlRunner("cookie_store.html", .{});
}

View File

@@ -564,8 +564,11 @@ test "tests:afterAll" {
@import("root").v8_peak_memory = test_browser.env.isolate.getHeapStatistics().total_physical_size;
test_notification.deinit();
// Browser must be deinit'd before the notification — Session/Frame
// teardown may unregister notification listeners (e.g. CookieStore
// detach), which dereferences `notification.listeners`.
test_browser.deinit();
test_notification.deinit();
test_app.deinit();
test_config.deinit(@import("root").tracking_allocator);
}