From 512ce0482d98f1a0f2b52ab52f1da10fc4f00b8d Mon Sep 17 00:00:00 2001 From: JC Brand Date: Wed, 11 Dec 2024 09:22:19 +0200 Subject: [PATCH 1/7] Let the Stanza class extend the Builder class. This enables better compatibility between the two and lets us use Builder methods on Stanza classes. --- src/builder.js | 42 ++++++++++++++++++++++++++++++++++++++---- src/stanza.js | 40 +++++++++++++++++++++++----------------- src/types/builder.d.ts | 14 ++++++++------ src/types/stanza.d.ts | 16 +++------------- 4 files changed, 72 insertions(+), 40 deletions(-) diff --git a/src/builder.js b/src/builder.js index dc238b6..5c613a2 100644 --- a/src/builder.js +++ b/src/builder.js @@ -73,6 +73,15 @@ class Builder { * @property {string} [StanzaAttrs.xmlns] */ + /** @type {Element} */ + #nodeTree; + /** @type {Element} */ + #node; + /** @type {string} */ + #name; + /** @type {StanzaAttrs} */ + #attrs; + /** * The attributes should be passed in object notation. * @param {string} name - The name of the root element. @@ -89,10 +98,35 @@ class Builder { attrs = { xmlns: NS.CLIENT }; } } - // Holds the tree being built. - this.nodeTree = xmlElement(name, attrs); - // Points to the current operation node. - this.node = this.nodeTree; + + this.#name = name; + this.#attrs = attrs; + } + + buildTree() { + return xmlElement(this.#name, this.#attrs); + } + + /** @return {Element} */ + get nodeTree() { + if (!this.#nodeTree) { + // Holds the tree being built. + this.#nodeTree = this.buildTree(); + } + return this.#nodeTree; + } + + /** @return {Element} */ + get node() { + if (!this.#node) { + this.#node = this.tree(); + } + return this.#node; + } + + /** @param {Element} el */ + set node(el) { + this.#node = el; } /** diff --git a/src/stanza.js b/src/stanza.js index cd8606d..127a74f 100644 --- a/src/stanza.js +++ b/src/stanza.js @@ -1,3 +1,4 @@ +import Builder from './builder.js'; import log from './log.js'; import { getFirstElementChild, getParserError, xmlHtmlNode } from './utils.js'; @@ -32,36 +33,41 @@ export function toStanzaElement(string, throwErrorIfInvalidNS) { /** * A Stanza represents a XML element used in XMPP (commonly referred to as stanzas). */ -export class Stanza { +export class Stanza extends Builder { + + /** @type {string} */ + #string; + /** @type {Array} */ + #strings; + /** @type {Array} */ + #values; + /** * @param {string[]} strings * @param {any[]} values */ constructor(strings, values) { - this.strings = strings; - this.values = values; + super('stanza'); + this.#strings = strings; + this.#values = values; + } + + buildTree() { + return toStanzaElement(this.toString(), true); } /** * @return {string} */ toString() { - this.string = - this.string || - this.strings.reduce((acc, str) => { - const idx = this.strings.indexOf(str); - const value = this.values.length > idx ? this.values[idx] : ''; + this.#string = + this.#string || + this.#strings.reduce((acc, str) => { + const idx = this.#strings.indexOf(str); + const value = this.#values.length > idx ? this.#values[idx] : ''; return acc + str + (Array.isArray(value) ? value.join('') : value.toString()); }, ''); - return this.string; - } - - /** - * @return {Element} - */ - tree() { - this.node = this.node ?? toStanzaElement(this.toString(), true); - return this.node; + return this.#string; } } diff --git a/src/types/builder.d.ts b/src/types/builder.d.ts index ebe88ac..5b44e9b 100644 --- a/src/types/builder.d.ts +++ b/src/types/builder.d.ts @@ -68,10 +68,6 @@ declare class Builder { * @return {string} - The serialized element tree as a String. */ static serialize(elem: Element | Builder): string; - /** - * @typedef {Object.} StanzaAttrs - * @property {string} [StanzaAttrs.xmlns] - */ /** * The attributes should be passed in object notation. * @param {string} name - The name of the root element. @@ -82,8 +78,13 @@ declare class Builder { constructor(name: string, attrs?: { [x: string]: string | number; }); - nodeTree: Element; - node: Element; + buildTree(): Element; + /** @return {Element} */ + get nodeTree(): Element; + /** @param {Element} el */ + set node(el: Element); + /** @return {Element} */ + get node(): Element; /** * Return the DOM tree. * @@ -184,5 +185,6 @@ declare class Builder { * @return {Builder} The Strophe.Builder object. */ h(html: string): Builder; + #private; } //# sourceMappingURL=builder.d.ts.map \ No newline at end of file diff --git a/src/types/stanza.d.ts b/src/types/stanza.d.ts index 3de79de..826bc61 100644 --- a/src/types/stanza.d.ts +++ b/src/types/stanza.d.ts @@ -31,23 +31,13 @@ export function stx(strings: string[], ...values: any[]): Stanza; /** * A Stanza represents a XML element used in XMPP (commonly referred to as stanzas). */ -export class Stanza { +export class Stanza extends Builder { /** * @param {string[]} strings * @param {any[]} values */ constructor(strings: string[], values: any[]); - strings: string[]; - values: any[]; - /** - * @return {string} - */ - toString(): string; - string: any; - /** - * @return {Element} - */ - tree(): Element; - node: any; + #private; } +import Builder from './builder.js'; //# sourceMappingURL=stanza.d.ts.map \ No newline at end of file From 553bbefb8d4b826fcf64675ec48cc28e4e43b11c Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sat, 14 Dec 2024 16:32:23 +0200 Subject: [PATCH 2/7] Escape values passed to the stx tagged template literal --- CHANGELOG.md | 4 ++-- src/stanza.js | 16 ++++++++++---- tests/stx.js | 58 ++++++++++++++++++++++++++++++++------------------- 3 files changed, 51 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d595e8..6c4ae7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Version 3.0.2 - (Unreleased) +- **Security Fix**: Escape values passed to the stx tagged template literal - Replace [xmldom](https://github.com/xmldom/xmldom) with [jsdom](https://github.com/jsdom/jsdom). - Avoid inserting commas when nesting lists of `stx` templates. - Remove deprecated `abab` package @@ -300,8 +301,7 @@ import { Strophe, $build, stx } from strophe.js; ## Version 1.0.2 - 2011-06-19 -* Fix security bug where DIGEST-MD5 client nonce was not properly - randomized. +* Fix security bug where DIGEST-MD5 client nonce was not properly randomized. * Fix double escaping in copyElement. * Fix IE errors related to importNode. * Add ability to pass text into Builder.c(). diff --git a/src/stanza.js b/src/stanza.js index 127a74f..d6275a8 100644 --- a/src/stanza.js +++ b/src/stanza.js @@ -1,6 +1,6 @@ import Builder from './builder.js'; import log from './log.js'; -import { getFirstElementChild, getParserError, xmlHtmlNode } from './utils.js'; +import { getFirstElementChild, getParserError, xmlHtmlNode, xmlescape } from './utils.js'; /** * @param {string} string @@ -34,7 +34,6 @@ export function toStanzaElement(string, throwErrorIfInvalidNS) { * A Stanza represents a XML element used in XMPP (commonly referred to as stanzas). */ export class Stanza extends Builder { - /** @type {string} */ #string; /** @type {Array} */ @@ -65,8 +64,17 @@ export class Stanza extends Builder { this.#strings.reduce((acc, str) => { const idx = this.#strings.indexOf(str); const value = this.#values.length > idx ? this.#values[idx] : ''; - return acc + str + (Array.isArray(value) ? value.join('') : value.toString()); - }, ''); + return ( + acc + + str + + (Array.isArray(value) + ? value.map((v) => (v instanceof Stanza ? v : xmlescape(v.toString()))).join('') + : value instanceof Stanza + ? value + : xmlescape(value.toString())) + ); + }, '').trim(); + return this.#string; } } diff --git a/tests/stx.js b/tests/stx.js index 847c338..791be6f 100644 --- a/tests/stx.js +++ b/tests/stx.js @@ -29,6 +29,7 @@ test('can be used to create Stanza objects that are equivalent to Builder object `; + // prettier-ignore let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) .c("items", { node: "urn:xmpp:bookmarks:1" }) @@ -62,15 +63,14 @@ test('can be used to create Stanza objects that are equivalent to Builder object stamp="2002-10-13T23:58:37Z"/> `; - builderStanza = - $msg({ from: 'coven@chat.shakespeare.lit/firstwitch', - id: '162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2', - to: 'hecate@shakespeare.lit/broom', - type: 'groupchat' }) - .c('body').t('Thrice the brinded cat hath mew\'d.').up() - .c('delay', { xmlns: 'urn:xmpp:delay', - from: 'coven@chat.shakespeare.lit', - stamp: '2002-10-13T23:58:37Z' }); + // prettier-ignore + builderStanza = $msg({ + from: 'coven@chat.shakespeare.lit/firstwitch', + id: '162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2', + to: 'hecate@shakespeare.lit/broom', + type: 'groupchat', + }).c('body').t("Thrice the brinded cat hath mew'd.").up() + .c('delay', { xmlns: 'urn:xmpp:delay', from: 'coven@chat.shakespeare.lit', stamp: '2002-10-13T23:58:37Z' }); assert.equal(isEqualNode(templateStanza, builderStanza), true); @@ -84,19 +84,18 @@ test('can be used to create Stanza objects that are equivalent to Builder object `; - builderStanza = - $pres({ from: 'hag66@shakespeare.lit/pda', - id: 'n13mt3l', - to: 'coven@chat.shakespeare.lit/thirdwitch' }) - .c('x', { xmlns: 'http://jabber.org/protocol/muc' }) - .c('history', { maxchars: '65000' }); + // prettier-ignore + builderStanza = $pres({ + from: 'hag66@shakespeare.lit/pda', + id: 'n13mt3l', + to: 'coven@chat.shakespeare.lit/thirdwitch', + }).c('x', { xmlns: 'http://jabber.org/protocol/muc' }) + .c('history', { maxchars: '65000' }); assert.equal(isEqualNode(templateStanza, builderStanza), true); }); - test('can be nested recursively', (assert) => { - let templateStanza = stx` { `; + // prettier-ignore let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) .c("items", { node: "urn:xmpp:bookmarks:1" }) @@ -149,13 +149,29 @@ test('can be nested recursively', (assert) => { assert.equal(isEqualNode(templateStanza, builderStanza), true); }); +test('escape the values passed in to them', (assert) => { + const status = ''; + const templateStanza = stx` + + ${status} + `; + + assert.equal( + templateStanza.tree().querySelector('status').innerHTML, + '<script>alert("p0wned")</script>' + ); +}); + const EMPTY_TEXT_REGEX = /\s*\n\s*/; const serializer = new XMLSerializer(); /** * @param {Element|Builder|Stanza} el */ -function stripEmptyTextNodes (el) { +function stripEmptyTextNodes(el) { if (el instanceof Strophe.Builder || el instanceof Strophe.Stanza) { el = el.tree(); } @@ -168,8 +184,8 @@ function stripEmptyTextNodes (el) { } return NodeFilter.FILTER_ACCEPT; }); - while (n = walker.nextNode()) text_nodes.push(n); - text_nodes.forEach((n) => EMPTY_TEXT_REGEX.test(/** @type {Text} */(n).data) && n.parentElement.removeChild(n)) + while ((n = walker.nextNode())) text_nodes.push(n); + text_nodes.forEach((n) => EMPTY_TEXT_REGEX.test(/** @type {Text} */ (n).data) && n.parentElement.removeChild(n)); return el; } @@ -180,7 +196,7 @@ function stripEmptyTextNodes (el) { * @param {Element} expected * @returns {Boolean} */ -function isEqualNode (actual, expected) { +function isEqualNode(actual, expected) { actual = stripEmptyTextNodes(actual); expected = stripEmptyTextNodes(expected); From 612949746cbb5c504056be21fa07833e26bac618 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 15 Dec 2024 08:47:00 +0200 Subject: [PATCH 3/7] Add the ability to nest Builder objects in stx tagged template literals --- CHANGELOG.md | 5 +++-- src/stanza.js | 37 +++++++++++++++++++++++-------------- tests/stx.js | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c4ae7a..17b745a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,10 @@ ## Version 3.0.2 - (Unreleased) -- **Security Fix**: Escape values passed to the stx tagged template literal +- **Security Fix**: Escape values passed to the `stx` tagged template literal +- Allow `Stanza` and `Builder` objects to be passed as values the `stx`. +- Avoid inserting commas when nesting array values in `stx` templates. - Replace [xmldom](https://github.com/xmldom/xmldom) with [jsdom](https://github.com/jsdom/jsdom). -- Avoid inserting commas when nesting lists of `stx` templates. - Remove deprecated `abab` package - Make sure `ConnectionOptions` type is exportable - fix: invert default and types exports diff --git a/src/stanza.js b/src/stanza.js index d6275a8..d0da1ae 100644 --- a/src/stanza.js +++ b/src/stanza.js @@ -38,7 +38,10 @@ export class Stanza extends Builder { #string; /** @type {Array} */ #strings; - /** @type {Array} */ + /** + * @typedef {Array} StanzaValue + * @type {StanzaValue|Array} + */ #values; /** @@ -61,19 +64,25 @@ export class Stanza extends Builder { toString() { this.#string = this.#string || - this.#strings.reduce((acc, str) => { - const idx = this.#strings.indexOf(str); - const value = this.#values.length > idx ? this.#values[idx] : ''; - return ( - acc + - str + - (Array.isArray(value) - ? value.map((v) => (v instanceof Stanza ? v : xmlescape(v.toString()))).join('') - : value instanceof Stanza - ? value - : xmlescape(value.toString())) - ); - }, '').trim(); + this.#strings + .reduce((acc, str) => { + const idx = this.#strings.indexOf(str); + const value = this.#values.length > idx ? this.#values[idx] : ''; + return ( + acc + + str + + (Array.isArray(value) + ? value + .map((v) => + v instanceof Stanza || v instanceof Builder ? v : xmlescape(v.toString()) + ) + .join('') + : value instanceof Stanza || value instanceof Builder + ? value + : xmlescape(value.toString())) + ); + }, '') + .trim(); return this.#string; } diff --git a/tests/stx.js b/tests/stx.js index 791be6f..91e0586 100644 --- a/tests/stx.js +++ b/tests/stx.js @@ -149,6 +149,52 @@ test('can be nested recursively', (assert) => { assert.equal(isEqualNode(templateStanza, builderStanza), true); }); +test('can have nested Builder objects', (assert) => { + // prettier-ignore + let templateStanza = stx` + + + + ${[ + new Strophe.Builder('item', { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC"), + new Strophe.Builder('item', { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }), + ]} + + + `; + + // prettier-ignore + let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) + .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .c("items", { node: "urn:xmpp:bookmarks:1" }) + .c("item", { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC").up() + .up() + .up() + .c("item", { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) + .up() + .up() + .up() + .up() + .up(); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); +}); + test('escape the values passed in to them', (assert) => { const status = ''; const templateStanza = stx` From 3c8862861e06fea833e7e973d2f043385f13a5de Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 15 Dec 2024 09:02:49 +0200 Subject: [PATCH 4/7] Refactor Stanza class. Make the `toStanzaElement` a static function `Stanza.toElement` --- src/index.js | 9 +++---- src/stanza.js | 57 ++++++++++++++++++++++--------------------- src/types/index.d.ts | 4 +-- src/types/stanza.d.ts | 12 ++++----- 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/src/index.js b/src/index.js index 391aa9f..2a5704c 100644 --- a/src/index.js +++ b/src/index.js @@ -22,7 +22,7 @@ import Websocket from './websocket.js'; import WorkerWebsocket from './worker-websocket.js'; import log from './log.js'; import { ElementType, ErrorCondition, LOG_LEVELS, NS, Status, XHTML } from './constants.js'; -import { stx, toStanzaElement, Stanza } from './stanza.js'; +import { stx, Stanza } from './stanza.js'; /** * A container for all Strophe library functions. @@ -185,10 +185,9 @@ globalThis.$iq = $iq; globalThis.$msg = $msg; globalThis.$pres = $pres; globalThis.Strophe = Strophe; -globalThis.toStanzaElement = toStanzaElement; globalThis.stx = stx; -globalThis.toStanza = toStanzaElement; // Deprecated +const toStanza = Stanza.toElement; +globalThis.toStanza = Stanza.toElement; // Deprecated -export { Builder, $build, $iq, $msg, $pres, Strophe, Stanza, stx, toStanzaElement, Request }; -export { toStanzaElement as toStanza } +export { Builder, $build, $iq, $msg, $pres, Strophe, Stanza, stx, toStanza, Request }; diff --git a/src/stanza.js b/src/stanza.js index d0da1ae..69b81eb 100644 --- a/src/stanza.js +++ b/src/stanza.js @@ -2,33 +2,6 @@ import Builder from './builder.js'; import log from './log.js'; import { getFirstElementChild, getParserError, xmlHtmlNode, xmlescape } from './utils.js'; -/** - * @param {string} string - * @param {boolean} [throwErrorIfInvalidNS] - * @returns {Element} - */ -export function toStanzaElement(string, throwErrorIfInvalidNS) { - const doc = xmlHtmlNode(string); - const parserError = getParserError(doc); - if (parserError) { - throw new Error(`Parser Error: ${parserError}`); - } - - const node = getFirstElementChild(doc); - if ( - ['message', 'iq', 'presence'].includes(node.nodeName.toLowerCase()) && - node.namespaceURI !== 'jabber:client' && - node.namespaceURI !== 'jabber:server' - ) { - const err_msg = `Invalid namespaceURI ${node.namespaceURI}`; - if (throwErrorIfInvalidNS) { - throw new Error(err_msg); - } else { - log.error(err_msg); - } - } - return node; -} /** * A Stanza represents a XML element used in XMPP (commonly referred to as stanzas). @@ -54,8 +27,36 @@ export class Stanza extends Builder { this.#values = values; } + /** + * @param {string} string + * @param {boolean} [throwErrorIfInvalidNS] + * @returns {Element} + */ + static toElement(string, throwErrorIfInvalidNS) { + const doc = xmlHtmlNode(string); + const parserError = getParserError(doc); + if (parserError) { + throw new Error(`Parser Error: ${parserError}`); + } + + const node = getFirstElementChild(doc); + if ( + ['message', 'iq', 'presence'].includes(node.nodeName.toLowerCase()) && + node.namespaceURI !== 'jabber:client' && + node.namespaceURI !== 'jabber:server' + ) { + const err_msg = `Invalid namespaceURI ${node.namespaceURI}`; + if (throwErrorIfInvalidNS) { + throw new Error(err_msg); + } else { + log.error(err_msg); + } + } + return node; + } + buildTree() { - return toStanzaElement(this.toString(), true); + return Stanza.toElement(this.toString(), true); } /** diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 1e0af19..5c45df3 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -203,7 +203,7 @@ export const Strophe: { }; import { Stanza } from './stanza.js'; import { stx } from './stanza.js'; -import { toStanzaElement } from './stanza.js'; +export const toStanza: typeof Stanza.toElement; import Request from './request.js'; import * as shims from './shims.js'; import Bosh from './bosh.js'; @@ -224,5 +224,5 @@ import SASLMechanism from './sasl.js'; import { Status } from './constants.js'; import TimedHandler from './timed-handler.js'; import * as utils from './utils.js'; -export { Builder, $build, $iq, $msg, $pres, Stanza, stx, toStanzaElement, Request, toStanzaElement as toStanza }; +export { Builder, $build, $iq, $msg, $pres, Stanza, stx, Request }; //# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/src/types/stanza.d.ts b/src/types/stanza.d.ts index 826bc61..09f6696 100644 --- a/src/types/stanza.d.ts +++ b/src/types/stanza.d.ts @@ -1,9 +1,3 @@ -/** - * @param {string} string - * @param {boolean} [throwErrorIfInvalidNS] - * @returns {Element} - */ -export function toStanzaElement(string: string, throwErrorIfInvalidNS?: boolean): Element; /** * Tagged template literal function which generates {@link Stanza} objects * @@ -32,6 +26,12 @@ export function stx(strings: string[], ...values: any[]): Stanza; * A Stanza represents a XML element used in XMPP (commonly referred to as stanzas). */ export class Stanza extends Builder { + /** + * @param {string} string + * @param {boolean} [throwErrorIfInvalidNS] + * @returns {Element} + */ + static toElement(string: string, throwErrorIfInvalidNS?: boolean): Element; /** * @param {string[]} strings * @param {any[]} values From 1a19c5597bd6b4e479b1079b974e1526eb65355d Mon Sep 17 00:00:00 2001 From: JC Brand Date: Sun, 15 Dec 2024 18:01:38 +0200 Subject: [PATCH 5/7] Add a `Stanza.unsafeXML` directive --- src/builder.js | 18 ++++++++++++++++-- src/stanza.js | 24 +++++++++++++++++++++++- src/types/builder.d.ts | 7 +++++++ src/types/index.d.ts | 1 + src/types/stanza.d.ts | 20 ++++++++++++++++++++ src/types/utils.d.ts | 7 +++++++ src/utils.js | 29 +++++++++++++++++++++++++++++ 7 files changed, 103 insertions(+), 3 deletions(-) diff --git a/src/builder.js b/src/builder.js index 5c613a2..255d7ee 100644 --- a/src/builder.js +++ b/src/builder.js @@ -1,5 +1,5 @@ import { ElementType, NS } from './constants.js'; -import { copyElement, createHtml, xmlElement, xmlGenerator, xmlTextNode, xmlescape } from './utils.js'; +import { copyElement, createHtml, toElement, xmlElement, xmlGenerator, xmlTextNode, xmlescape } from './utils.js'; /** * Create a {@link Strophe.Builder} @@ -103,6 +103,19 @@ class Builder { this.#attrs = attrs; } + /** + * Creates a new Builder object from an XML string. + * @param {string} str + * @returns {Builder} + * @example const stanza = Builder.fromString(''); + */ + static fromString(str) { + const el = toElement(str); + const b = new Builder(''); + b.#nodeTree = el; + return b; + } + buildTree() { return xmlElement(this.#name, this.#attrs); } @@ -288,7 +301,8 @@ class Builder { const xmlGen = xmlGenerator(); try { impNode = xmlGen.importNode !== undefined; - } catch (e) { // eslint-disable-line no-unused-vars + // eslint-disable-next-line no-unused-vars + } catch (e) { impNode = false; } diff --git a/src/stanza.js b/src/stanza.js index 69b81eb..3944fa6 100644 --- a/src/stanza.js +++ b/src/stanza.js @@ -2,7 +2,6 @@ import Builder from './builder.js'; import log from './log.js'; import { getFirstElementChild, getParserError, xmlHtmlNode, xmlescape } from './utils.js'; - /** * A Stanza represents a XML element used in XMPP (commonly referred to as stanzas). */ @@ -28,6 +27,29 @@ export class Stanza extends Builder { } /** + * A directive which can be used to pass a string of XML as a value to the + * stx tagged template literal. + * + * It's considered "unsafe" because it can pose a security risk if used with + * untrusted input. + * + * @param {string} string + * @returns {Builder} + * @example + * const status = 'I am busy!'; + * const pres = stx` + * + * dnd + * ${unsafeXML(status)} + * `; + * connection.send(pres); + */ + static unsafeXML(string) { + return Builder.fromString(string); + } + + /** + * Turns the passed-in string into an XML Element. * @param {string} string * @param {boolean} [throwErrorIfInvalidNS] * @returns {Element} diff --git a/src/types/builder.d.ts b/src/types/builder.d.ts index 5b44e9b..26ce78a 100644 --- a/src/types/builder.d.ts +++ b/src/types/builder.d.ts @@ -62,6 +62,13 @@ export default Builder; * // */ declare class Builder { + /** + * Creates a new Builder object from an XML string. + * @param {string} str + * @returns {Builder} + * @example const stanza = Builder.fromString(''); + */ + static fromString(str: string): Builder; /** * Render a DOM element and all descendants to a String. * @param {Element|Builder} elem - A DOM element. diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 5c45df3..655def8 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -145,6 +145,7 @@ export const Strophe: { warn(msg: string): void; error(msg: string): void; fatal(msg: string): void; + toElement(string: string, throwErrorIfInvalidNS?: boolean): Element; handleError(e: Error): void; utf16to8(str: string): string; xorArrayBuffers(x: ArrayBufferLike, y: ArrayBufferLike): ArrayBuffer; diff --git a/src/types/stanza.d.ts b/src/types/stanza.d.ts index 09f6696..6f19ac0 100644 --- a/src/types/stanza.d.ts +++ b/src/types/stanza.d.ts @@ -27,6 +27,26 @@ export function stx(strings: string[], ...values: any[]): Stanza; */ export class Stanza extends Builder { /** + * A directive which can be used to pass a string of XML as a value to the + * stx tagged template literal. + * + * It's considered "unsafe" because it can pose a security risk if used with + * untrusted input. + * + * @param {string} string + * @returns {Builder} + * @example + * const status = 'I am busy!'; + * const pres = stx` + * + * dnd + * ${unsafeXML(status) + * `; + * connection.send(pres); + */ + static unsafeXML(string: string): Builder; + /** + * Turns the passed-in string into an XML Element. * @param {string} string * @param {boolean} [throwErrorIfInvalidNS] * @returns {Element} diff --git a/src/types/utils.d.ts b/src/types/utils.d.ts index 0337b86..4d4f2a4 100644 --- a/src/types/utils.d.ts +++ b/src/types/utils.d.ts @@ -1,3 +1,10 @@ +/** + * Takes a string and turns it into an XML Element. + * @param {string} string + * @param {boolean} [throwErrorIfInvalidNS] + * @returns {Element} + */ +export function toElement(string: string, throwErrorIfInvalidNS?: boolean): Element; /** * Properly logs an error to the console * @param {Error} e diff --git a/src/utils.js b/src/utils.js index 72dbaa5..9a2165a 100644 --- a/src/utils.js +++ b/src/utils.js @@ -3,6 +3,35 @@ import log from './log.js'; import * as shims from './shims.js'; import { ElementType, PARSE_ERROR_NS, XHTML } from './constants.js'; +/** + * Takes a string and turns it into an XML Element. + * @param {string} string + * @param {boolean} [throwErrorIfInvalidNS] + * @returns {Element} + */ +export function toElement(string, throwErrorIfInvalidNS) { + const doc = xmlHtmlNode(string); + const parserError = getParserError(doc); + if (parserError) { + throw new Error(`Parser Error: ${parserError}`); + } + + const node = getFirstElementChild(doc); + if ( + ['message', 'iq', 'presence'].includes(node.nodeName.toLowerCase()) && + node.namespaceURI !== 'jabber:client' && + node.namespaceURI !== 'jabber:server' + ) { + const err_msg = `Invalid namespaceURI ${node.namespaceURI}`; + if (throwErrorIfInvalidNS) { + throw new Error(err_msg); + } else { + log.error(err_msg); + } + } + return node; +} + /** * Properly logs an error to the console * @param {Error} e From 2733afd681facfc615c52660c3d9a8bb1f7cf98c Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 16 Dec 2024 08:44:17 +0200 Subject: [PATCH 6/7] Refactor tests so that the stx tests also run in NodeJS It would be nice to have tests split up into modules, but this causes all kinds of headaches with running the tests in both the browser and NodeJS. So we're keeping them in one file for now. --- karma.conf.js | 1 - src/builder.js | 2 +- src/shims.js | 32 +++- src/types/builder.d.ts | 2 +- src/types/shims.d.ts | 4 + src/types/stanza.d.ts | 2 +- tests/stx.js | 276 ------------------------------- tests/tests.js | 363 ++++++++++++++++++++++++++++++++++++----- 8 files changed, 354 insertions(+), 328 deletions(-) delete mode 100644 tests/stx.js diff --git a/karma.conf.js b/karma.conf.js index 3d7b6dc..a405574 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -13,7 +13,6 @@ module.exports = function (config) { 'node_modules/sinon/pkg/sinon.js', 'dist/strophe.umd.js', 'tests/tests.js', - 'tests/stx.js' ], // list of files to exclude diff --git a/src/builder.js b/src/builder.js index 255d7ee..3a1bc35 100644 --- a/src/builder.js +++ b/src/builder.js @@ -110,7 +110,7 @@ class Builder { * @example const stanza = Builder.fromString(''); */ static fromString(str) { - const el = toElement(str); + const el = toElement(str, true); const b = new Builder(''); b.#nodeTree = el; return b; diff --git a/src/shims.js b/src/shims.js index 512365b..3ed346e 100644 --- a/src/shims.js +++ b/src/shims.js @@ -26,7 +26,8 @@ function getWebSocketImplementation() { if (typeof globalThis.WebSocket === 'undefined') { try { return require('ws'); - } catch (e) { // eslint-disable-line no-unused-vars + // eslint-disable-next-line no-unused-vars + } catch (e) { throw new Error('You must install the "ws" package to use Strophe in nodejs.'); } } @@ -34,6 +35,29 @@ function getWebSocketImplementation() { } export const WebSocket = getWebSocketImplementation(); +/** + * Retrieves the XMLSerializer implementation for the current environment. + * + * In browser environments, it uses the built-in XMLSerializer. + * In Node.js environments, it attempts to load the 'jsdom' package + * to create a compatible XMLSerializer. + */ +function getXMLSerializerImplementation() { + if (typeof globalThis.XMLSerializer === 'undefined') { + let JSDOM; + try { + JSDOM = require('jsdom').JSDOM; + // eslint-disable-next-line no-unused-vars + } catch (e) { + throw new Error('You must install the "ws" package to use Strophe in nodejs.'); + } + const dom = new JSDOM(''); + return dom.window.XMLSerializer; + } + return globalThis.XMLSerializer; +} +export const XMLSerializer = getXMLSerializerImplementation(); + /** * DOMParser * https://w3c.github.io/DOM-Parsing/#the-domparser-interface @@ -52,7 +76,8 @@ function getDOMParserImplementation() { let JSDOM; try { JSDOM = require('jsdom').JSDOM; - } catch (e) { // eslint-disable-line no-unused-vars + // eslint-disable-next-line no-unused-vars + } catch (e) { throw new Error('You must install the "jsdom" package to use Strophe in nodejs.'); } const dom = new JSDOM(''); @@ -75,7 +100,8 @@ export function getDummyXMLDOMDocument() { let JSDOM; try { JSDOM = require('jsdom').JSDOM; - } catch (e) { // eslint-disable-line no-unused-vars + // eslint-disable-next-line no-unused-vars + } catch (e) { throw new Error('You must install the "jsdom" package to use Strophe in nodejs.'); } const dom = new JSDOM(''); diff --git a/src/types/builder.d.ts b/src/types/builder.d.ts index 26ce78a..a848ec8 100644 --- a/src/types/builder.d.ts +++ b/src/types/builder.d.ts @@ -66,7 +66,7 @@ declare class Builder { * Creates a new Builder object from an XML string. * @param {string} str * @returns {Builder} - * @example const stanza = Builder.fromString(''); + * @example const stanza = Builder.fromString(''); */ static fromString(str: string): Builder; /** diff --git a/src/types/shims.d.ts b/src/types/shims.d.ts index 187d054..77f01d1 100644 --- a/src/types/shims.d.ts +++ b/src/types/shims.d.ts @@ -14,6 +14,10 @@ export const WebSocket: { readonly CLOSING: 2; readonly CLOSED: 3; } | typeof import("ws"); +export const XMLSerializer: { + new (): XMLSerializer; + prototype: XMLSerializer; +}; export const DOMParser: { new (): DOMParser; prototype: DOMParser; diff --git a/src/types/stanza.d.ts b/src/types/stanza.d.ts index 6f19ac0..bb3b8ea 100644 --- a/src/types/stanza.d.ts +++ b/src/types/stanza.d.ts @@ -40,7 +40,7 @@ export class Stanza extends Builder { * const pres = stx` * * dnd - * ${unsafeXML(status) + * ${unsafeXML(status)} * `; * connection.send(pres); */ diff --git a/tests/stx.js b/tests/stx.js deleted file mode 100644 index 91e0586..0000000 --- a/tests/stx.js +++ /dev/null @@ -1,276 +0,0 @@ -QUnit.module('The stx tagged template literal'); - -test('can be used to create Stanza objects that are equivalent to Builder objects', (assert) => { - let templateStanza = stx` - - - - - - JC - - - - - JC - - - - - - - - `; - - // prettier-ignore - let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) - .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) - .c("items", { node: "urn:xmpp:bookmarks:1" }) - .c("item", { id: "theplay@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) - .c("nick").t("JC").up() - .up() - .up() - .c("item", { id: "orchard@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) - .c("nick").t("JC").up() - .c("extensions") - .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) - .up() - .up() - .up() - .up() - .up(); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); - - templateStanza = stx` - - Thrice the brinded cat hath mew'd. - - `; - - // prettier-ignore - builderStanza = $msg({ - from: 'coven@chat.shakespeare.lit/firstwitch', - id: '162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2', - to: 'hecate@shakespeare.lit/broom', - type: 'groupchat', - }).c('body').t("Thrice the brinded cat hath mew'd.").up() - .c('delay', { xmlns: 'urn:xmpp:delay', from: 'coven@chat.shakespeare.lit', stamp: '2002-10-13T23:58:37Z' }); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); - - templateStanza = stx` - - - - - `; - - // prettier-ignore - builderStanza = $pres({ - from: 'hag66@shakespeare.lit/pda', - id: 'n13mt3l', - to: 'coven@chat.shakespeare.lit/thirdwitch', - }).c('x', { xmlns: 'http://jabber.org/protocol/muc' }) - .c('history', { maxchars: '65000' }); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); -}); - -test('can be nested recursively', (assert) => { - let templateStanza = stx` - - - - ${[ - stx` - - JC - - `, - stx` - - JC - - - - - `, - ]} - - - `; - - // prettier-ignore - let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) - .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) - .c("items", { node: "urn:xmpp:bookmarks:1" }) - .c("item", { id: "theplay@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) - .c("nick").t("JC").up() - .up() - .up() - .c("item", { id: "orchard@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) - .c("nick").t("JC").up() - .c("extensions") - .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) - .up() - .up() - .up() - .up() - .up(); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); -}); - -test('can have nested Builder objects', (assert) => { - // prettier-ignore - let templateStanza = stx` - - - - ${[ - new Strophe.Builder('item', { id: "theplay@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) - .c("nick").t("JC"), - new Strophe.Builder('item', { id: "orchard@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) - .c("nick").t("JC").up() - .c("extensions") - .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }), - ]} - - - `; - - // prettier-ignore - let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) - .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) - .c("items", { node: "urn:xmpp:bookmarks:1" }) - .c("item", { id: "theplay@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) - .c("nick").t("JC").up() - .up() - .up() - .c("item", { id: "orchard@conference.shakespeare.lit" }) - .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) - .c("nick").t("JC").up() - .c("extensions") - .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) - .up() - .up() - .up() - .up() - .up(); - - assert.equal(isEqualNode(templateStanza, builderStanza), true); -}); - -test('escape the values passed in to them', (assert) => { - const status = ''; - const templateStanza = stx` - - ${status} - `; - - assert.equal( - templateStanza.tree().querySelector('status').innerHTML, - '<script>alert("p0wned")</script>' - ); -}); - -const EMPTY_TEXT_REGEX = /\s*\n\s*/; -const serializer = new XMLSerializer(); - -/** - * @param {Element|Builder|Stanza} el - */ -function stripEmptyTextNodes(el) { - if (el instanceof Strophe.Builder || el instanceof Strophe.Stanza) { - el = el.tree(); - } - - let n; - const text_nodes = []; - const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, (node) => { - if (node.parentElement.nodeName.toLowerCase() === 'body') { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - }); - while ((n = walker.nextNode())) text_nodes.push(n); - text_nodes.forEach((n) => EMPTY_TEXT_REGEX.test(/** @type {Text} */ (n).data) && n.parentElement.removeChild(n)); - - return el; -} - -/** - * Given two XML or HTML elements, determine if they're equal - * @param {Element} actual - * @param {Element} expected - * @returns {Boolean} - */ -function isEqualNode(actual, expected) { - actual = stripEmptyTextNodes(actual); - expected = stripEmptyTextNodes(expected); - - let isEqual = actual.isEqualNode(expected); - - if (!isEqual) { - // XXX: This is a hack. - // When creating two XML elements, one via DOMParser, and one via - // createElementNS (or createElement), then "isEqualNode" doesn't match. - // - // For example, in the following code `isEqual` is false: - // ------------------------------------------------------ - // const a = document.createElementNS('foo', 'div'); - // a.setAttribute('xmlns', 'foo'); - // - // const b = (new DOMParser()).parseFromString('
', 'text/xml').firstElementChild; - // const isEqual = a.isEqualNode(div); // false - // - // The workaround here is to serialize both elements to string and then use - // DOMParser again for both (via xmlHtmlNode). - // - // This is not efficient, but currently this is only being used in tests. - // - const { xmlHtmlNode } = Strophe; - const actual_string = serializer.serializeToString(actual); - const expected_string = serializer.serializeToString(expected); - isEqual = - actual_string === expected_string || xmlHtmlNode(actual_string).isEqualNode(xmlHtmlNode(expected_string)); - } - return isEqual; -} diff --git a/tests/tests.js b/tests/tests.js index d28c6e7..8135bff 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -1,49 +1,5 @@ /*global globalThis, Strophe, $iq, $msg, $build, $pres, QUnit, stx */ - -/** - * Mock xhr, provides getAllResponseHeaders function. - * @param status - * @param readyState - * @param responseText - */ -function XHR(status, readyState, responseText) { - this.status = status; - this.readyState = readyState; - this.responseText = responseText; - this.getAllResponseHeaders = () => null; -} - -class SASLFoo extends Strophe.SASLMechanism { - constructor() { - super('FOO', false, 10); - } - - static get name() { - return 'FOO'; - } -} - -function makeRequest(stanza) { - const req = new Strophe.Request(stanza, () => {}); - req.getResponse = function () { - const env = new Strophe.Builder('env', { type: 'mock' }).tree(); - env.appendChild(stanza); - return env; - }; - return req; -} - -const _sessionStorage = {}; - -if (!globalThis.sessionStorage) { - Object.defineProperty(globalThis, 'sessionStorage', { - value: { - setItem: (key, value) => (_sessionStorage[key] = value), - getItem: (key) => _sessionStorage[key] ?? null, - removeItem: (key) => delete _sessionStorage[key], - }, - }); -} +const serializer = new Strophe.shims.XMLSerializer(); const { test } = QUnit; @@ -193,6 +149,11 @@ test('Strophe.Connection.prototype.send() accepts Builders (#27)', (assert) => { timeoutStub.restore(); }); +test('The fromString static method', (assert) => { + const stanza = Strophe.Builder.fromString(''); + assert.equal(isEqualNode(stanza, $pres({ from: 'juliet@example.com/chamber' })), true); +}); + QUnit.module('Strophe.Connection options'); test('withCredentials can be set on the XMLHttpRequest object', (assert) => { @@ -1043,3 +1004,315 @@ test('nextValidRid is called after connection reset', (assert) => { assert.equal(spy.calledWith(4294967295), true, 'The RID was valid'); Math.random.restore(); }); + +QUnit.module('The stx tagged template literal'); + +test('can be used to create Stanza objects that are equivalent to Builder objects', (assert) => { + let templateStanza = stx` + + + + + + JC + + + + + JC + + + + + + + + `; + + // prettier-ignore + let builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) + .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .c("items", { node: "urn:xmpp:bookmarks:1" }) + .c("item", { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC").up() + .up() + .up() + .c("item", { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) + .up() + .up() + .up() + .up() + .up(); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); + + templateStanza = stx` + + Thrice the brinded cat hath mew'd. + + `; + + // prettier-ignore + builderStanza = $msg({ + from: 'coven@chat.shakespeare.lit/firstwitch', + id: '162BEBB1-F6DB-4D9A-9BD8-CFDCC801A0B2', + to: 'hecate@shakespeare.lit/broom', + type: 'groupchat', + }).c('body').t("Thrice the brinded cat hath mew'd.").up() + .c('delay', { xmlns: 'urn:xmpp:delay', from: 'coven@chat.shakespeare.lit', stamp: '2002-10-13T23:58:37Z' }); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); + + templateStanza = stx` + + + + + `; + + // prettier-ignore + builderStanza = $pres({ + from: 'hag66@shakespeare.lit/pda', + id: 'n13mt3l', + to: 'coven@chat.shakespeare.lit/thirdwitch', + }).c('x', { xmlns: 'http://jabber.org/protocol/muc' }) + .c('history', { maxchars: '65000' }); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); +}); + +test('can be nested recursively', (assert) => { + const templateStanza = stx` + + + + ${[ + stx` + + JC + + `, + stx` + + JC + + + + + `, + ]} + + + `; + + // prettier-ignore + const builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) + .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .c("items", { node: "urn:xmpp:bookmarks:1" }) + .c("item", { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC").up() + .up() + .up() + .c("item", { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) + .up() + .up() + .up() + .up() + .up(); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); +}); + +test('can have nested Builder objects', (assert) => { + // prettier-ignore + const templateStanza = stx` + + + + ${[ + new Strophe.Builder('item', { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC"), + new Strophe.Builder('item', { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }), + ]} + + + `; + + // prettier-ignore + const builderStanza = $iq({ type: "result", to: "juliet@capulet.lit/balcony", id: "retrieve1" }) + .c("pubsub", { xmlns: "http://jabber.org/protocol/pubsub" }) + .c("items", { node: "urn:xmpp:bookmarks:1" }) + .c("item", { id: "theplay@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Play's the Thing", autojoin: "true" }) + .c("nick").t("JC").up() + .up() + .up() + .c("item", { id: "orchard@conference.shakespeare.lit" }) + .c("conference", { xmlns: "urn:xmpp:bookmarks:1", name: "The Orcard", autojoin: "1" }) + .c("nick").t("JC").up() + .c("extensions") + .c("state", { xmlns: "http://myclient.example/bookmark/state", minimized: "true" }) + .up() + .up() + .up() + .up() + .up(); + + assert.equal(isEqualNode(templateStanza, builderStanza), true); +}); + +test('escape the values passed in to them', (assert) => { + const status = ''; + const templateStanza = stx` + + ${status} + `; + + assert.equal( + templateStanza.tree().querySelector('status').innerHTML, + '<script>alert("p0wned")</script>' + ); +}); + +const TEXT_NODE = 3; +const ELEMENT_NODE = 1; + +function stripEmptyTextNodes(element) { + const childNodes = Array.from(element.childNodes ?? []); + childNodes.forEach((node) => { + if (node.nodeType === TEXT_NODE && !node.nodeValue.trim()) { + element.removeChild(node); + } else if (node.nodeType === ELEMENT_NODE) { + stripEmptyTextNodes(node); // Recursively call for child elements + } + }); + return element; +} + +/** + * Given two XML or HTML elements, determine if they're equal + * @param {Strophe.Stanza|Strophe.Builder} actual + * @param {Strophe.Stanza|Strophe.Builder} expected + * @returns {Boolean} + */ +function isEqualNode(actual, expected) { + actual = stripEmptyTextNodes(actual.tree()); + expected = stripEmptyTextNodes(expected.tree()); + + let isEqual = actual.isEqualNode(expected); + + if (!isEqual) { + // XXX: This is a hack. + // When creating two XML elements, one via DOMParser, and one via + // createElementNS (or createElement), then "isEqualNode" doesn't match. + // + // For example, in the following code `isEqual` is false: + // ------------------------------------------------------ + // const a = document.createElementNS('foo', 'div'); + // a.setAttribute('xmlns', 'foo'); + // + // const b = (new DOMParser()).parseFromString('
', 'text/xml').firstElementChild; + // const isEqual = a.isEqualNode(div); // false + // + // The workaround here is to serialize both elements to string and then use + // DOMParser again for both (via xmlHtmlNode). + // + // This is not efficient, but currently this is only being used in tests. + // + const { xmlHtmlNode } = Strophe; + const actual_string = serializer.serializeToString(actual); + const expected_string = serializer.serializeToString(expected); + isEqual = + actual_string === expected_string || xmlHtmlNode(actual_string).isEqualNode(xmlHtmlNode(expected_string)); + } + return isEqual; +} + +/** + * Mock xhr, provides getAllResponseHeaders function. + * @param status + * @param readyState + * @param responseText + */ +function XHR(status, readyState, responseText) { + this.status = status; + this.readyState = readyState; + this.responseText = responseText; + this.getAllResponseHeaders = () => null; +} + +class SASLFoo extends Strophe.SASLMechanism { + constructor() { + super('FOO', false, 10); + } + + static get name() { + return 'FOO'; + } +} + +function makeRequest(stanza) { + const req = new Strophe.Request(stanza, () => {}); + req.getResponse = function () { + const env = new Strophe.Builder('env', { type: 'mock' }).tree(); + env.appendChild(stanza); + return env; + }; + return req; +} + +const _sessionStorage = {}; + +if (!globalThis.sessionStorage) { + Object.defineProperty(globalThis, 'sessionStorage', { + value: { + setItem: (key, value) => (_sessionStorage[key] = value), + getItem: (key) => _sessionStorage[key] ?? null, + removeItem: (key) => delete _sessionStorage[key], + }, + }); +} From 623aad465bbd38562ad0d2b70d1dcbe96d3e1d96 Mon Sep 17 00:00:00 2001 From: JC Brand Date: Mon, 16 Dec 2024 09:02:50 +0200 Subject: [PATCH 7/7] Add a test for the unsafeXML directive. --- tests/tests.js | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/tests/tests.js b/tests/tests.js index 8135bff..f8283e1 100644 --- a/tests/tests.js +++ b/tests/tests.js @@ -150,7 +150,9 @@ test('Strophe.Connection.prototype.send() accepts Builders (#27)', (assert) => { }); test('The fromString static method', (assert) => { - const stanza = Strophe.Builder.fromString(''); + const stanza = Strophe.Builder.fromString( + '' + ); assert.equal(isEqualNode(stanza, $pres({ from: 'juliet@example.com/chamber' })), true); }); @@ -1218,11 +1220,24 @@ test('escape the values passed in to them', (assert) => { ); }); +test('The unsafeXML directive', (assert) => { + const templateStanza = stx` + + ${Strophe.Stanza.unsafeXML(`I'm busy!`)} + `; + + assert.equal( + isEqualNode(templateStanza, $pres({ from: 'juliet@example.com/chamber' }).c('status').t("I'm busy!")), + true + ); +}); + const TEXT_NODE = 3; const ELEMENT_NODE = 1; function stripEmptyTextNodes(element) { - const childNodes = Array.from(element.childNodes ?? []); + const childNodes = Array.from(element.childNodes ?? []); childNodes.forEach((node) => { if (node.nodeType === TEXT_NODE && !node.nodeValue.trim()) { element.removeChild(node);