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,146 @@
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user