mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-07-30 17:25:58 -04:00
uaf: Fix potential UAF from js-created CookieChangeEvent
Add WebDriver.getNamedCookie to give WPT access to http-only cookie.
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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, .{});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user