Merge pull request #2801 from lightpanda-io/document-setDomain

webapi: Expose document.domain setter
This commit is contained in:
Karl Seguin
2026-06-25 14:13:59 +08:00
committed by GitHub
6 changed files with 146 additions and 20 deletions

View File

@@ -320,7 +320,7 @@ fn findPopupBy(self: *Page, comptime field: []const u8, id: u32) ?*Frame {
//
// The returned set is fixed, so a caller may run user JS (which can create or
// tear down frames/workers) while walking it without invalidating the slice.
pub fn executionsForOrigin(self: *Page, arena: Allocator, origin: *js.Origin) ![]*js.Execution {
pub fn executionsForOrigin(self: *Page, arena: Allocator, origin: []const u8) ![]*js.Execution {
var list: std.ArrayList(*js.Execution) = .empty;
try appendFrameExecutions(&self.frame, origin, arena, &list);
for (self.popups.items) |popup| {
@@ -329,14 +329,18 @@ pub fn executionsForOrigin(self: *Page, arena: Allocator, origin: *js.Origin) ![
return list.items;
}
fn appendFrameExecutions(frame: *Frame, origin: *js.Origin, arena: Allocator, list: *std.ArrayList(*js.Execution)) !void {
if (frame.js.origin == origin) {
try list.append(arena, &frame.js.execution);
fn appendFrameExecutions(frame: *Frame, origin: []const u8, arena: Allocator, list: *std.ArrayList(*js.Execution)) !void {
if (frame.origin) |fo| {
if (std.mem.eql(u8, fo, origin)) {
try list.append(arena, &frame.js.execution);
}
}
for (frame.workers.items) |worker| {
const wgs = worker._worker_scope;
if (wgs.js.origin == origin) {
try list.append(arena, &wgs.js.execution);
if (wgs.origin) |wo| {
if (std.mem.eql(u8, wo, origin)) {
try list.append(arena, &wgs.js.execution);
}
}
}
for (frame.child_frames.items) |child| {

View File

@@ -537,6 +537,17 @@ pub fn getHostname(raw: [:0]const u8) []const u8 {
return host[0..port_sep];
}
// Like getHostname, but for an origin serialization ("scheme://host[:port]"),
// which is not necessarily sentinel-terminated. Used where the document's host
// must come from its (possibly inherited) origin rather than its URL — e.g. an
// about:blank iframe whose url stays "about:blank" but whose origin is the
// parent's.
pub fn getOriginHostname(origin: []const u8) []const u8 {
const host = getHost(origin);
const port_sep = findPortSeparator(host) orelse return host;
return host[0..port_sep];
}
pub fn getPort(raw: [:0]const u8) []const u8 {
const host = getHost(raw);
const port_sep = findPortSeparator(host) orelse return "";

View File

@@ -229,18 +229,11 @@ pub fn deinit(self: *Context) void {
v8.v8__MicrotaskQueue__DELETE(self.microtask_queue);
}
// setOrigin is called at navigation (opaque -> real origin) and again when a
// script sets document.domain (real origin -> '!'-marked effective domain).
pub fn setOrigin(self: *Context, key: ?[]const u8) !void {
const env = self.env;
const isolate = env.isolate;
if (comptime IS_DEBUG) {
// A frame starts off with an opaque origin. After navigation, setOrigin
// is called. This is the only time setOrigin should be called for that
// frame. Therefore, when setOrigin is called, the previous origin should
// have been opaque and its rc should have been 1.
lp.assert(self.origin.rc == 1, "Ref opaque origin", .{ .rc = self.origin.rc });
}
const origin = try self.page.getOrCreateOrigin(key);
self.page.releaseOrigin(self.origin);

View File

@@ -62,6 +62,14 @@
testing.expectEqual(document.URL, document.documentURI);
testing.expectEqual('', document.referrer);
testing.expectEqual(testing.HOST, document.domain);
document.domain = testing.HOST;
testing.expectEqual(testing.HOST, document.domain);
testing.expectError('SecurityError', () => { document.domain = 'example.org'; });
testing.expectError('SecurityError', () => { document.domain = 'com'; });
testing.expectError('SecurityError', () => { document.domain = ''; });
// Rejected assignments must not have changed the domain.
testing.expectEqual(testing.HOST, document.domain);
</script>
<script id=programmatic_document_metadata>

View File

@@ -160,11 +160,11 @@ const PostMessageCallback = struct {
const sender = self.sender;
const page = self.exec.page;
const origin = self.exec.js.origin;
const origin = self.exec.origin();
// MessageEvent.origin is the serialization of the sender's origin (same
// for every receiver); an opaque origin serializes to "null".
const sender_origin = self.exec.origin() orelse "null";
const sender_origin = origin orelse "null";
// Snapshot every same-origin global up front. Dispatch (below) runs user
// JS that can create or tear down frames/workers, so we must not hold a
@@ -174,7 +174,14 @@ const PostMessageCallback = struct {
const arena = try page.getArena(.tiny, "BroadcastChannel.postMessage");
defer page.releaseArena(arena);
for (try page.executionsForOrigin(arena, origin)) |exec| {
// Opaque origins have no string form and are unique per execution, so
// the sender is the only same-origin context
const executions = if (origin) |o|
try page.executionsForOrigin(arena, o)
else
(&self.exec)[0..1];
for (executions) |exec| {
// The MessageEvent and its cloned `data` must live in the receiver's
// realm, and its listeners are in the receiver's event manager.
// localScope enters the receiver's v8 context, so both happen there.

View File

@@ -22,6 +22,8 @@ const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const Frame = @import("../Frame.zig");
const URL = @import("../URL.zig");
const idna = @import("../../sys/idna.zig");
const public_suffix_list = @import("../../data/public_suffix_list.zig");
const Node = @import("Node.zig");
const Element = @import("Element.zig");
@@ -147,7 +149,80 @@ pub fn getContentType(self: *const Document) []const u8 {
}
pub fn getDomain(self: *const Document, frame: *const Frame) []const u8 {
return URL.getHostname((self._frame orelse frame).url);
const doc_frame = self._frame orelse frame;
// When document.domain has been set, the effective domain is encoded in
// the origin key with a leading '!' marker. The key is a "!scheme://host"
// serialization with the port already dropped, so the host *is* the
// effective domain.
const key = doc_frame.js.origin.key;
if (key.len != 0 and key[0] == '!') {
return URL.getHost(key[1..]);
}
// Derive from the origin, not the URL: an about:blank child inherits the
// parent origin while keeping url == "about:blank". Opaque origin => "".
const origin = doc_frame.origin orelse return "";
return URL.getOriginHostname(origin);
}
pub fn setDomain(self: *Document, value: []const u8) !void {
// e.g. (new Document().domain = '')
const doc_frame = self._frame orelse return error.SecurityError;
const origin = doc_frame.origin orelse return error.SecurityError;
const arena = doc_frame.call_arena;
const requested = if (idna.needsAscii(value)) try idna.toAscii(arena, value) else value;
// Validate against the current effective domain. Once relaxed,
// document.domain can only broaden further.
const base = self.getDomain(doc_frame);
if (isRelaxableTo(base, requested) == false) {
return error.SecurityError;
}
// When the domain is explicitly set, it only matches other explicitly set
// domains. We do this by prepending a '!' to the origin, so that it can
// only ever match another explicitly set domain.
// The scheme is preserved (http and https must never collide) and the
// port is dropped, per spec.
const scheme_end = (std.mem.indexOf(u8, origin, "://") orelse return error.SecurityError) + 3;
const key = try std.mem.concat(arena, u8, &.{ "!", origin[0..scheme_end], requested });
try doc_frame.js.setOrigin(key);
}
// Returns true if the requested domain is valid for the given host
fn isRelaxableTo(host: []const u8, requested: []const u8) bool {
if (requested.len == 0) {
return false;
}
// Pure opt-in: relaxing to your own host is always allowed (and it's the
// only valid value for IPs)
if (std.mem.eql(u8, host, requested)) {
return true;
}
// request must be a subset of host, so it must be smaller
if (host.len <= requested.len) {
return false;
}
if (host[host.len - requested.len - 1] != '.') {
return false;
}
if (std.mem.endsWith(u8, host, requested) == false) {
return false;
}
// it can't be a bare TLD, "com"
if (std.mem.indexOfScalar(u8, requested, '.') == null) {
return false;
}
// and it can't be a public suffix (e.g. "gov.uk")
return public_suffix_list.lookup(requested) == false;
}
const CreateElementOptions = struct {
@@ -1178,7 +1253,7 @@ pub const JsApi = struct {
pub const styleSheets = bridge.accessor(Document.getStyleSheets, null, .{});
pub const fonts = bridge.accessor(Document.getFonts, null, .{});
pub const contentType = bridge.accessor(Document.getContentType, null, .{});
pub const domain = bridge.accessor(Document.getDomain, null, .{});
pub const domain = bridge.accessor(Document.getDomain, Document.setDomain, .{ .dom_exception = true });
pub const createElement = bridge.function(Document.createElement, .{ .dom_exception = true });
pub const createElementNS = bridge.function(Document.createElementNS, .{ .dom_exception = true });
pub const createDocumentFragment = bridge.function(Document.createDocumentFragment, .{});
@@ -1261,3 +1336,31 @@ test "WebApi: Document" {
test "WebApi: Document.evaluate" {
try testing.htmlRunner("xpath/document_evaluate.html", .{});
}
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"));
try testing.expectEqual(true, isRelaxableTo("127.0.0.1", "127.0.0.1"));
// Relaxing to a registrable superdomain.
try testing.expectEqual(true, isRelaxableTo("a.example.com", "example.com"));
try testing.expectEqual(true, isRelaxableTo("a.b.example.com", "example.com"));
try testing.expectEqual(true, isRelaxableTo("a.b.example.com", "b.example.com"));
// Bare TLDs (single label) are never registrable. Multi-label public
// suffixes are rejected via the PSL — note the test build stubs the PSL
// to {gov.uk, api.gov.uk}, so those are the entries exercised here.
try testing.expectEqual(false, isRelaxableTo("a.example.com", "com"));
try testing.expectEqual(false, isRelaxableTo("foo.gov.uk", "gov.uk"));
try testing.expectEqual(false, isRelaxableTo("a.api.gov.uk", "api.gov.uk"));
// ...but a registrable domain sitting under that suffix is fine.
try testing.expectEqual(true, isRelaxableTo("a.dept.gov.uk", "dept.gov.uk"));
// Must be a label-boundary suffix, not a substring suffix.
try testing.expectEqual(false, isRelaxableTo("a.example.com", "ample.com"));
try testing.expectEqual(false, isRelaxableTo("notexample.com", "example.com"));
// Unrelated domain, and the empty string.
try testing.expectEqual(false, isRelaxableTo("a.example.com", "example.org"));
try testing.expectEqual(false, isRelaxableTo("a.example.com", ""));
}