Files
wt e2b4dbdb76 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
2026-10-15 02:31:33 +07:00

147 lines
6.1 KiB
JavaScript

import {SaxesParser} from 'saxes';
import jxt from 'stanza/jxt/index.js';
// Experiment policy only. The byte ceiling bounds SAX token/namespace storage
// even before callbacks can inspect a completed name or attribute value.
export const LIMITS = Object.freeze({bytes: 4096, depth: 8, elements: 32,
attributes: 16, attributeChars: 1024, nameChars: 128, valueChars: 256});
class IngressError extends Error {
constructor(code) { super(code); this.code = code; }
}
const fail = code => { throw new IngressError(code); };
export function validateMessage(data) {
if (typeof data !== 'string') fail('text-required');
// Avoid encoding an attacker-sized string. UTF-8 bytes >= UTF-16 units for
// valid XML; isolated surrogates are subsequently rejected by saxes.
if (data.length > LIMITS.bytes) fail('bytes');
if (new TextEncoder().encode(data).length > LIMITS.bytes) fail('bytes');
if (!data.startsWith('<')) fail('framing');
let depth = 0, elements = 0, attributes = 0, attributeChars = 0, ended = false;
const parser = new SaxesParser({xmlns: true, fragment: false,
defaultXMLVersion: '1.0', forceXMLVersion: true});
parser.on('error', () => fail('invalid-xml'));
for (const event of ['doctype', 'comment', 'processinginstruction', 'xmldecl']) {
parser.on(event, () => fail('restricted-xml'));
}
parser.on('opentagstart', tag => {
if (++depth > LIMITS.depth) fail('depth');
if (++elements > LIMITS.elements) fail('elements');
if (tag.name.length > LIMITS.nameChars) fail('name');
});
parser.on('attribute', attr => {
if (++attributes > LIMITS.attributes) fail('attributes');
if (attr.name.length > LIMITS.nameChars) fail('name');
if (attr.value.length > LIMITS.valueChars) fail('attribute-value');
attributeChars += attr.name.length + attr.value.length;
if (attributeChars > LIMITS.attributeChars) fail('attribute-chars');
});
parser.on('closetag', () => { depth--; });
parser.on('end', () => { ended = true; });
// close() detects unfinished XML. Throwing from callbacks aborts immediately;
// there is no recovery listener, DTD fetch, custom entity or tree construction.
parser.write(data).close();
if (!ended || depth !== 0 || elements === 0) fail('framing');
}
export function importMessage(data, registry, config) {
validateMessage(data);
// Only bounded, complete documents reach the actual candidate parser/import.
const xml = jxt.parse(data, {allowComments: false});
if (!xml) fail('invalid-xml');
const kind = registry.getImportKey(xml);
const stanza = registry.import(xml, {lang: config.lang,
acceptLanguages: config.acceptLanguages});
if (!kind || !stanza) fail('unknown-root');
return {kind, stanza};
}
// Public Transport interface and Client.transports constructor registry.
// Socket injection is solely for finite fixtures; default is browser WebSocket.
export function installBoundedTransport(client, Socket = globalThis.WebSocket) {
class BoundedTransport {
#client; #registry; #sm; #socket; #config; #closed = true;
hasStream = false;
stream;
constructor(agent, sm, registry) {
this.#client = agent; this.#sm = sm; this.#registry = registry;
}
connect(config) {
if (this.#socket || !this.#closed) throw new Error('Fresh transport required');
if (new URL(config.url).protocol !== 'wss:') throw new Error('WSS required');
this.#config = config;
this.#closed = false;
const socket = this.#socket = new Socket(config.url, 'xmpp');
socket.onopen = () => {
if (this.#closed) return;
if (socket.protocol !== 'xmpp') return this.#reject('subprotocol');
this.#sm.started = false;
this.#client.emit('connected');
this.restart();
};
socket.onmessage = event => {
if (this.#closed) return;
let parsed;
try { parsed = importMessage(event.data, this.#registry, this.#config); }
catch (error) { return this.#reject(error instanceof IngressError ? error.code : 'invalid-xml'); }
// No raw payload event/log or async parser continuation escapes ingress.
if (parsed.kind === 'stream') {
if (parsed.stanza.action === 'open') {
this.hasStream = true;
this.stream = parsed.stanza;
this.#client.emit('stream:start', parsed.stanza);
} else {
this.#client.emit('stream:end');
this.disconnect(false);
}
} else {
this.#client.emit('stream:data', parsed.stanza, parsed.kind);
}
};
socket.onerror = () => this.#reject('socket-error');
socket.onclose = () => this.disconnect(false);
}
#reject(code) {
if (this.#closed) return;
// Close/clear before notifying consumers; a reentrant callback cannot
// revive the attempt. Never expose parser error text containing input.
this.disconnect(false);
this.#client.emit('ingress:rejected', code);
}
disconnect(clean = true) {
if (this.#closed) return;
this.#closed = true;
const socket = this.#socket;
this.#socket = undefined;
if (socket) {
socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null;
if (clean && this.hasStream && socket.readyState === 1) {
socket.send(this.#registry.export('stream', {action: 'close'}).toString());
}
socket.close();
}
this.hasStream = false;
this.stream = undefined;
this.#config = undefined;
if (this.#client.transport === this) this.#client.emit('--transport-disconnected');
}
restart() {
if (this.#closed) return;
this.hasStream = false;
this.stream = undefined;
this.#write(this.#registry.export('stream', {action: 'open',
lang: this.#config.lang, to: this.#config.server, version: '1.0'}).toString());
}
#write(data) {
if (this.#closed || this.#socket?.readyState !== 1) throw new Error('Socket closed');
this.#socket.send(data);
}
async send(name, data) {
const xml = this.#registry.export(name, data);
if (!xml) throw new Error('Unknown outgoing stanza');
this.#write(xml.toString());
}
}
client.transports['bounded-websocket'] = BoundedTransport;
}