From 80640f720462c0300a04280bf09ad6ad36356dca Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 23 Sep 2026 12:54:19 +0800 Subject: [PATCH] http: add support for x-frame-options Brings /x-frame-options/ from 43/157 to 157/157. --- src/browser/Frame.zig | 13 ++ src/browser/frame/framing.zig | 167 +++++++++++++++++++++ src/browser/webapi/element/html/IFrame.zig | 11 +- 3 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/browser/frame/framing.zig diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 38e078594..d537c2df6 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -71,6 +71,7 @@ const GlobalScope = @import("global_scope.zig").GlobalScope; const GlobalEventHandlersLookup = @import("webapi/global_event_handlers.zig").Lookup; +const framing = @import("frame/framing.zig"); pub const parse = @import("frame/parse.zig"); pub const preload = @import("frame/preload.zig"); pub const resource_load = @import("frame/resource_load.zig"); @@ -1444,6 +1445,15 @@ fn frameHeaderDoneCallback(transfer: *HttpClient.Transfer) !HttpClient.Transfer. self.url = try self.arena.dupeZ(u8, response_url); self.origin = try URL.getOrigin(self.arena, self.url); } + + if (self.parent != null and framing.allowed(self, transfer) == false) { + log.warn(.frame, "x-frame-options blocked", .{ .url = self.url }); + // give this an opaque origin so that any request to the error page + // is treated as being cross-origin + self.origin = null; + try self.js.setOrigin(null); + return error.XFrameOptionsDenied; + } try self.js.setOrigin(self.origin); // After any redirect, drop the original method/body/header so a later @@ -2041,6 +2051,9 @@ pub fn iframeAddedCallback(self: *Frame, iframe: *IFrame) !void { try Frame.init(new_frame, frame_id, self.page, .{ .parent = self }); errdefer new_frame.deinit(); + // until the navigate commits, the iframe is about:blank and inherits the parent's origin + try new_frame.js.setOrigin(self.origin); + const delays_load = iframe.isLazyLoading() == false; new_frame._delays_parent_load = delays_load; if (delays_load) { diff --git a/src/browser/frame/framing.zig b/src/browser/frame/framing.zig new file mode 100644 index 000000000..f16f3ad82 --- /dev/null +++ b/src/browser/frame/framing.zig @@ -0,0 +1,167 @@ +// Copyright (C) 2023 - 2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +const std = @import("std"); + +const Frame = @import("../Frame.zig"); +const HttpClient = @import("../../network/HttpClient.zig"); + +// https://html.spec.whatwg.org/multipage/document-lifecycle.html#the-x-frame-options-header +pub fn allowed(frame: *const Frame, transfer: *HttpClient.Transfer) bool { + var options: XFrameOptions = .{}; + var it = transfer.responseHeaderIterator(); + while (it.next()) |hdr| { + if (std.ascii.eqlIgnoreCase(hdr.name, "content-security-policy")) { + if (hasFrameAncestors(hdr.value)) { + // has priority over any x-frame-options + return true; + } + } else if (std.ascii.eqlIgnoreCase(hdr.name, "x-frame-options")) { + options.add(hdr.value); + } + } + + switch (options.policy()) { + .allow => return true, + .deny => return false, + .same_origin => { + const origin = frame.origin orelse return false; + var ancestor = frame.parent; + while (ancestor) |a| : (ancestor = a.parent) { + // with a same-origin value, every ancestor has to be + // the same origin + const ancestor_origin = a.origin orelse return false; + if (std.mem.eql(u8, origin, ancestor_origin) == false) { + return false; + } + } + return true; + }, + } +} + +fn hasFrameAncestors(csp: []const u8) bool { + const name = "frame-ancestors"; + var pos: usize = 0; + while (std.ascii.indexOfIgnoreCasePos(csp, pos, name)) |start| { + pos = start + name.len; + + // A directive name starts a policy (',') or a directive (';'), so + // `script-src frame-ancestors` (a host source) doesn't count. + const before = std.mem.trimEnd(u8, csp[0..start], HTTP_WHITESPACE); + if (before.len != 0) { + const last = before[before.len - 1]; + if (last != ';' and last != ',') { + continue; + } + } + if (pos == csp.len or std.mem.indexOfScalar(u8, HTTP_WHITESPACE ++ ";,", csp[pos]) != null) { + return true; + } + } + return false; +} + +const HTTP_WHITESPACE = " \t\r\n"; + +const XFrameOptions = struct { + first: ?[]const u8 = null, + conflict: bool = false, + has_keyword: bool = false, + + const Policy = enum { allow, deny, same_origin }; + + fn add(self: *XFrameOptions, value: []const u8) void { + var it = std.mem.splitScalar(u8, value, ','); + while (it.next()) |token| { + const v = std.mem.trim(u8, token, HTTP_WHITESPACE); + if (keyword(v) != null) { + self.has_keyword = true; + } + if (self.first) |first| { + // we care about the first value and if any subsequent values are different + if (std.ascii.eqlIgnoreCase(first, v) == false) { + self.conflict = true; + } + } else { + self.first = v; + } + } + } + + fn policy(self: *const XFrameOptions) Policy { + const first = self.first orelse return .allow; + if (self.conflict) { + // conflict is a fail, unless they all had meaningless values + return if (self.has_keyword) .deny else .allow; + } + return switch (keyword(first) orelse return .allow) { + .deny => .deny, + .sameorigin => .same_origin, + .allowall => .allow, + }; + } + + fn keyword(value: []const u8) ?enum { deny, sameorigin, allowall } { + if (std.ascii.eqlIgnoreCase(value, "deny")) { + return .deny; + } + if (std.ascii.eqlIgnoreCase(value, "sameorigin")) { + return .sameorigin; + } + if (std.ascii.eqlIgnoreCase(value, "allowall")) { + return .allowall; + } + return null; + } +}; + +const testing = @import("../../testing.zig"); +test "framing: XFrameOptions" { + const expectPolicy = struct { + fn expectPolicy(expected: XFrameOptions.Policy, values: []const []const u8) !void { + var xfo: XFrameOptions = .{}; + for (values) |v| xfo.add(v); + try testing.expectEqual(expected, xfo.policy()); + } + }.expectPolicy; + + try expectPolicy(.allow, &.{}); + try expectPolicy(.allow, &.{""}); + try expectPolicy(.allow, &.{"INVALID"}); + try expectPolicy(.allow, &.{"ALLOWALL"}); + try expectPolicy(.allow, &.{"\x0bDENY"}); + try expectPolicy(.allow, &.{ "INVALID", "" }); + try expectPolicy(.deny, &.{" denY "}); + try expectPolicy(.deny, &.{ "DENY", "deny" }); + try expectPolicy(.deny, &.{",SAMEORIGIN,,DENY,"}); + try expectPolicy(.deny, &.{ "SAMEORIGIN", "DENY" }); + try expectPolicy(.deny, &.{"ALLOWALL,"}); + try expectPolicy(.deny, &.{ "INVALID", "allowAll" }); + try expectPolicy(.same_origin, &.{ "SAMEORIGIN", "sameOrigin" }); + + try testing.expect(hasFrameAncestors("default-src 'self'; frame-ancestors 'self'")); + try testing.expect(hasFrameAncestors("default-src 'self', FRAME-ANCESTORS")); + try testing.expect(hasFrameAncestors("default-src 'self'") == false); + try testing.expect(hasFrameAncestors("frame-ancestors-x 'self'") == false); + try testing.expect(hasFrameAncestors("frame-ancestors")); + try testing.expect(hasFrameAncestors("frame-ancestors;")); + try testing.expect(hasFrameAncestors("script-src frame-ancestors") == false); + try testing.expect(hasFrameAncestors("x-frame-ancestors 'self'") == false); + try testing.expect(hasFrameAncestors("script-src frame-ancestors; frame-ancestors 'none'")); +} diff --git a/src/browser/webapi/element/html/IFrame.zig b/src/browser/webapi/element/html/IFrame.zig index b9526195a..3cd5a4ef0 100644 --- a/src/browser/webapi/element/html/IFrame.zig +++ b/src/browser/webapi/element/html/IFrame.zig @@ -126,7 +126,16 @@ pub const JsApi = struct { pub const srcdoc = bridge.accessor(IFrame.getSrcdoc, IFrame.setSrcdoc, .{ .ce_reactions = true }); pub const name = reflect.string("name"); pub const contentWindow = bridge.accessor(IFrame.getContentWindow, null, .{}); - pub const contentDocument = bridge.accessor(IFrame.getContentDocument, null, .{}); + pub const contentDocument = bridge.accessor(struct { + fn wrap(self: *const IFrame, frame: *Frame) ?*Document { + // specific JS implementation which is origin-aware. + const window = self._window orelse return null; + if (window._frame.js.origin != frame.js.origin) { + return null; + } + return window._document; + } + }.wrap, null, .{}); pub const sandbox = bridge.accessor(IFrame.getSandbox, null, .{ .null_as_undefined = true }); };