From eba58d7d576534e506ab538f6d1f65a36d0a290b Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 24 Sep 2026 09:38:34 +0800 Subject: [PATCH] webapi: Sanitizer API usage Builds ontop of https://github.com/lightpanda-io/browser/pull/3604 to actually use a Sanitizer across 6 apis (some of which we didn't have at all, and some of which were incomplete). Namely setHtml and setHTMLUnsafe for Element, ShadowRoot and Document. --- src/browser/frame/parse.zig | 41 +++ src/browser/markdown.zig | 2 +- src/browser/screenshot.zig | 2 +- src/browser/tests/sanitizer.html | 233 ++++++++++++++ src/browser/webapi/DOMParser.zig | 38 +-- src/browser/webapi/Document.zig | 11 + src/browser/webapi/DocumentFragment.zig | 6 - src/browser/webapi/Element.zig | 18 +- src/browser/webapi/Node.zig | 22 ++ src/browser/webapi/Sanitizer.zig | 397 +++++++++++++++++++++++- src/browser/webapi/ShadowRoot.zig | 10 +- 11 files changed, 724 insertions(+), 56 deletions(-) diff --git a/src/browser/frame/parse.zig b/src/browser/frame/parse.zig index 946fb0978..b697db90e 100644 --- a/src/browser/frame/parse.zig +++ b/src/browser/frame/parse.zig @@ -16,12 +16,15 @@ // 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 Parser = @import("../parser/Parser.zig"); const Node = @import("../webapi/Node.zig"); const Element = @import("../webapi/Element.zig"); const Document = @import("../webapi/Document.zig"); +const HTMLDocument = @import("../webapi/HTMLDocument.zig"); const ShadowRoot = @import("../webapi/ShadowRoot.zig"); const slotting = @import("../webapi/element/slotting.zig"); @@ -101,6 +104,44 @@ pub fn fragment(frame: *Frame, node: *Node, html: []const u8, opts: FragmentPars try Element.Html.Picture.childrenInserted(node, frame); } +pub const HtmlDocumentOpts = struct { + allow_declarative_shadow: bool = false, +}; + +// Build a detached HTMLDocument from `html` (DOMParser.parseFromString and +// Document.parseHTML). The caller sets its URL. +pub fn htmlDocument(frame: *Frame, html: []const u8, opts: HtmlDocumentOpts) !*HTMLDocument { + const arena = try frame.getArena(.medium, "parse.htmlDocument"); + defer arena.release(); + + // Frame-side hooks triggered from `Build.created` / `nodeIsReady` + // (external stylesheet fetches, script execution, mutation-observer + // fan-out, default-script injection) treat the parsed nodes as detached + // and skip side effects on the live document. + const previous_parse_mode = frame._parse_mode; + frame._parse_mode = .fragment; + defer frame._parse_mode = previous_parse_mode; + + const doc = try frame._factory.document(HTMLDocument{ ._proto = undefined }); + + var normalized = std.mem.trim(u8, html, &std.ascii.whitespace); + if (normalized.len == 0) { + normalized = ""; + } + + var parser = Parser.init(arena.allocator(), doc.asNode(), frame, .{ + .allow_declarative_shadow = opts.allow_declarative_shadow, + }); + parser.parse(normalized); + if (parser.terminated) { + return error.ExecutionTerminated; + } + if (parser.err) |pe| { + return pe.err; + } + return doc; +} + // Build a detached XMLDocument from `xml` (DOMParser.parseFromString and // XMLHttpRequest.responseXML). Returns null when the input isn't well-formed // XML. diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index d777fa760..c7c137f2d 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -952,7 +952,7 @@ test "browser.markdown: declarative shadow DOM renders through piercing" { const host = try doc.createElement("div", null, frame); try host.setHTMLUnsafe( \\
- , frame); + , null, frame); var aw: std.Io.Writer.Allocating = .init(testing.allocator); defer aw.deinit(); diff --git a/src/browser/screenshot.zig b/src/browser/screenshot.zig index 8237b0577..00d88b337 100644 --- a/src/browser/screenshot.zig +++ b/src/browser/screenshot.zig @@ -1468,7 +1468,7 @@ test "browser.screenshot: shadow dom and slots" { const div = try doc.createElement("div", null, frame); try div.setHTMLUnsafe( \\light - , frame); + , null, frame); var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame, .tree = .{ .frame = frame, .state = .{ .root = div.asNode() } } }; try builder.render(div.asNode()); diff --git a/src/browser/tests/sanitizer.html b/src/browser/tests/sanitizer.html index 0f85b0c61..e11a37d17 100644 --- a/src/browser/tests/sanitizer.html +++ b/src/browser/tests/sanitizer.html @@ -368,3 +368,236 @@ testing.expectEqual(false, config.javascriptURLs); } + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig index f3f02fa0a..337118aa5 100644 --- a/src/browser/webapi/DOMParser.zig +++ b/src/browser/webapi/DOMParser.zig @@ -21,11 +21,9 @@ const std = @import("std"); const js = @import("../js/js.zig"); const Frame = @import("../Frame.zig"); -const Parser = @import("../parser/Parser.zig"); const Node = @import("Node.zig"); const Document = @import("Document.zig"); -const HTMLDocument = @import("HTMLDocument.zig"); const DOMParser = @This(); @@ -45,42 +43,8 @@ pub fn parseFromString( switch (target_mime) { .@"text/html" => { - const arena = try frame.getArena(.medium, "DOMParser.parseFromString"); - defer arena.release(); - - // DOMParser builds a detached Document. Borrow the same fragment - // parse-mode that `Frame.parse` uses so frame-side hooks - // triggered from `Build.created` / `nodeIsReady` (external - // stylesheet fetches, script execution, mutation-observer fan-out, - // default-script injection) treat the parsed nodes as detached and - // skip side effects on the live document. The frame's - // `_parse_mode` is restored on exit. - const previous_parse_mode = frame._parse_mode; - frame._parse_mode = .fragment; - defer frame._parse_mode = previous_parse_mode; - - // Create a new HTMLDocument - const doc = try frame._factory.document(HTMLDocument{ - ._proto = undefined, - }); + const doc = try Frame.parse.htmlDocument(frame, html, .{}); doc.asDocument()._url = frame.url; - - var normalized = std.mem.trim(u8, html, &std.ascii.whitespace); - if (normalized.len == 0) { - normalized = ""; - } - - // Parse HTML into the document - var parser = Parser.init(arena.allocator(), doc.asNode(), frame, .{}); - parser.parse(normalized); - if (parser.terminated) { - return error.ExecutionTerminated; - } - - if (parser.err) |pe| { - return pe.err; - } - return doc.asDocument(); }, else => { diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 226add2d6..9d0f9c6e7 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -40,6 +40,7 @@ const DOMImplementation = @import("DOMImplementation.zig"); const StyleSheetList = @import("css/StyleSheetList.zig"); const FontFaceSet = @import("css/FontFaceSet.zig"); const Selection = @import("Selection.zig"); +const Sanitizer = @import("Sanitizer.zig"); const XPathResult = @import("XPathResult.zig"); const XPathExpression = @import("XPathExpression.zig"); @@ -1543,6 +1544,16 @@ pub const JsApi = struct { return frame._factory.genericDocument(.{ .url = "about:blank", .charset = "UTF-8" }); } + pub const parseHTML = bridge.function(_parseHTML, .{ .static = true }); + fn _parseHTML(html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !*Document { + return Sanitizer.parseHTML(html, options, true, frame); + } + + pub const parseHTMLUnsafe = bridge.function(_parseHTMLUnsafe, .{ .static = true }); + fn _parseHTMLUnsafe(html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !*Document { + return Sanitizer.parseHTML(html, options, false, frame); + } + pub const onselectionchange = bridge.accessor(Document.getOnSelectionChange, Document.setOnSelectionChange, .{}); pub const onclick = bridge.accessor(Document.getOnClick, Document.setOnClick, .{}); pub const ontouchstart = bridge.accessor(handlerAccessor(.ontouchstart).get, handlerAccessor(.ontouchstart).set, .{}); diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index aef884fcc..0457361f0 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -165,12 +165,6 @@ pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, frame: *Frame) !v return parent.setHTML(html, .{}, frame); } -/// allows declarative shadow dom -pub fn setHTMLUnsafe(self: *DocumentFragment, html: []const u8, frame: *Frame) !void { - const parent = self.asNode(); - return parent.setHTML(html, .{ .allow_declarative_shadow = true }, frame); -} - pub fn cloneFragment(self: *DocumentFragment, deep: bool, document: *const Node.Document, frame: *Frame) !*Node { const fragment = try DocumentFragment.init(document, frame); const fragment_node = fragment.asNode(); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index 4283c67ba..89ba05b9b 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -29,6 +29,7 @@ const Node = @import("Node.zig"); const ShadowRoot = @import("ShadowRoot.zig"); const EventTarget = @import("EventTarget.zig"); const collections = @import("collections.zig"); +const Sanitizer = @import("Sanitizer.zig"); const Selector = @import("selector/Selector.zig"); const Animation = @import("animation/Animation.zig"); @@ -627,10 +628,20 @@ pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void { return parent.setHTML(html, .{}, frame); } +pub fn setHTML(self: *Element, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void { + return Sanitizer.setAndFilterHTML(self.htmlTarget(), self, html, options, true, frame); +} + /// allows declarative shadow dom -pub fn setHTMLUnsafe(self: *Element, html: []const u8, frame: *Frame) !void { - const parent = self.asNode(); - return parent.setHTML(html, .{ .allow_declarative_shadow = true }, frame); +pub fn setHTMLUnsafe(self: *Element, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void { + return Sanitizer.setAndFilterHTML(self.htmlTarget(), self, html, options, false, frame); +} + +fn htmlTarget(self: *Element) *Node { + if (self.is(Html.Template)) |template| { + return template.getContent().asNode(); + } + return self.asNode(); } pub fn getId(self: *const Element) ?[]const u8 { @@ -2662,6 +2673,7 @@ pub const JsApi = struct { pub const assignedSlot = bridge.accessor(Element.getAssignedSlot, null, .{}); pub const attachShadow = bridge.function(_attachShadow, .{}); pub const insertAdjacentHTML = bridge.function(Element.insertAdjacentHTML, .{ .ce_reactions = true }); + pub const setHTML = bridge.function(Element.setHTML, .{ .ce_reactions = true }); pub const setHTMLUnsafe = bridge.function(Element.setHTMLUnsafe, .{ .ce_reactions = true }); pub const insertAdjacentElement = bridge.function(Element.insertAdjacentElement, .{ .ce_reactions = true }); pub const insertAdjacentText = bridge.function(Element.insertAdjacentText, .{ .ce_reactions = true }); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 364178b6d..4e885bbf9 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -1613,6 +1613,28 @@ pub fn setHTML(self: *Node, html: []const u8, opts: Frame.parse.FragmentParseOpt } } +pub fn replaceAllWithFragment(self: *Node, fragment: *Node, frame: *Frame) !void { + frame.domChanged(); + + const notify = Frame.observers.hasMutationObservers(frame); + var added: std.ArrayList(*Node) = .empty; + if (notify) { + var it = fragment.childrenIterator(); + while (it.next()) |child| { + try added.append(frame.call_arena, child); + } + } + + const removed = try self.removeAllChildrenCollecting(notify, frame); + try frame.moveAllChildren(fragment, self, null, .silent_parent); + + if (notify and (removed.items.len > 0 or added.items.len > 0)) { + // The point here is to batch all of the adds/remove and get a combined + // mutation record + Frame.observers.notifyChildListChange(frame, self, added.items, removed.items, null, null); + } +} + // Writes a JSON representation of the node and its children pub fn jsonStringify(self: *const Node, writer: *std.json.Stringify) !void { // stupid json api requires this to be const, diff --git a/src/browser/webapi/Sanitizer.zig b/src/browser/webapi/Sanitizer.zig index 3650356af..6cf57a724 100644 --- a/src/browser/webapi/Sanitizer.zig +++ b/src/browser/webapi/Sanitizer.zig @@ -30,7 +30,11 @@ const lp = @import("lightpanda"); const js = @import("../js/js.zig"); const Page = @import("../Page.zig"); +const Frame = @import("../Frame.zig"); +const Node = @import("Node.zig"); +const Element = @import("Element.zig"); +const TreeWalker = @import("TreeWalker.zig"); const defaults = @import("sanitizer_defaults.zig"); const String = lp.String; @@ -221,6 +225,10 @@ const Config = struct { // ?js.Value because not provided, undefined and null are all handled differently pub fn init(configuration_: ?js.Value, exec: *const Execution) !*Sanitizer { + return create(configuration_, true, exec); +} + +fn create(configuration_: ?js.Value, permissive_defaults: bool, exec: *const Execution) !*Sanitizer { const arena = try exec.getPinnedArena(.small, "Sanitizer"); errdefer arena.release(); @@ -247,7 +255,7 @@ pub fn init(configuration_: ?js.Value, exec: *const Execution) !*Sanitizer { } const config: Config = if (configuration.isNull()) .{} else try configuration.toZig(Config); - if (try self.setFromConfig(config) == false) { + if (try self.setFromConfig(config, permissive_defaults) == false) { return exec.js.typeError("invalid Sanitizer configuration"); } } @@ -301,7 +309,7 @@ fn setFromDefault(self: *Sanitizer) !void { self._javascript_urls = false; } -fn setFromConfig(self: *Sanitizer, config: Config) !bool { +fn setFromConfig(self: *Sanitizer, config: Config, permissive_defaults: bool) !bool { var all_new = true; const arena = self._arena; @@ -348,11 +356,11 @@ fn setFromConfig(self: *Sanitizer, config: Config) !bool { self._remove_processing_instructions = try self.targetSet(pis, &all_new); } - self._comments = Config.boolean(config.comments, true); + self._comments = Config.boolean(config.comments, permissive_defaults); if (self._allow_attributes != null or config.dataAttributes != null) { - self._data_attributes = Config.boolean(config.dataAttributes, true); + self._data_attributes = Config.boolean(config.dataAttributes, permissive_defaults); } - self._javascript_urls = Config.boolean(config.javascriptURLs, true); + self._javascript_urls = Config.boolean(config.javascriptURLs, permissive_defaults); if (config.elements == null and config.removeElements == null) { self._remove_elements = .empty; @@ -361,7 +369,11 @@ fn setFromConfig(self: *Sanitizer, config: Config) !bool { self._remove_attributes = .empty; } if (self._allow_processing_instructions == null and self._remove_processing_instructions == null) { - self._remove_processing_instructions = .empty; + if (permissive_defaults) { + self._remove_processing_instructions = .empty; + } else { + self._allow_processing_instructions = .empty; + } } return all_new and self.isValid(); @@ -992,6 +1004,379 @@ fn hasDataAttribute(set: NameSet) bool { return false; } +// Like init: we need to tell the difference between not-set, null and undefined +// AND, this can actually be a Sanitizer config already. +pub const Options = struct { + sanitizer: ?js.Value = null, +}; + +const FromOptions = struct { + owned: bool, + sanitizer: *Sanitizer, + + fn release(self: FromOptions, page: *Page) void { + if (self.owned) { + // We have to free a Sanitizer we created, versus a Sanitizer that + // was passed to use from JS. + self.sanitizer.deinit(page); + } + } +}; + +fn fromOptions(options: ?Options, safe: bool, exec: *const Execution) !?FromOptions { + const spec = blk: { + const o = options orelse break :blk null; + const spec = o.sanitizer orelse break :blk null; + if (spec.isUndefined()) { + break :blk null; + } + if (spec.toZig(*Sanitizer)) |sanitizer| { + return .{ .sanitizer = sanitizer, .owned = false }; + } else |_| {} + break :blk spec; + }; + + if (spec == null and safe == false) { + return null; + } + // A missing spec takes the "default" preset + return .{ .sanitizer = try create(spec, safe == false, exec), .owned = true }; +} + +pub fn setAndFilterHTML(target: *Node, context: *Element, html: []const u8, options: ?Options, safe: bool, frame: *Frame) !void { + if (safe and std.mem.eql(u8, context.getLocalName(), "script")) { + if (context._namespace == .html or context._namespace == .svg) { + // hahaha, nice try! + return; + } + } + + const resolved = try fromOptions(options, safe, &frame.js.execution); + defer if (resolved) |r| r.release(frame.page); + + // Parsed into a detached fragment, so that nothing is connected (no fetch, + // no custom element reaction) until it's been sanitized. + const fragment = (try Node.DocumentFragment.init(target.getDocument(frame), frame)).asNode(); + if (html.len > 0) { + try Frame.parse.fragment(frame, fragment, html, .{ .context = context, .allow_declarative_shadow = true }); + } + if (resolved) |r| { + try r.sanitizer.sanitize(fragment, safe, frame); + } + try target.replaceAllWithFragment(fragment, frame); +} + +pub fn parseHTML(html: []const u8, options: ?Options, safe: bool, frame: *Frame) !*Node.Document { + const resolved = try fromOptions(options, safe, &frame.js.execution); + defer if (resolved) |r| r.release(frame.page); + + const document = (try Frame.parse.htmlDocument(frame, html, .{ .allow_declarative_shadow = true })).asDocument(); + document._url = "about:blank"; + if (resolved) |r| { + try r.sanitizer.sanitize(document.asNode(), safe, frame); + } + return document; +} + +fn sanitize(self: *const Sanitizer, root: *Node, safe: bool, frame: *Frame) !void { + const arena = frame.call_arena; + + // A template's contents and a shadow root are trees of their own + var trees: std.ArrayList(*Node) = .empty; + try trees.append(arena, root); + + var remove_attributes: std.ArrayList([]const u8) = .empty; + + // Parents that lost a child, whose text nodes might now be adjacent. + var touched: std.ArrayList(*Node) = .empty; + + while (trees.pop()) |tree| { + var tw = TreeWalker.FullExcludeSelf.init(tree, .{}); + while (tw.next()) |node| { + const parent = node._parent.?; + const element = node.is(Element) orelse { + if (self.keepNonElement(node) == false) { + tw.skipChildren(); + frame.removeNode(parent, node, .{ .reconnect_to = null }); + try touch(&touched, parent, arena); + } + continue; + }; + + const element_name: Name = .{ .name = .wrap(element.getLocalName()), .namespace = elementNamespace(element, frame) }; + switch (self.elementAction(element_name, safe)) { + .keep => {}, + .remove => { + tw.skipChildren(); + frame.removeNode(parent, node, .{ .reconnect_to = null }); + try touch(&touched, parent, arena); + continue; + }, + .replace => { + // Move the children up; the walker's next node is the + // first of them (or, if there are none, what follows). + // run_ready = false: nothing gets to act on them until + // they've been sanitized. + if (node.firstChild() != null) { + const previous_root = node.getRootNode(.{}); + while (node.firstChild()) |child| { + frame.removeNode(node, child, .{ .reconnect_to = parent }); + try frame.insertNodeRelative(parent, child, .{ .before = node }, .{ .previous_root = previous_root, .run_ready = false }); + } + } + frame.removeNode(parent, node, .{ .reconnect_to = null }); + try touch(&touched, parent, arena); + continue; + }, + } + + if (element.is(Element.Html.Template)) |template| { + try trees.append(arena, template.getContent().asNode()); + } + if (element.hostedShadowRoot(frame)) |shadow_root| { + try trees.append(arena, shadow_root.asNode()); + } + + // Collected first: removing one can run script (attributeChangedCallback) + remove_attributes.clearRetainingCapacity(); + for (element.attributeEntries()) |*entry| { + if (self.keepAttribute(element_name, entry.name(), entry.value(), safe) == false) { + try remove_attributes.append(arena, entry.name()); + } + } + for (remove_attributes.items) |name| { + element.removeAttributeSafe(.wrap(name), frame); + } + } + } + + var buffer: std.ArrayList(u8) = .empty; + for (touched.items) |parent| { + try mergeAdjacentText(parent, &buffer, frame); + } +} + +fn touch(touched: *std.ArrayList(*Node), parent: *Node, arena: Allocator) !void { + // Siblings are removed one after another, so this catches most repeats. + // Those it misses just get a second, no-op, merge. + if (touched.getLastOrNull() != parent) { + try touched.append(arena, parent); + } +} + +// if we removed a node between two text nodes, we need to merge the text ndoes + +fn mergeAdjacentText(parent: *Node, buffer: *std.ArrayList(u8), frame: *Frame) !void { + var child = parent.firstChild(); + while (child) |node| { + var next = node.nextSibling(); + const text = node.is(Node.CData.Text) orelse { + child = next; + continue; + }; + if (next == null or next.?.is(Node.CData.Text) == null) { + child = next; + continue; + } + + buffer.clearRetainingCapacity(); + try buffer.appendSlice(frame.call_arena, text.ownData()); + while (next) |sibling| { + const sibling_text = sibling.is(Node.CData.Text) orelse break; + try buffer.appendSlice(frame.call_arena, sibling_text.ownData()); + next = sibling.nextSibling(); + frame.removeNode(parent, sibling, .{ .reconnect_to = null }); + } + text.asCData()._data = try frame.dupeSSO(buffer.items); + child = next; + } +} + +fn keepNonElement(self: *const Sanitizer, node: *Node) bool { + const cdata = node.is(Node.CData) orelse return true; // doctype + return switch (cdata._type) { + .text, .cdata_section => true, + .comment => self._comments == true, + .processing_instruction => { + const target = cdata.subtype(Node.CData.ProcessingInstruction)._target; + if (self._allow_processing_instructions) |allowed| { + return allowed.contains(target); + } + return self._remove_processing_instructions.?.contains(target) == false; + }, + }; +} + +fn elementAction(self: *const Sanitizer, name: Name, safe: bool) enum { keep, remove, replace } { + if (safe) { + for (defaults.baseline_remove_elements) |unsafe| { + if (staticName(unsafe).eql(name)) { + return .remove; + } + } + } + if (self._replace_elements) |replace| { + if (replace.contains(name)) { + return .replace; + } + } + if (self._allow_elements) |allowed| { + return if (allowed.contains(name)) .keep else .remove; + } + return if (self._remove_elements.?.contains(name)) .remove else .keep; +} + +fn keepAttribute(self: *const Sanitizer, element: Name, qualified_name: []const u8, value: []const u8, safe: bool) bool { + const name = attributeName(element.namespace, qualified_name); + + const element_allowed = self._element_allow_attributes.getPtr(element); + if (self._element_remove_attributes.getPtr(element)) |removed| { + if (removed.contains(name)) { + return false; + } + } + + if (self._allow_attributes) |allowed| { + if (allowed.contains(name) == false and + (element_allowed == null or element_allowed.?.contains(name) == false) and + (self._data_attributes != true or name.isDataAttribute() == false)) + { + return false; + } + } else { + if (element_allowed) |set| { + if (set.contains(name) == false) { + return false; + } + } else if (self._remove_attributes.?.contains(name)) { + return false; + } + } + + if (safe and name.namespace == .none and isEventHandler(qualified_name)) { + return false; + } + + if (safe or self._javascript_urls != true) { + if (isNavigatingURLAttribute(element, name) and isJavascriptURL(value)) { + return false; + } + if (isAnimatingURLAttribute(element, name) and (std.mem.eql(u8, value, "href") or std.mem.eql(u8, value, "xlink:href"))) { + return false; + } + } + return true; +} + +fn elementNamespace(element: *Element, frame: *Frame) Namespace { + return switch (element._namespace) { + .html => .xhtml, + .svg => .svg, + .mathml => .mathml, + .xml => .xml, + .null => .none, + .unknown => .intern(element.getNamespaceUri(frame)), + }; +} + +// Attributes' namespaces aren't tracked, but the HTML parser only ever gives a +// namespace to these attributes of foreign (SVG/MathML) elements. +// https://html.spec.whatwg.org/#adjust-foreign-attributes +const foreign_attributes = std.StaticStringMap(defaults.Name).initComptime(.{ + .{ "xlink:actuate", defaults.Name{ .name = "actuate", .namespace = .xlink } }, + .{ "xlink:arcrole", defaults.Name{ .name = "arcrole", .namespace = .xlink } }, + .{ "xlink:href", defaults.Name{ .name = "href", .namespace = .xlink } }, + .{ "xlink:role", defaults.Name{ .name = "role", .namespace = .xlink } }, + .{ "xlink:show", defaults.Name{ .name = "show", .namespace = .xlink } }, + .{ "xlink:title", defaults.Name{ .name = "title", .namespace = .xlink } }, + .{ "xlink:type", defaults.Name{ .name = "type", .namespace = .xlink } }, + .{ "xml:lang", defaults.Name{ .name = "lang", .namespace = .xml } }, + .{ "xml:space", defaults.Name{ .name = "space", .namespace = .xml } }, + .{ "xmlns", defaults.Name{ .name = "xmlns", .namespace = .xmlns } }, + .{ "xmlns:xlink", defaults.Name{ .name = "xlink", .namespace = .xmlns } }, +}); + +fn attributeName(element_namespace: Namespace, qualified_name: []const u8) Name { + if (element_namespace == .svg or element_namespace == .mathml) { + if (foreign_attributes.get(qualified_name)) |name| { + return staticName(name); + } + } + return .{ .name = .wrap(qualified_name), .namespace = .none }; +} + +fn isEventHandler(name: []const u8) bool { + if (std.mem.startsWith(u8, name, "on") == false) { + return false; + } + return std.sort.binarySearch([]const u8, defaults.event_handler_attributes, name, struct { + fn order(key: []const u8, item: []const u8) std.math.Order { + return std.mem.order(u8, key, item); + } + }.order) != null; +} + +// https://html.spec.whatwg.org/#built-in-navigating-url-attributes-list +fn isNavigatingURLAttribute(element: Name, attribute: Name) bool { + const local = attribute.name.str(); + switch (element.namespace) { + .xhtml => { + if (attribute.namespace != .none) { + return false; + } + const tag = element.name.str(); + if (std.mem.eql(u8, tag, "a") or std.mem.eql(u8, tag, "area")) { + return std.mem.eql(u8, local, "href"); + } + if (std.mem.eql(u8, tag, "form")) { + return std.mem.eql(u8, local, "action"); + } + if (std.mem.eql(u8, tag, "button") or std.mem.eql(u8, tag, "input")) { + return std.mem.eql(u8, local, "formaction"); + } + return false; + }, + // any MathML element, not just + .svg, .mathml => { + if (element.namespace == .svg and std.mem.eql(u8, element.name.str(), "a") == false) { + return false; + } + return (attribute.namespace == .none or attribute.namespace == .xlink) and std.mem.eql(u8, local, "href"); + }, + else => return false, + } +} + +fn isAnimatingURLAttribute(element: Name, attribute: Name) bool { + if (element.namespace != .svg or attribute.namespace != .none) { + return false; + } + if (std.mem.eql(u8, attribute.name.str(), "attributeName") == false) { + return false; + } + const tag = element.name.str(); + return std.mem.eql(u8, tag, "animate") or std.mem.eql(u8, tag, "animateTransform") or std.mem.eql(u8, tag, "set"); +} + +fn isJavascriptURL(value: []const u8) bool { + const trimmed = std.mem.trimStart(u8, value, "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f "); + const scheme = "javascript:"; + var i: usize = 0; + for (trimmed) |c| { + if (c == '\t' or c == '\n' or c == '\r') { + continue; + } + if (std.ascii.toLower(c) != scheme[i]) { + return false; + } + i += 1; + if (i == scheme.len) { + return true; + } + } + return false; +} + pub const JsApi = struct { pub const bridge = js.Bridge(Sanitizer); diff --git a/src/browser/webapi/ShadowRoot.zig b/src/browser/webapi/ShadowRoot.zig index 99f6c1566..ac18e3935 100644 --- a/src/browser/webapi/ShadowRoot.zig +++ b/src/browser/webapi/ShadowRoot.zig @@ -24,6 +24,7 @@ const Frame = @import("../Frame.zig"); const Node = @import("Node.zig"); const Element = @import("Element.zig"); +const Sanitizer = @import("Sanitizer.zig"); const DocumentFragment = @import("DocumentFragment.zig"); const ShadowRoot = @This(); @@ -110,8 +111,12 @@ fn getSerializable(self: *const ShadowRoot) bool { return self._serializable; } -pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, frame: *Frame) !void { - return self.asDocumentFragment().setHTMLUnsafe(html, frame); +pub fn setHTML(self: *ShadowRoot, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void { + return Sanitizer.setAndFilterHTML(self.asNode(), self._host, html, options, true, frame); +} + +pub fn setHTMLUnsafe(self: *ShadowRoot, html: []const u8, options: ?Sanitizer.Options, frame: *Frame) !void { + return Sanitizer.setAndFilterHTML(self.asNode(), self._host, html, options, false, frame); } pub fn getHTML(self: *ShadowRoot, opts: dump.Opts.Shadow.Declarative, writer: *std.Io.Writer, frame: *Frame) !void { @@ -219,6 +224,7 @@ pub const JsApi = struct { return self.getElementById(try value.toZig([]const u8), frame); } pub const adoptedStyleSheets = bridge.accessor(ShadowRoot.getAdoptedStyleSheets, ShadowRoot.setAdoptedStyleSheets, .{}); + pub const setHTML = bridge.function(ShadowRoot.setHTML, .{ .ce_reactions = true }); pub const setHTMLUnsafe = bridge.function(ShadowRoot.setHTMLUnsafe, .{ .ce_reactions = true }); pub const getHTML = bridge.function(_getHTML, .{}); const GetHTMLOpts = struct {