Something went wrong. Try again.
Something went wrong. Try again.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091import { describe, expect, it } from "vitest";import { el, parseXml, serialize, text } from "../../shared/xml";
/** XMPP stanzas arrive from a network. The parser reads exactly what XMPP needs and refuses everything else: no DTDs, no entities beyond the five and numeric references, no processing instructions beyond the XML declaration, bounded depth and size. */
describe("parseXml", () => { it("reads an element with attributes (either quote), children and text", () => { const r = parseXml(`<message xmlns='jabber:client' to="a@b" from='c@d/e' type='chat'><body>hi & bye</body><x/></message>`); expect(r.name).toBe("message"); expect(r.attrs).toMatchObject({ xmlns: "jabber:client", to: "a@b", from: "c@d/e", type: "chat" }); expect(r.children.map((c) => c.name)).toEqual(["body", "x"]); expect(text(r.children[0]!)).toBe("hi & bye"); }); it("takes the namespace from the nearest xmlns, and strips a prefix from the name while keeping it in `prefix`", () => { const r = parseXml(`<stream:features xmlns:stream='http://etherx.jabber.org/streams'><mechanisms xmlns='urn:ietf:params:xml:ns:xmpp-sasl'><mechanism>PLAIN</mechanism></mechanisms></stream:features>`); expect(r).toMatchObject({ name: "features", prefix: "stream" }); expect(r.children[0]).toMatchObject({ name: "mechanisms", ns: "urn:ietf:params:xml:ns:xmpp-sasl" }); expect(r.children[0]!.children[0]).toMatchObject({ name: "mechanism", ns: "urn:ietf:params:xml:ns:xmpp-sasl" }); }); it("decodes the five entities and numeric character references, and nothing else", () => { expect(text(parseXml("<a><>&"'AB</a>"))).toBe(`<>&"'AB`); expect(() => parseXml("<a> </a>")).toThrow(/entity/); expect(() => parseXml("<a>�</a>")).toThrow(/character/); expect(() => parseXml("<a>�</a>")).toThrow(/character/); expect(() => parseXml("<a><</a>")).toThrow(); }); it("accepts self-closing tags, an XML declaration and comments, and CDATA as text", () => { expect(parseXml(`<?xml version='1.0'?><a><!-- note --><b/><![CDATA[<raw>]]></a>`).children.map((c) => c.name)).toEqual(["b"]); expect(text(parseXml("<a><![CDATA[<raw>]]></a>"))).toBe("<raw>"); }); it("refuses a DOCTYPE, an entity declaration and other processing instructions (the ways XML is made to read files or explode)", () => { expect(() => parseXml(`<!DOCTYPE a [<!ENTITY x SYSTEM "file:///etc/passwd">]><a>&x;</a>`)).toThrow(/DOCTYPE|not allowed/); expect(() => parseXml(`<?xml-stylesheet href='x'?><a/>`)).toThrow(/instruction/); expect(() => parseXml(`<a><?php echo 1; ?></a>`)).toThrow(/instruction/); }); it.each([ ["mismatched tags", "<a><b></a></b>"], ["an unclosed element", "<a><b>"], ["text outside the root element", "x<a/>"], ["two root elements", "<a/><b/>"], ["an attribute with no value", "<a b/>"], ["an unquoted attribute value", "<a b=c/>"], ["an attribute given twice", "<a b='1' b='2'/>"], ["a < in an attribute value", "<a b='<'/>"], ["an empty input", ""], ["a name that starts with a digit", "<1a/>"], ["a closing tag with attributes", "<a></a b='1'>"], ["an unterminated comment", "<a><!-- x</a>"], ["a NUL character", "<a>\u0000</a>"], ])("refuses %s", (_n, xml) => expect(() => parseXml(xml)).toThrow()); it("is bounded: nesting deeper than 20, an input over 1 MB, more than 200 attributes or 5000 children are refused", () => { expect(() => parseXml("<a>".repeat(25) + "</a>".repeat(25))).toThrow(/deep/); expect(() => parseXml(`<a>${"x".repeat(1_000_001)}</a>`)).toThrow(/large/); expect(() => parseXml(`<a ${Array.from({ length: 201 }, (_, i) => `a${i}='1'`).join(" ")}/>`)).toThrow(/attributes/); expect(() => parseXml(`<a>${"<b/>".repeat(5001)}</a>`)).toThrow(/children/); }); it("does not take long on pathological input (no backtracking blowup)", () => { const t0 = Date.now(); try { parseXml(`<a b='${"x".repeat(200_000)}`); } catch { /* expected */ } try { parseXml("<a>" + "&".repeat(100_000)); } catch { /* expected */ } expect(Date.now() - t0).toBeLessThan(1000); }); it("keeps non-ASCII text", () => { expect(text(parseXml("<a>héllo ✓ 日本</a>"))).toBe("héllo ✓ 日本"); });});
describe("el and serialize", () => { it("builds an element and writes it with everything escaped, so what goes in comes back out", () => { const e = el("message", { to: "a@b", type: "normal" }, el("body", {}, `5 < 6 & "7" 'x'`)); const xml = serialize(e); expect(xml).toBe(`<message to="a@b" type="normal"><body>5 < 6 & "7" 'x'</body></message>`); const back = parseXml(xml); expect(text(back.children[0]!)).toBe(`5 < 6 & "7" 'x'`); expect(back.attrs.to).toBe("a@b"); }); it("escapes attribute values, including quotes and angle brackets", () => { const xml = serialize(el("a", { v: `"><script>` })); expect(xml).not.toContain("<script>"); expect(parseXml(xml).attrs.v).toBe(`"><script>`); }); it("writes an empty element as a self-closing tag, and a namespace as xmlns", () => { expect(serialize(el("offer", { xmlns: "urn:aite:doorbell:0", job: "j" }))).toBe(`<offer xmlns="urn:aite:doorbell:0" job="j"/>`); }); it("refuses a name that is not a plain name (so an element cannot be injected through one)", () => { expect(() => serialize(el("a b", {}))).toThrow(/name/); expect(() => serialize(el("a", { "x y": "1" }))).toThrow(/name/); expect(() => serialize(el("a><b", {}))).toThrow(/name/); });});