tinymux/client/web/test_web.js
Stephen Dennis 17f74f1a94 fix(web/mcp): cap multiline reassembly pending size (#1889)
McpParser stored every unterminated #$#* tag without bound. Cap pending
messages (32), per-message bytes (256 KiB), and total pending (1 MiB);
evict oldest or drop the overflowing tag with a diagnostic. Regression
covers flood of unique tags, fat continuations, and happy-path reassembly.
2026-07-31 09:27:38 -06:00

486 lines
16 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const root = __dirname;
function createStorage() {
const data = new Map();
return {
getItem(key) {
return data.has(key) ? data.get(key) : null;
},
setItem(key, value) {
data.set(key, String(value));
},
removeItem(key) {
data.delete(key);
},
clear() {
data.clear();
},
};
}
function loadScript(relPath, exportNames, extra = {}) {
const code = fs.readFileSync(path.join(root, relPath), 'utf8');
const context = {
console,
TextEncoder,
TextDecoder,
Uint8Array,
ArrayBuffer,
AbortController,
setTimeout(fn) {
fn();
return 1;
},
clearTimeout() {},
setInterval() {
return 1;
},
clearInterval() {},
...extra,
};
context.globalThis = context;
vm.createContext(context);
const exportSrc = '\n;globalThis.__exports = {' + exportNames.join(', ') + '};\n';
vm.runInContext(code + exportSrc, context, { filename: relPath });
return { context, exports: context.__exports };
}
function encodeGrpcWebResponse(payload) {
const trailerText = 'grpc-status: 0\r\n';
const trailer = new TextEncoder().encode(trailerText);
const out = new Uint8Array(5 + payload.length + 5 + trailer.length);
out[0] = 0x00;
out[1] = (payload.length >>> 24) & 0xFF;
out[2] = (payload.length >>> 16) & 0xFF;
out[3] = (payload.length >>> 8) & 0xFF;
out[4] = payload.length & 0xFF;
out.set(payload, 5);
const pos = 5 + payload.length;
out[pos] = 0x80;
out[pos + 1] = (trailer.length >>> 24) & 0xFF;
out[pos + 2] = (trailer.length >>> 16) & 0xFF;
out[pos + 3] = (trailer.length >>> 8) & 0xFF;
out[pos + 4] = trailer.length & 0xFF;
out.set(trailer, pos + 5);
return out.buffer;
}
async function flush() {
for (let i = 0; i < 5; i++) {
await Promise.resolve();
}
}
function makeHydraEnv() {
const sessionStorage = createStorage();
const localStorage = createStorage();
class FakeWebSocket {
static instances = [];
static OPEN = 1;
constructor(url, protocols) {
this.url = url;
this.protocols = protocols;
this.readyState = 0;
this.binaryType = 'arraybuffer';
this.sent = [];
FakeWebSocket.instances.push(this);
}
send(data) {
this.sent.push(data);
}
open() {
this.readyState = FakeWebSocket.OPEN;
if (this.onopen) this.onopen();
}
emitMessage(data) {
if (this.onmessage) this.onmessage({ data });
}
close() {
this.readyState = 3;
if (this.onclose) this.onclose();
}
}
const loaded = loadScript(
'js/hydra_connection.js',
['HydraConnection', 'Proto', 'AuthRequestFields', 'SessionRequestFields',
'ClientMessageFields', 'grpcWebEncodeRequest'],
{
window: { innerWidth: 800, innerHeight: 600 },
sessionStorage,
localStorage,
WebSocket: FakeWebSocket,
fetch: async () => { throw new Error('fetch not configured'); },
}
);
return {
...loaded,
sessionStorage,
localStorage,
FakeWebSocket,
};
}
async function testSettingsPasswordMigration() {
const localStorage = createStorage();
const sessionStorage = createStorage();
localStorage.setItem('titan_settings', JSON.stringify({
worlds: [{ name: 'Alpha', host: 'mux.example', password: 'secret' }]
}));
const { exports } = loadScript('js/settings.js', ['Settings'], {
localStorage,
sessionStorage,
});
const Settings = exports.Settings;
Settings.load();
const worlds = Settings.getWorlds();
assert.strictEqual(worlds[0].password, 'secret');
assert.ok(!JSON.parse(localStorage.getItem('titan_settings')).worlds[0].password);
assert.strictEqual(sessionStorage.getItem('titan_world_password:Alpha'), 'secret');
}
async function testHydraFreshAuthAndPromptHandling() {
const env = makeHydraEnv();
const { HydraConnection, Proto } = env.exports;
const calls = [];
const AuthResponseFields = {
success: { num: 1, type: 'bool' },
session_id: { num: 2, type: 'string' },
error: { num: 3, type: 'string' },
};
const GameOutputFields = {
text: { num: 1, type: 'string' },
end_of_record: { num: 5, type: 'bool' },
};
const ServerMessageFields = {
game_output: { num: 1, type: 'message', fields: GameOutputFields },
gmcp: { num: 2, type: 'message', fields: {
package: { num: 1, type: 'string' },
json: { num: 2, type: 'string' },
} },
};
const ClientMessageDecode = {
4: { name: 'preferences', type: 'message', fields: {
1: { name: 'color_format', type: 'int32' },
2: { name: 'terminal_width', type: 'int32' },
3: { name: 'terminal_height', type: 'int32' },
4: { name: 'terminal_type', type: 'string' },
5: { name: 'session_id', type: 'string' },
} },
};
env.context.fetch = async (url) => {
calls.push(url);
if (url.endsWith('/Authenticate')) {
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async () => encodeGrpcWebResponse(Proto.encode({
success: true,
session_id: 'sess-1234',
}, AuthResponseFields)),
};
}
throw new Error('unexpected fetch ' + url);
};
const conn = new HydraConnection('World', 'hydra.example', 4201, true);
conn.username = 'alice';
conn.password = 'pw';
const lines = [];
const prompts = [];
let connected = false;
conn.onLine = line => lines.push(line);
conn.onPrompt = prompt => prompts.push(prompt);
conn.onConnect = () => { connected = true; };
const ok = await conn.connect();
assert.strictEqual(ok, true);
assert.strictEqual(connected, true);
assert.strictEqual(env.sessionStorage.getItem('hydra_session_World'), 'sess-1234');
assert.strictEqual(env.FakeWebSocket.instances.length, 1);
const ws = env.FakeWebSocket.instances[0];
ws.open();
const sent = Proto.decode(new Uint8Array(ws.sent[0]), ClientMessageDecode);
assert.strictEqual(sent.preferences.session_id, 'sess-1234');
assert.strictEqual(sent.preferences.terminal_type, 'Hydra-Web');
const msg = Proto.encode({
game_output: { text: 'hello world\nlook', end_of_record: true }
}, ServerMessageFields);
ws.emitMessage(msg);
assert.deepStrictEqual(lines, ['hello world']);
assert.deepStrictEqual(prompts, ['look']);
const gmcpMsg = Proto.encode({
gmcp: { package: 'Char.Vitals', json: '{"hp":34,"maxhp":50,"mana":12,"maxmana":20}' }
}, ServerMessageFields);
ws.emitMessage(gmcpMsg);
assert.ok(lines.includes('[Vitals] HP 34/50 MP 12/20'));
}
async function testHydraSessionResumeSkipsAuthenticate() {
const env = makeHydraEnv();
const { HydraConnection, Proto } = env.exports;
const calls = [];
const GetSessionResponseFields = {
session_id: { num: 1, type: 'string' },
username: { num: 2, type: 'string' },
state: { num: 6, type: 'int32' },
};
env.sessionStorage.setItem('hydra_session_World', 'resume-1');
env.context.fetch = async (url) => {
calls.push(url);
if (url.endsWith('/GetSession')) {
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async () => encodeGrpcWebResponse(Proto.encode({
session_id: 'resume-1',
username: 'alice',
state: 1,
}, GetSessionResponseFields)),
};
}
throw new Error('unexpected fetch ' + url);
};
const conn = new HydraConnection('World', 'hydra.example', 4201, true);
conn.username = 'alice';
conn.password = 'pw';
const ok = await conn.connect();
assert.strictEqual(ok, true);
assert.strictEqual(conn.sessionId, 'resume-1');
assert.strictEqual(calls.length, 1);
assert.ok(calls[0].endsWith('/GetSession'));
}
async function testHydraFallbackSubscribePath() {
const env = makeHydraEnv();
const { HydraConnection, Proto } = env.exports;
const AuthResponseFields = {
success: { num: 1, type: 'bool' },
session_id: { num: 2, type: 'string' },
error: { num: 3, type: 'string' },
};
const GameOutputFields = {
text: { num: 1, type: 'string' },
end_of_record: { num: 5, type: 'bool' },
};
const calls = [];
env.context.fetch = async (url) => {
calls.push(url);
if (url.endsWith('/Authenticate')) {
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async () => encodeGrpcWebResponse(Proto.encode({
success: true,
session_id: 'sess-fallback',
}, AuthResponseFields)),
};
}
if (url.endsWith('/Subscribe')) {
const payload = Proto.encode({
text: 'stream line\nprompt',
end_of_record: true,
}, GameOutputFields);
const frame = new Uint8Array(5 + payload.length);
frame[0] = 0x00;
frame[1] = (payload.length >>> 24) & 0xFF;
frame[2] = (payload.length >>> 16) & 0xFF;
frame[3] = (payload.length >>> 8) & 0xFF;
frame[4] = payload.length & 0xFF;
frame.set(payload, 5);
let readCount = 0;
return {
ok: true,
status: 200,
statusText: 'OK',
body: {
getReader() {
return {
async read() {
readCount++;
if (readCount === 1) {
return { done: false, value: frame };
}
return new Promise(() => {});
}
};
}
}
};
}
throw new Error('unexpected fetch ' + url);
};
const conn = new HydraConnection('World', 'hydra.example', 4201, true);
conn.username = 'alice';
conn.password = 'pw';
const lines = [];
const prompts = [];
conn.onLine = line => lines.push(line);
conn.onPrompt = prompt => prompts.push(prompt);
await conn.connect();
const ws = env.FakeWebSocket.instances[0];
ws.close();
await flush();
assert.ok(calls.some(url => url.endsWith('/Subscribe')));
assert.ok(lines.includes('% [Hydra] WebSocket unavailable, falling back to gRPC-Web stream.'));
assert.ok(lines.includes('stream line'));
assert.deepStrictEqual(prompts, ['prompt']);
}
async function testHydraReconnectCreatesNewWebSocket() {
const env = makeHydraEnv();
const { HydraConnection, Proto } = env.exports;
const AuthResponseFields = {
success: { num: 1, type: 'bool' },
session_id: { num: 2, type: 'string' },
error: { num: 3, type: 'string' },
};
env.context.fetch = async (url) => {
if (url.endsWith('/Authenticate')) {
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async () => encodeGrpcWebResponse(Proto.encode({
success: true,
session_id: 'sess-reconnect',
}, AuthResponseFields)),
};
}
throw new Error('unexpected fetch ' + url);
};
const conn = new HydraConnection('World', 'hydra.example', 4201, true);
conn.username = 'alice';
conn.password = 'pw';
const lines = [];
conn.onLine = line => lines.push(line);
await conn.connect();
const firstWs = env.FakeWebSocket.instances[0];
firstWs.open();
firstWs.close();
await flush();
assert.ok(lines.some(line => line.includes('Stream lost, reconnecting')));
assert.strictEqual(env.FakeWebSocket.instances.length, 2);
}
// #1788: oversized IAC SB is discarded; following line still parses.
async function testTelnetOversizedSubnegotiationDiscarded() {
const { TelnetParser } = loadScript('js/telnet.js', ['TelnetParser']).exports;
const sent = [];
const lines = [];
const parser = new TelnetParser(bytes => sent.push(Array.from(bytes)));
parser.onLine = line => lines.push(line);
const data = [255, 250, 201]; // IAC SB GMCP
for (let i = 0; i < 5000; i++) data.push(0x41);
data.push(255, 240); // IAC SE
for (const ch of 'ok\n') data.push(ch.charCodeAt(0));
parser.process(new Uint8Array(data));
assert.deepStrictEqual(lines, ['ok']);
}
// #1889: MCP multiline reassembly is bounded (pending count + bytes).
async function testMcpMultilinePendingCaps() {
const { McpParser } = loadScript('js/mcp.js', ['McpParser']).exports;
const diags = [];
const p = new McpParser();
p.onDiagnostic = m => diags.push(m);
p.sessionKey = 'key';
// Flood unique unterminated multiline starts past MCP_MAX_PENDING_MESSAGES.
const N = 40;
for (let i = 0; i < N; i++) {
p.processLine(
`#$#dns-org-mud-moo-simpleedit-content key reference: r content*: "" _data-tag: t${i}`);
}
const pendingCount = Object.keys(p.pending).length;
assert.ok(pendingCount <= 32,
'pending multiline count must be capped, got ' + pendingCount);
assert.ok(diags.some(d => /evict|capacity|pending/i.test(d)),
'should diagnose pending eviction');
// Per-message growth: one tag, keep appending until dropped.
const p2 = new McpParser();
const diags2 = [];
p2.onDiagnostic = m => diags2.push(m);
p2.sessionKey = 'key';
p2.processLine(
'#$#dns-org-mud-moo-simpleedit-content key reference: r content*: "" _data-tag: fat');
assert.ok(p2.pending.fat, 'fat tag should start pending');
// 256 KiB limit; each continuation ~1 KiB.
const chunk = 'x'.repeat(1024);
for (let i = 0; i < 300; i++) {
p2.processLine(`#$#* fat content: ${chunk}`);
if (!p2.pending.fat) break;
}
assert.strictEqual(p2.pending.fat, undefined,
'oversized multiline must be dropped');
assert.ok(diags2.some(d => /size limit|dropped/i.test(d)),
'should diagnose per-message size drop');
// Happy path still reassembles.
const p3 = new McpParser();
let edit = null;
p3.sessionKey = 'key';
p3.onEditRequest = (ref, name, type, content) => {
edit = { ref, name, type, content };
};
p3.processLine(
'#$#dns-org-mud-moo-simpleedit-content key reference: R name: N type: string-list content*: "" _data-tag: ok');
p3.processLine('#$#* ok content: line1');
p3.processLine('#$#* ok content: line2');
p3.processLine('#$#: ok');
assert.ok(edit, 'complete multiline should dispatch edit');
assert.strictEqual(edit.content, 'line1\nline2');
assert.strictEqual(Object.keys(p3.pending).length, 0);
}
async function main() {
const tests = [
testSettingsPasswordMigration,
testHydraFreshAuthAndPromptHandling,
testHydraSessionResumeSkipsAuthenticate,
testHydraFallbackSubscribePath,
testHydraReconnectCreatesNewWebSocket,
testTelnetOversizedSubnegotiationDiscarded,
testMcpMultilinePendingCaps,
];
for (const fn of tests) {
await fn();
console.log('PASS', fn.name);
}
}
main().catch(err => {
console.error('FAIL', err && err.stack ? err.stack : err);
process.exit(1);
});