uaf: Fix potential UAF from js-created CookieChangeEvent

Add WebDriver.getNamedCookie to give WPT access to http-only cookie.
This commit is contained in:
Karl Seguin
2026-07-20 12:45:37 +08:00
parent 4cdf8b441e
commit 3d59f0eec2
4 changed files with 105 additions and 12 deletions

View File

@@ -21,6 +21,7 @@
await state.done(() => {
testing.expectEqual('user', item.name);
testing.expectEqual('lp', item.value);
// We follow Chrome and expose the full attribute set, not just name/value.
testing.expectEqual('/', item.path);
testing.expectEqual('strict', item.sameSite);
testing.expectEqual(null, item.expires);
@@ -162,6 +163,18 @@
testing.expectEqual('change', ev.type);
testing.expectEqual(0, ev.changed.length);
testing.expectEqual(0, ev.deleted.length);
// The changed/deleted lists must be copied off the transient call arena;
// reading them after construction must stay valid.
const ev2 = new CookieChangeEvent('change', {
changed: [{ name: 'c1', value: 'v1' }, { name: 'c2', value: 'v2' }],
deleted: [{ name: 'd1', value: 'w1' }],
});
testing.expectEqual(2, ev2.changed.length);
testing.expectEqual('c1', ev2.changed[0].name);
testing.expectEqual('v2', ev2.changed[1].value);
testing.expectEqual(1, ev2.deleted.length);
testing.expectEqual('d1', ev2.deleted[0].name);
</script>
<script id=change-event-from-cookieStore-set type=module>

View File

@@ -23,6 +23,7 @@ const js = @import("../js/js.zig");
const Page = @import("../Page.zig");
const Frame = @import("../Frame.zig");
const Cookie = @import("storage/Cookie.zig");
const Element = @import("Element.zig");
const Event = @import("Event.zig");
const EventTarget = @import("EventTarget.zig");
@@ -76,6 +77,51 @@ pub fn click(_: *const WebDriver, element: *Element, frame: *Frame) !void {
dispatchMouse(element, "click", 0, 0, frame);
}
const WebDriverCookie = struct {
name: []const u8,
value: []const u8,
path: []const u8,
domain: []const u8,
secure: bool,
httpOnly: bool,
sameSite: []const u8,
expiry: ?f64,
};
// Unlike the script-facing CookieStore, WebDriver can see HttpOnly cookies.
pub fn getNamedCookie(_: *const WebDriver, name: []const u8, frame: *Frame) ?WebDriverCookie {
const jar = &frame._session.cookie_jar;
const target = Cookie.PreparedUri.init(frame.url);
if (target.host.len == 0) {
return null;
}
jar.removeExpired(null);
for (jar.cookies.items) |*cookie| {
if (cookie.appliesTo(&target, true, true, true) == false) {
continue;
}
if (std.mem.eql(u8, cookie.name, name) == false) {
continue;
}
return .{
.name = cookie.name,
.value = cookie.value,
.path = cookie.path,
.domain = if (cookie.domain.len > 0 and cookie.domain[0] == '.') cookie.domain[1..] else cookie.domain,
.secure = cookie.secure,
.httpOnly = cookie.http_only,
.sameSite = switch (cookie.same_site) {
.strict => "Strict",
.lax => "Lax",
.none => "None",
},
.expiry = cookie.expires,
};
}
return null;
}
// Implements testdriver's `action_sequence` (the WebDriver "Perform Actions"
// command) for the renderless browser. We can't do real hit-testing, so we only
// support the subset that targets a concrete element via `origin`. Each input
@@ -469,6 +515,7 @@ pub const JsApi = struct {
};
pub const deleteAllCookies = bridge.function(WebDriver.deleteAllCookies, .{});
pub const getComputedLabel = bridge.function(WebDriver.getComputedLabel, .{});
pub const getNamedCookie = bridge.function(WebDriver.getNamedCookie, .{});
pub const actionSequence = bridge.function(WebDriver.actionSequence, .{});
pub const click = bridge.function(WebDriver.click, .{});
};

View File

@@ -16,6 +16,7 @@
// 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");
@@ -26,6 +27,7 @@ const CookieStore = @import("../storage/CookieStore.zig");
const String = lp.String;
const Execution = js.Execution;
const Allocator = std.mem.Allocator;
// https://developer.mozilla.org/en-US/docs/Web/API/CookieChangeEvent
const CookieChangeEvent = @This();
@@ -53,8 +55,8 @@ pub fn init(typ: []const u8, _opts: ?Options, exec: *const Execution) !*CookieCh
type_string,
CookieChangeEvent{
._proto = undefined,
._changed = opts.changed orelse &.{},
._deleted = opts.deleted orelse &.{},
._changed = try cloneListItems(arena, opts.changed),
._deleted = try cloneListItems(arena, opts.deleted),
},
);
@@ -109,6 +111,25 @@ pub fn initSingle(
return event;
}
// Passed in from V8, everything is call_arena allocated, need to dupe it into our arena.
fn cloneListItems(arena: Allocator, items_: ?[]CookieStore.CookieListItem) ![]CookieStore.CookieListItem {
const items = items_ orelse return &.{};
const out = try arena.alloc(CookieStore.CookieListItem, items.len);
for (items, out) |src, *dst| {
dst.* = .{
.name = try String.init(arena, src.name.str(), .{}),
.value = if (src.value) |v| try String.init(arena, v.str(), .{}) else null,
.domain = if (src.domain) |d| try String.init(arena, d.str(), .{}) else null,
.path = try String.init(arena, src.path.str(), .{}),
.expires = src.expires,
.secure = src.secure,
.sameSite = try arena.dupe(u8, src.sameSite),
.partitioned = src.partitioned,
};
}
return out;
}
pub fn asEvent(self: *CookieChangeEvent) *Event {
return self._proto;
}

View File

@@ -411,7 +411,9 @@ fn matchCookies(
},
.partitioned = false,
});
if (first_only) break;
if (first_only) {
break;
}
}
return items.items;
@@ -436,6 +438,12 @@ fn storeCookie(exec: *const Execution, init_: CookieInit, is_delete: bool) !void
init.expires = (@as(f64, @floatFromInt(std.time.timestamp())) + max_age) * 1000.0;
}
if (init.expires) |ms| {
// 400 day expiry limit, per spec.
const cap_ms = (@as(f64, @floatFromInt(std.time.timestamp())) + 400 * std.time.s_per_day) * 1000.0;
init.expires = @min(ms, cap_ms);
}
// delete() may legitimately target a nameless cookie — its value is always empty.
if (!is_delete and init.name.len == 0) {
if (init.value.len == 0) {
@@ -507,8 +515,9 @@ fn storeCookie(exec: *const Execution, init_: CookieInit, is_delete: bool) !void
return error.InvalidPrefixedCookie;
}
}
const effective_path = if (init.path.len > 0) init.path else "/";
if (!std.mem.eql(u8, effective_path, "/")) {
const resolved_path = try Cookie.parsePath(exec.local_arena, url, init.path);
if (std.mem.eql(u8, resolved_path, "/") == false) {
return error.InvalidPrefixedCookie;
}
} else if (std.ascii.startsWithIgnoreCase(init.name, "__Secure-")) {
@@ -586,18 +595,21 @@ pub const JsApi = struct {
// CookieListItem is an plain JavaScript object, not an interface. The bridge
// automatically translate a Zig struct -> JS Object This should _not_ have a
// JsApi.
//
// NOTE: Per spec, name and value are the only valid fields. All other fields
// are experimental. Chrome exposes them all, so we do too.
pub const CookieListItem = struct {
name: String,
// Optional because a deletion change-event reports the removed cookie with
// `value` omitted (serialized as undefined via the `deleted` accessor's
// null_as_undefined). For get/getAll and `changed` items it is always set.
value: ?String,
domain: ?String,
path: String,
expires: ?f64,
secure: bool,
sameSite: []const u8,
partitioned: bool,
value: ?String = null,
domain: ?String = null,
path: String = String.wrap("/"),
expires: ?f64 = null,
secure: bool = false,
sameSite: []const u8 = "strict",
partitioned: bool = false,
};
const testing = @import("../../../testing.zig");