From 97887d42d6ad4ed9cc8a7c7c709deac124a90ff3 Mon Sep 17 00:00:00 2001 From: Kumar McMillan Date: Fri, 22 Aug 2025 10:34:14 +0100 Subject: [PATCH 1/2] First vibe coded allowlist filtering --- examples/custom-element/app/index.html | 111 +++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 9 deletions(-) diff --git a/examples/custom-element/app/index.html b/examples/custom-element/app/index.html index ff4025af..21bc91e2 100644 --- a/examples/custom-element/app/index.html +++ b/examples/custom-element/app/index.html @@ -80,20 +80,113 @@ From 642c7c66b44055a644b0e9d25854c36f61d37aba Mon Sep 17 00:00:00 2001 From: Kumar McMillan Date: Fri, 22 Aug 2025 11:13:18 +0100 Subject: [PATCH 2/2] Use a subclass instead --- examples/custom-element/app/index.html | 168 ++++++++++++------------- 1 file changed, 81 insertions(+), 87 deletions(-) diff --git a/examples/custom-element/app/index.html b/examples/custom-element/app/index.html index 21bc91e2..e8d37050 100644 --- a/examples/custom-element/app/index.html +++ b/examples/custom-element/app/index.html @@ -83,6 +83,8 @@ import { ROOT_ID, NODE_TYPE_ELEMENT, + NODE_TYPE_TEXT, + NODE_TYPE_COMMENT, MUTATION_TYPE_INSERT_CHILD, MUTATION_TYPE_UPDATE_TEXT, MUTATION_TYPE_UPDATE_PROPERTY, @@ -92,100 +94,92 @@ const root = document.querySelector('#root'); const iframe = document.querySelector('#remote-iframe'); - const receiver = new DOMRemoteReceiver(); - receiver.connect(root); - - // Host-level allowlist: only permit `ui-button` elements to be inserted. - // This wrapper filters incoming mutation records before forwarding them to - // the receiver. Unknown tags are ignored. - const allowedTags = new Set(['ui-button']); - const allowedIds = new Set([ROOT_ID]); - - function collectIds(node) { - // Collect ids for the node and any descendants - const stack = [node]; - while (stack.length > 0) { - const current = stack.pop(); - if (current && typeof current.id === 'string') { - allowedIds.add(current.id); - } - if (current && current.children) { - for (const child of current.children) stack.push(child); - } - } - } - - function sanitizeNode(node) { - // Keep text and comment nodes as-is - if (node.type !== NODE_TYPE_ELEMENT) return node; - - // Drop disallowed element nodes entirely - if (!allowedTags.has(node.element)) return null; - - // Rebuild the element with only sanitized children - const sanitizedChildren = []; - if (Array.isArray(node.children)) { - for (const child of node.children) { - const sanitized = sanitizeNode(child); - if (sanitized) sanitizedChildren.push(sanitized); - } + class ProtectedRemoteDOMReceiver extends DOMRemoteReceiver { + #allowedElements; + #wrapped = false; + constructor(options = {}) { + const {allowedElements = [], ...rest} = options; + super(rest); + this.#allowedElements = new Set(allowedElements); } - return { - ...node, - children: sanitizedChildren, - }; - } - - const filteredConnection = { - call: (...args) => receiver.connection.call(...args), - mutate(records) { - const filtered = []; - - for (const record of records) { - const [type, ...rest] = record; - - if (type === MUTATION_TYPE_INSERT_CHILD) { - const [parentId, child, index] = rest; + connect(element) { + super.connect(element); + if (this.#wrapped) return; + this.#wrapped = true; - // Only insert under an allowed parent, and only if the child node is allowed - if (!allowedIds.has(parentId)) continue; - const sanitized = sanitizeNode(child); - if (!sanitized) continue; + const allowedTags = this.#allowedElements; + const allowedIds = new Set([ROOT_ID]); - collectIds(sanitized); - filtered.push([type, parentId, sanitized, index]); - continue; - } - - if (type === MUTATION_TYPE_UPDATE_TEXT) { - const [id] = rest; - if (!allowedIds.has(id)) continue; - filtered.push(record); - continue; - } - - if (type === MUTATION_TYPE_UPDATE_PROPERTY) { - const [id] = rest; - if (!allowedIds.has(id)) continue; - filtered.push(record); - continue; + function collectIds(node) { + const stack = [node]; + while (stack.length > 0) { + const current = stack.pop(); + if (current && typeof current.id === 'string') { + allowedIds.add(current.id); + } + if (current && current.children) { + for (const child of current.children) stack.push(child); + } } + } - if (type === MUTATION_TYPE_REMOVE_CHILD) { - const [parentId] = rest; - if (!allowedIds.has(parentId)) continue; - filtered.push(record); - continue; - } + const original = this.connection; + const filtered = { + call: (...args) => original.call(...args), + mutate: (records) => { + const filteredRecords = []; + for (const record of records) { + const [type, ...rest] = record; + if (type === MUTATION_TYPE_INSERT_CHILD) { + const [parentId, child, index] = rest; + if (!allowedIds.has(parentId)) continue; + if (child.type === NODE_TYPE_ELEMENT) { + if (!allowedTags.has(child.element)) continue; + collectIds(child); + } else if ( + child.type === NODE_TYPE_TEXT || + child.type === NODE_TYPE_COMMENT + ) { + if (child && typeof child.id === 'string') { + allowedIds.add(child.id); + } + } + filteredRecords.push([type, parentId, child, index]); + continue; + } + if (type === MUTATION_TYPE_UPDATE_TEXT) { + const [id] = rest; + if (!allowedIds.has(id)) continue; + filteredRecords.push(record); + continue; + } + if (type === MUTATION_TYPE_UPDATE_PROPERTY) { + const [id] = rest; + if (!allowedIds.has(id)) continue; + filteredRecords.push(record); + continue; + } + if (type === MUTATION_TYPE_REMOVE_CHILD) { + const [parentId] = rest; + if (!allowedIds.has(parentId)) continue; + filteredRecords.push(record); + continue; + } + filteredRecords.push(record); + } + if (filteredRecords.length > 0) original.mutate(filteredRecords); + }, + }; - // Any other record types (future-proof): pass through - filtered.push(record); - } + this.connection = filtered; + } + } - if (filtered.length > 0) receiver.connection.mutate(filtered); - }, - }; + const receiver = new ProtectedRemoteDOMReceiver({ + allowedElements: ['ui-button'], + }); + receiver.connect(root); // We use the `@quilted/threads` library to create a “thread” for our iframe, // which lets us communicate over `postMessage` without having to worry about @@ -197,7 +191,7 @@ // the `receiver.connection` object. This object, called a `RemoteConnection`, // allows the remote environment to synchronize its tree of UI elements into // the `root` element we connected our `receiver` to above. - thread.imports.render(filteredConnection); + thread.imports.render(receiver.connection);