S00-018: Bound standalone XML ingress before Stanza parsing and import
Changes: Add an isolated public transport integration with finite byte, depth, element and attribute budgets; validate complete text XML with pinned saxes before real JXT parsing/import. Add adversarial and boundary fixtures, terminal cleanup tests, dependency notices and a proposed parser ADR. Validation: Fresh-cache npm ci and npm test passed all 55 tests with Node 24.21.0/npm 11.19.0. Reproduced three stock parser failures, checked ten exact dependency licenses/integrities and 340 unchanged Stanza files; make check passed 72 tooling tests. Scope and whitespace inspected. Browser runtime, live WSS and interoperability were not tested. Task: S00-018 Agent: auto-protocol-89fe6563092c Role: protocol Action: claim Status: review Task title: Validate bounded standalone XML ingress for the XMPP candidate
This commit is contained in:
1 parent
f556fc0deb
commit
e2b4dbdb76
19 files changed
+1648
-8
No files matched your search
@@ -0,0 +1,68 @@
|
||||
export const opening = '<message xmlns="jabber:client"><body>';
|
||||
export const closing = '</body></message>';
|
||||
export const message = text => opening + text + closing;
|
||||
export const valid = message('finite fixture');
|
||||
export const deep = n => '<message xmlns="jabber:client">' + '<x>'.repeat(n - 1) +
|
||||
'</x>'.repeat(n - 1) + '</message>';
|
||||
export const elements = n => '<message xmlns="jabber:client">' + '<x/>'.repeat(n - 1) + '</message>';
|
||||
export const attributes = n => '<message xmlns="jabber:client"' +
|
||||
Array.from({length: n - 1}, (_, i) => ` a${i}="x"`).join('') + '/>';
|
||||
export function boundary() {
|
||||
// 16 attributes / 1024 name+value UTF-16 units including xmlns.
|
||||
let attrs = ' xmlns="jabber:client"';
|
||||
let chars = 'xmlns'.length + 'jabber:client'.length;
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const name = `a${i}`;
|
||||
const value = 'v'.repeat(i === 14 ? 1024 - chars - name.length : 60);
|
||||
attrs += ` ${name}="${value}"`;
|
||||
chars += name.length + value.length;
|
||||
}
|
||||
// Root + body + chain of 7 + 23 siblings = 32 elements; depth 8.
|
||||
const before = `<message${attrs}><body>`;
|
||||
const after = '</body>' + '<x>'.repeat(7) + '</x>'.repeat(7) + '<x/>'.repeat(23) + '</message>';
|
||||
return {xml: before + 'z'.repeat(4096 - before.length - after.length) + after,
|
||||
body: 'z'.repeat(4096 - before.length - after.length)};
|
||||
}
|
||||
export const invalid = [
|
||||
['large stock body', message('b'.repeat(1048576)), 'bytes'],
|
||||
['deep stock XML', deep(257), 'depth'],
|
||||
['unfinished opening', opening, 'invalid-xml'],
|
||||
['unfinished attribute', '<message xmlns="jabber:client" a="unfinished', 'invalid-xml'],
|
||||
['unfinished tag', '<message xmlns="jabber:client"', 'invalid-xml'],
|
||||
['unfinished entity', message('&'), 'invalid-xml'],
|
||||
['empty', '', 'framing'],
|
||||
['leading whitespace', ' ' + valid, 'framing'],
|
||||
['text only', 'not xml', 'framing'],
|
||||
['two roots', valid + valid, 'invalid-xml'],
|
||||
['valid root then unfinished root', valid + '<message', 'invalid-xml'],
|
||||
['valid root then junk', valid + 'junk', 'invalid-xml'],
|
||||
['mismatched tags', '<message xmlns="jabber:client"><body></message>', 'invalid-xml'],
|
||||
['depth over', deep(9), 'depth'],
|
||||
['elements over', elements(33), 'elements'],
|
||||
['attributes over', attributes(17), 'attributes'],
|
||||
['attribute value over', `<message xmlns="jabber:client" a="${'x'.repeat(257)}"/>`, 'attribute-value'],
|
||||
['attribute chars over', '<message xmlns="jabber:client" ' +
|
||||
Array.from({length: 4}, (_, i) => `a${i}="${'x'.repeat(256)}"`).join(' ') + '/>', 'attribute-chars'],
|
||||
['oversized name', `<message xmlns="jabber:client"><${'x'.repeat(129)}/></message>`, 'name'],
|
||||
['oversized attribute name', `<message xmlns="jabber:client" ${'x'.repeat(129)}="x"/>`, 'name'],
|
||||
['oversized unfinished token', '<' + 'x'.repeat(4096), 'bytes'],
|
||||
['UTF-8 bytes over with bounded units', message('🦉'.repeat(1100)), 'bytes'],
|
||||
['binary buffer', new Uint8Array([60, 120, 47, 62]), 'text-required'],
|
||||
['binary ArrayBuffer', new ArrayBuffer(4), 'text-required'],
|
||||
['binary Blob', new Blob([valid]), 'text-required'],
|
||||
['boxed string', new String(valid), 'text-required'],
|
||||
['comment', '<!--x-->' + valid, 'restricted-xml'],
|
||||
['DTD internal entity', '<!DOCTYPE message [<!ENTITY x "payload">]>' + message('&x;'), 'restricted-xml'],
|
||||
['external DTD', '<!DOCTYPE message SYSTEM "https://example.invalid/dtd">' + valid, 'restricted-xml'],
|
||||
['processing instruction', '<?test x?>' + valid, 'restricted-xml'],
|
||||
['XML declaration', '<?xml version="1.0"?>' + valid, 'restricted-xml'],
|
||||
['duplicate attribute', '<message xmlns="jabber:client" id="a" id="b"/>', 'invalid-xml'],
|
||||
['duplicate expanded attribute', '<message xmlns="jabber:client" xmlns:a="urn:x" xmlns:b="urn:x" a:id="a" b:id="b"/>', 'invalid-xml'],
|
||||
['unbound namespace', '<p:message/>', 'invalid-xml'],
|
||||
['illegal namespace binding', '<message xmlns="jabber:client" xmlns:xml="urn:wrong"/>', 'invalid-xml'],
|
||||
['unknown entity', message('&unknown;'), 'invalid-xml'],
|
||||
['illegal control', message('\u0000'), 'invalid-xml'],
|
||||
['unpaired surrogate', message('\ud800'), 'invalid-xml'],
|
||||
['invalid char reference', message('�'), 'invalid-xml'],
|
||||
['unknown root', '<unknown xmlns="urn:fixture"/>', 'unknown-root']
|
||||
];
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import clientModule from 'stanza/Client.js';
|
||||
import {installBoundedTransport, validateMessage, LIMITS} from '../bounded-ingress.mjs';
|
||||
import {opening, closing, message, valid, deep, elements, attributes, boundary, invalid} from './fixtures.mjs';
|
||||
const Client = clientModule.default;
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
|
||||
async function harness() {
|
||||
const sockets = [];
|
||||
class FixtureSocket {
|
||||
readyState = 1; protocol = 'xmpp'; sent = []; closes = 0;
|
||||
constructor(url, protocol) { this.url = url; this.requestedProtocol = protocol; sockets.push(this); }
|
||||
send(data) { this.sent.push(data); }
|
||||
close() { this.closes++; this.readyState = 3; }
|
||||
}
|
||||
const client = new Client({autoReconnect: false, server: 'example.invalid'});
|
||||
installBoundedTransport(client, FixtureSocket);
|
||||
client.updateConfig({transports: {'bounded-websocket': 'wss://example.invalid/xmpp'},
|
||||
transportPreferenceOrder: ['bounded-websocket']});
|
||||
const received = [], errors = [], raw = [], stanzas = [], starts = [];
|
||||
client.on('stream:data', (stanza, kind) => received.push({stanza, kind}));
|
||||
client.on('stanza', stanza => stanzas.push(stanza));
|
||||
client.on('stream:start', stanza => starts.push(stanza));
|
||||
client.on('ingress:rejected', code => errors.push(code));
|
||||
client.on('raw', (...args) => raw.push(args));
|
||||
// Tracing calls the real registry.import; proves rejection before import.
|
||||
let imports = 0;
|
||||
const original = client.stanzas.import.bind(client.stanzas);
|
||||
client.stanzas.import = (...args) => { imports++; return original(...args); };
|
||||
await client.connect();
|
||||
const socket = sockets.at(-1);
|
||||
socket.onopen();
|
||||
return {client, sockets, socket, received, errors, raw, stanzas, starts,
|
||||
imports: () => imports, finish: async () => {client.transport?.disconnect(false); await tick();}};
|
||||
}
|
||||
|
||||
for (const [name, xml, code] of invalid) {
|
||||
test(`terminal rejection: ${name}`, async () => {
|
||||
const h = await harness();
|
||||
const late = h.socket.onmessage;
|
||||
late({data: xml});
|
||||
assert.deepEqual(h.errors, [code]);
|
||||
assert.equal(h.socket.closes, 1);
|
||||
assert.equal(h.socket.onmessage, null);
|
||||
assert.equal(h.received.length, 0);
|
||||
assert.equal(h.starts.length, 0);
|
||||
if (code !== 'unknown-root') assert.equal(h.imports(), 0);
|
||||
// Old captured handlers and subsequent messages cannot revive an attempt.
|
||||
late({data: closing}); late({data: valid});
|
||||
await tick();
|
||||
assert.equal(h.stanzas.length, 0);
|
||||
assert.equal(h.received.length, 0);
|
||||
assert.deepEqual(h.errors, [code]);
|
||||
assert.equal(h.client.transport, undefined);
|
||||
assert.equal(h.raw.length, 0);
|
||||
await h.finish();
|
||||
});
|
||||
}
|
||||
|
||||
for (const [name, xml, body] of [
|
||||
['all aggregate limits equal', boundary().xml, boundary().body],
|
||||
['depth equal', deep(LIMITS.depth)],
|
||||
['element count equal', elements(LIMITS.elements)],
|
||||
['attribute count equal', attributes(LIMITS.attributes)],
|
||||
['attribute value equal', `<message xmlns="jabber:client" a="${'v'.repeat(256)}"/>`],
|
||||
['name equal', `<message xmlns="jabber:client"><${'x'.repeat(128)}/></message>`],
|
||||
['UTF-8 byte equality', message('🦉'.repeat(1000) + 'a'.repeat(4096 - message('').length - 4000)), '🦉'.repeat(1000) + 'a'.repeat(4096 - message('').length - 4000)],
|
||||
['escaped text and CDATA', message('<&<![CDATA[<fixture>]]>'), '<&<fixture>'],
|
||||
['prefixed namespace', '<c:message xmlns:c="jabber:client"><c:body>namespace</c:body></c:message>', 'namespace']
|
||||
]) {
|
||||
test(`actual parser/import accepts ${name}`, async () => {
|
||||
const h = await harness();
|
||||
h.socket.onmessage({data: xml});
|
||||
await tick();
|
||||
assert.deepEqual(h.errors, []);
|
||||
assert.equal(h.received.length, 1);
|
||||
assert.equal(h.received[0].kind, 'message');
|
||||
if (body !== undefined) assert.equal(h.received[0].stanza.body, body);
|
||||
assert.equal(h.stanzas.length, 1);
|
||||
assert.ok(h.imports() > 0);
|
||||
await h.finish();
|
||||
});
|
||||
}
|
||||
|
||||
test('bytes one over and UTF-8 one over reject before import', async () => {
|
||||
for (const xml of [boundary().xml + ' ', message('🦉'.repeat(1000) + 'a'.repeat(4097 - message('').length - 4000))]) {
|
||||
const h = await harness(); h.socket.onmessage({data: xml});
|
||||
assert.deepEqual(h.errors, ['bytes']); assert.equal(h.imports(), 0);
|
||||
await h.finish();
|
||||
}
|
||||
});
|
||||
|
||||
test('stock 18-message partial document never accumulates or revives; fresh connect works', async () => {
|
||||
const h = await harness(); const old = h.socket.onmessage;
|
||||
old({data: opening});
|
||||
for (let i = 0; i < 16; i++) old({data: 'a'.repeat(4096)});
|
||||
old({data: closing});
|
||||
await tick();
|
||||
assert.equal(h.received.length, 0); assert.equal(h.imports(), 0);
|
||||
assert.deepEqual(h.errors, ['invalid-xml']);
|
||||
await h.client.connect(); const fresh = h.sockets.at(-1); fresh.onopen();
|
||||
old({data: valid}); assert.equal(h.received.length, 0);
|
||||
fresh.onmessage({data: valid}); await tick();
|
||||
assert.equal(h.received.length, 1); assert.equal(h.stanzas.length, 1);
|
||||
assert.equal(h.received[0].stanza.body, 'finite fixture');
|
||||
await h.finish();
|
||||
});
|
||||
|
||||
test('successive complete documents have no shared namespace/parser state', async () => {
|
||||
const h = await harness();
|
||||
h.socket.onmessage({data: valid}); h.socket.onmessage({data: valid}); await tick();
|
||||
assert.equal(h.stanzas.length, 2);
|
||||
h.socket.onmessage({data: '<c:message/>'}); await tick();
|
||||
assert.deepEqual(h.errors, ['invalid-xml']); assert.equal(h.stanzas.length, 2);
|
||||
await h.finish();
|
||||
});
|
||||
|
||||
test('open, restart, message, outgoing export and close use actual registry', async () => {
|
||||
const h = await harness();
|
||||
h.socket.onmessage({data: '<open xmlns="urn:ietf:params:xml:ns:xmpp-framing" from="example.invalid" version="1.0"/>'});
|
||||
assert.equal(h.client.transport.hasStream, true); assert.equal(h.starts.length, 1);
|
||||
h.client.transport.restart(); assert.equal(h.client.transport.hasStream, false);
|
||||
await h.client.transport.send('message', {body: 'outgoing fixture'});
|
||||
assert.ok(h.socket.sent.at(-1).includes('outgoing fixture'));
|
||||
h.socket.onmessage({data: '<close xmlns="urn:ietf:params:xml:ns:xmpp-framing"/>'});
|
||||
await tick(); assert.equal(h.socket.closes, 1); assert.equal(h.client.transport, undefined);
|
||||
assert.equal(h.errors.length, 0);
|
||||
await h.finish();
|
||||
});
|
||||
|
||||
test('failed subprotocol, socket error, clean disconnect and stale callbacks are terminal', async () => {
|
||||
for (const mode of ['subprotocol', 'error', 'clean']) {
|
||||
const h = await harness(); const old = h.socket.onmessage;
|
||||
if (mode === 'subprotocol') {h.socket.protocol = 'wrong'; h.socket.onopen();}
|
||||
if (mode === 'error') h.socket.onerror(new Error('fixture'));
|
||||
if (mode === 'clean') {
|
||||
h.socket.onmessage({data: '<open xmlns="urn:ietf:params:xml:ns:xmpp-framing"/>'});
|
||||
h.client.transport.disconnect(); assert.ok(h.socket.sent.at(-1).includes('close'));
|
||||
}
|
||||
old({data: valid}); await tick();
|
||||
assert.equal(h.received.length, 0); assert.equal(h.socket.closes, 1);
|
||||
await h.finish();
|
||||
}
|
||||
});
|
||||
|
||||
test('SAX framing validation retains no attempt object', () => {
|
||||
assert.throws(() => validateMessage(opening));
|
||||
assert.throws(() => validateMessage(closing));
|
||||
assert.doesNotThrow(() => validateMessage(valid));
|
||||
});
|
||||
Reference in new issue
Block a user