mirror of
https://github.com/brazilofmux/tinymux
synced 2026-08-13 00:23:11 -04:00
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.
486 lines
16 KiB
JavaScript
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);
|
|
});
|