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(
\\shadow
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 {