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.
This commit is contained in:
Karl Seguin committed 2026-09-24 09:38:34 +08:00
1 parent 578acc9040
commit eba58d7d57
11 files changed
+724 -56

No files matched your search

+41
View File
@@ -16,12 +16,15 @@
// 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 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 = "<html></html>";
}
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.
+1 -1
View File
@@ -952,7 +952,7 @@ test "browser.markdown: declarative shadow DOM renders through piercing" {
const host = try doc.createElement("div", null, frame);
try host.setHTMLUnsafe(
\\<div><template shadowrootmode="open"><p>shadow content</p></template></div>
, frame);
, null, frame);
var aw: std.Io.Writer.Allocating = .init(testing.allocator);
defer aw.deinit();
+1 -1
View File
@@ -1468,7 +1468,7 @@ test "browser.screenshot: shadow dom and slots" {
const div = try doc.createElement("div", null, frame);
try div.setHTMLUnsafe(
\\<x-host><template shadowrootmode="open"><p>shadow <slot></slot></p></template>light</x-host>
, frame);
, null, frame);
var builder: Builder = .{ .arena = testing.arena_allocator, .frame = frame, .tree = .{ .frame = frame, .state = .{ .root = div.asNode() } } };
try builder.render(div.asNode());
+233
View File
@@ -368,3 +368,236 @@
testing.expectEqual(false, config.javascriptURLs);
}
</script>
<script id=setHTML>
{
const div = document.createElement('div');
div.setHTML('<p id=a onclick="x()" title=t data-x=1>hi<script>boom()<\/script><!-- c --></p><iframe></iframe>');
testing.expectEqual('<p title="t">hi</p>', div.innerHTML);
// an explicit "default" is the same thing
div.setHTML('<b onclick="x()">b</b>', {sanitizer: 'default'});
testing.expectEqual('<b>b</b>', div.innerHTML);
testing.expectError('TypeError', () => div.setHTML('x', {sanitizer: 'nope'}));
testing.expectError('TypeError', () => div.setHTML('x', {sanitizer: {elements: [], removeElements: []}}));
div.setHTML('');
testing.expectEqual('', div.innerHTML);
}
</script>
<script id=setHTMLConfig>
{
const div = document.createElement('div');
// A dictionary on a safe method defaults to not keeping comments or data-*.
div.setHTML('<p data-x=1 id=i>a<!--c--></p>', {sanitizer: {attributes: ['id']}});
testing.expectEqual('<p id="i">a</p>', div.innerHTML);
// ... but unsafe methods, like the constructor, default to keeping them.
div.setHTMLUnsafe('<p data-x=1 id=i>a<!--c--></p>', {sanitizer: {attributes: ['id']}});
testing.expectEqual('<p data-x="1" id="i">a<!--c--></p>', div.innerHTML);
div.setHTML('<div><p>a<b>b</b></p><i>i</i></div>', {sanitizer: {elements: ['div', 'p']}});
testing.expectEqual('<div><p>a</p></div>', div.innerHTML);
div.setHTML('<div><p>a<b>b<i>i</i></b>c</p></div>', {sanitizer: {replaceWithChildrenElements: ['b', 'p']}});
testing.expectEqual('<div>ab<i>i</i>c</div>', div.innerHTML);
div.setHTML('<div><span>s</span><em>e</em></div>', {sanitizer: {removeElements: ['span']}});
testing.expectEqual('<div><em>e</em></div>', div.innerHTML);
// per-element attribute lists
div.setHTML('<p id=1 title=2>a</p><em id=3 title=4>b</em>', {sanitizer: {
elements: [{name: 'p', attributes: ['id']}, {name: 'em', removeAttributes: ['title']}],
attributes: ['title'],
}});
testing.expectEqual('<p id="1" title="2">a</p><em>b</em>', div.innerHTML);
// an element's removeAttributes must be a subset of the global attributes
testing.expectError('TypeError', () => div.setHTML('x', {sanitizer: {
elements: [{name: 'em', removeAttributes: ['id']}],
attributes: ['title'],
}}));
// A safe method still removes unsafe markup the config allows.
div.setHTML('<script>x()<\/script><b onclick=x()>b</b>', {sanitizer: {elements: ['script', 'b'], attributes: ['onclick']}});
testing.expectEqual('<b>b</b>', div.innerHTML);
}
</script>
<script id=setHTMLUnsafe>
{
const div = document.createElement('div');
div.setHTMLUnsafe('<b onclick="x()">b</b><script>x()<\/script><!--c-->');
testing.expectEqual('<b onclick="x()">b</b><script>x()<\/script><!--c-->', div.innerHTML);
// The sanitizer's own configuration is used as-is.
const sanitizer = new Sanitizer({removeElements: ['i']});
const before = sanitizer.get();
div.setHTMLUnsafe('<b onclick="x()">b</b><i>i</i>', {sanitizer});
testing.expectEqual('<b onclick="x()">b</b>', div.innerHTML);
// A safe method must not remove unsafe from the caller's Sanitizer.
div.setHTML('<b onclick="x()">b</b><i>i</i>', {sanitizer});
testing.expectEqual('<b>b</b>', div.innerHTML);
testing.expectEqual(before, sanitizer.get());
// The default configuration on an unsafe method is still the default one
div.setHTMLUnsafe('<b onclick="x()">b</b><script><\/script>', {sanitizer: 'default'});
testing.expectEqual('<b>b</b>', div.innerHTML);
}
</script>
<script id=textMerge>
{
// Text left adjacent by a removal is merged, as it would be had the node
// never been parsed.
const div = document.createElement('div');
div.setHTML('a <!-- c --> b');
testing.expectEqual(1, div.childNodes.length);
testing.expectEqual('a b', div.firstChild.data);
div.setHTML('<p>x<script>s<\/script>y<!--c-->z<b>b</b></p>');
testing.expectEqual(2, div.firstChild.childNodes.length);
testing.expectEqual('xyz', div.firstChild.firstChild.data);
// replaced elements merge at both edges
div.setHTML('<p>x<span>y<i>i</i>z</span>w</p>', {sanitizer: {replaceWithChildrenElements: ['span']}});
const p = div.firstChild;
testing.expectEqual(3, p.childNodes.length);
testing.expectEqual('xy', p.childNodes[0].data);
testing.expectEqual('zw', p.childNodes[2].data);
div.setHTML('<p>x<b><i>y</i></b>z</p>', {sanitizer: {replaceWithChildrenElements: ['b', 'i']}});
testing.expectEqual(1, div.firstChild.childNodes.length);
testing.expectEqual('xyz', div.firstChild.firstChild.data);
// nothing removed: nothing touched
div.setHTMLUnsafe('<p>x<!--c-->y</p>');
testing.expectEqual(3, div.firstChild.childNodes.length);
// in the other trees too
const doc = Document.parseHTML('a<!--c-->b<template>c<!--c-->d</template>', {sanitizer: {elements: ['html', 'head', 'body', 'template']}});
testing.expectEqual(2, doc.body.childNodes.length);
testing.expectEqual('ab', doc.body.firstChild.data);
testing.expectEqual(1, doc.body.lastChild.content.childNodes.length);
testing.expectEqual('cd', doc.body.lastChild.content.firstChild.data);
}
</script>
<script id=scriptContext>
{
const script = document.createElement('script');
script.textContent = 'untouched';
script.setHTML('<b>b</b>');
testing.expectEqual('untouched', script.textContent);
script.setHTMLUnsafe('<b>b</b>');
testing.expectEqual('<b>b</b>', script.innerHTML);
}
</script>
<script id=javascriptURLs>
{
const div = document.createElement('div');
// safe: always removed, whatever the config says
div.setHTML('<a href="javascript:x()">a</a><a href="https://x/">b</a>', {sanitizer: {javascriptURLs: true}});
testing.expectEqual('<a>a</a><a href="https://x/">b</a>', div.innerHTML);
// unsafe: removed only when the config says so
div.setHTMLUnsafe('<a href="javascript:x()">a</a>', {sanitizer: {}});
testing.expectEqual('<a href="javascript:x()">a</a>', div.innerHTML);
const off = {sanitizer: {javascriptURLs: false}};
for (const url of ['javascript:x()', ' JaVaScRiPt:x()', '\x01java\nscript:x()', 'java\tscript:x()']) {
const a = document.createElement('a');
a.setAttribute('href', url);
div.setHTMLUnsafe(a.outerHTML, off);
testing.expectEqual(null, div.firstChild.getAttribute('href'), {script_id: 'javascriptURLs'});
}
div.setHTMLUnsafe('<a href="javascriptx:x()">a</a><a href="xjavascript:x()">b</a><p href="javascript:x()"></p>', off);
testing.expectEqual('<a href="javascriptx:x()">a</a><a href="xjavascript:x()">b</a><p href="javascript:x()"></p>', div.innerHTML);
div.setHTMLUnsafe('<area href="javascript:x()"><form action="javascript:x()"><button formaction="javascript:x()"></button></form>', off);
testing.expectEqual('<area><form><button></button></form>', div.innerHTML);
div.setHTMLUnsafe('<svg><a href="javascript:x()"></a><a xlink:href="javascript:x()"></a></svg>', off);
testing.expectEqual('<svg><a></a><a></a></svg>', div.innerHTML);
div.setHTMLUnsafe('<math><mi href="javascript:x()"></mi></math>', off);
testing.expectEqual('<math><mi></mi></math>', div.innerHTML);
div.setHTMLUnsafe('<svg><animate attributeName="href"></animate><set attributeName="xlink:href"></set><animate attributeName="x"></animate></svg>', off);
testing.expectEqual('<svg><animate></animate><set></set><animate attributeName="x"></animate></svg>', div.innerHTML);
}
</script>
<script id=nestedTrees>
{
const div = document.createElement('div');
div.setHTML('<template><b onclick=x()>b</b><script><\/script></template>', {sanitizer: {elements: ['template', 'b', 'script']}});
testing.expectEqual('<b>b</b>', div.firstChild.innerHTML);
const host = document.createElement('div');
host.setHTMLUnsafe('<div><template shadowrootmode=open><i onclick=x()>i</i><script><\/script></template></div>', {sanitizer: {removeElements: ['script'], removeAttributes: ['onclick']}});
testing.expectEqual('<i>i</i>', host.firstChild.shadowRoot.innerHTML);
// setHTML on a template sets its contents
const template = document.createElement('template');
template.setHTML('<b onclick=x()>b</b>');
testing.expectEqual(0, template.childNodes.length);
testing.expectEqual('<b>b</b>', template.content.firstChild.outerHTML);
}
</script>
<script id=shadowRoot>
{
const host = document.createElement('div');
const root = host.attachShadow({mode: 'open'});
root.setHTML('<b onclick=x()>b</b><script><\/script>');
testing.expectEqual('<b>b</b>', root.innerHTML);
root.setHTMLUnsafe('<b onclick=x()>b</b>');
testing.expectEqual('<b onclick="x()">b</b>', root.innerHTML);
root.setHTMLUnsafe('<b onclick=x()>b</b>', {sanitizer: {}});
testing.expectEqual('<b onclick="x()">b</b>', root.innerHTML);
}
</script>
<script id=mutationRecord>
{
const div = document.createElement('div');
div.innerHTML = '<p>old</p>';
const observer = new MutationObserver(() => {});
observer.observe(div, {childList: true});
div.setHTML('<b>a</b><i>b</i><script><\/script>');
const records = observer.takeRecords();
testing.expectEqual(1, records.length);
testing.expectEqual(1, records[0].removedNodes.length);
testing.expectEqual(2, records[0].addedNodes.length);
}
</script>
<script id=parseHTML>
{
const doc = Document.parseHTML('<title>t</title><p onclick=x() data-x=1>a<script>x()<\/script></p>');
testing.expectEqual(true, doc instanceof Document);
testing.expectEqual('about:blank', doc.URL);
testing.expectEqual('<p>a</p>', doc.body.innerHTML);
testing.expectEqual('t', doc.title);
const unsafe = Document.parseHTMLUnsafe('<p onclick=x()>a<script>x()<\/script></p>');
testing.expectEqual('<p onclick="x()">a<script>x()<\/script></p>', unsafe.body.innerHTML);
const filtered = Document.parseHTMLUnsafe('<p>a<i>b</i></p>', {sanitizer: {removeElements: ['i']}});
testing.expectEqual('<p>a</p>', filtered.body.innerHTML);
// html isn't allowed, so it all goes
testing.expectEqual(null, Document.parseHTML('<div>text', {sanitizer: {elements: ['body', 'div']}}).documentElement);
testing.expectError('TypeError', () => Document.parseHTML('x', {sanitizer: {replaceWithChildrenElements: ['html']}}));
}
</script>
+1 -37
View File
@@ -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 = "<html></html>";
}
// 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 => {
+11
View File
@@ -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, .{});
-6
View File
@@ -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();
+15 -3
View File
@@ -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 });
+22
View File
@@ -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,
+391 -6
View File
@@ -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 <a>
.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);
+8 -2
View File
@@ -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 {