From 182e9e78b958326cc712574b39c8bb28fb9fd6f9 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 13 Feb 2026 18:44:07 -0800 Subject: [PATCH] test: add 3 edge-case tests for readFile binary, output() NaN/Infinity, loadAliases __proto__ safety Round 125: Tests for readFile returning garbled strings (not null) on binary files, output() handling undefined/NaN/Infinity as non-objects logged directly (and JSON.stringify converting NaN/Infinity to null in objects), and loadAliases with __proto__ key in JSON proving no prototype pollution occurs. Total: 935 tests, all passing. --- tests/lib/session-aliases.test.js | 70 ++++++++++++++++++++++++++++ tests/lib/utils.test.js | 76 +++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/tests/lib/session-aliases.test.js b/tests/lib/session-aliases.test.js index 7a4dc126..a9e5a2ee 100644 --- a/tests/lib/session-aliases.test.js +++ b/tests/lib/session-aliases.test.js @@ -1752,6 +1752,76 @@ function runTests() { 'limit > total should return all aliases'); })) passed++; else failed++; + // ── Round 125: loadAliases with __proto__ key in JSON — no prototype pollution ── + console.log('\nRound 125: loadAliases (__proto__ key in JSON — safe, no prototype pollution):'); + if (test('loadAliases with __proto__ alias key does not pollute Object prototype', () => { + // JSON.parse('{"__proto__":...}') creates a normal property named "__proto__", + // it does NOT modify Object.prototype. This is safe but worth documenting. + // The alias would be accessible via data.aliases['__proto__'] and iterable + // via Object.entries, but it won't affect other objects. + resetAliases(); + + // Write raw JSON string with __proto__ as an alias name. + // IMPORTANT: Cannot use JSON.stringify(obj) because {'__proto__':...} in JS + // sets the prototype rather than creating an own property, so stringify drops it. + // Must write the JSON string directly to simulate a maliciously crafted file. + const aliasesPath = aliases.getAliasesPath(); + const now = new Date().toISOString(); + const rawJson = `{ + "version": "1.0.0", + "aliases": { + "__proto__": { + "sessionPath": "/evil/path", + "createdAt": "${now}", + "title": "Prototype Pollution Attempt" + }, + "normal": { + "sessionPath": "/normal/path", + "createdAt": "${now}", + "title": "Normal Alias" + } + }, + "metadata": { "totalCount": 2, "lastUpdated": "${now}" } +}`; + fs.writeFileSync(aliasesPath, rawJson); + + // Load aliases — should NOT pollute prototype + const data = aliases.loadAliases(); + + // Verify __proto__ did NOT pollute Object.prototype + const freshObj = {}; + assert.strictEqual(freshObj.sessionPath, undefined, + 'Object.prototype should NOT have sessionPath (no pollution)'); + assert.strictEqual(freshObj.title, undefined, + 'Object.prototype should NOT have title (no pollution)'); + + // The __proto__ key IS accessible as a normal property + assert.ok(data.aliases['__proto__'], + '__proto__ key exists as normal property in parsed aliases'); + assert.strictEqual(data.aliases['__proto__'].sessionPath, '/evil/path', + '__proto__ alias data is accessible normally'); + + // Normal alias also works + assert.ok(data.aliases['normal'], + 'Normal alias coexists with __proto__ key'); + + // resolveAlias with '__proto__' — rejected by regex (underscores ok but __ prefix works) + // Actually ^[a-zA-Z0-9_-]+$ would ACCEPT '__proto__' since _ is allowed + const resolved = aliases.resolveAlias('__proto__'); + // If the regex accepts it, it should find the alias + if (resolved) { + assert.strictEqual(resolved.sessionPath, '/evil/path', + 'resolveAlias can access __proto__ alias (regex allows underscores)'); + } + + // Object.keys should enumerate __proto__ from JSON.parse + const keys = Object.keys(data.aliases); + assert.ok(keys.includes('__proto__'), + 'Object.keys includes __proto__ from JSON.parse (normal property)'); + assert.ok(keys.includes('normal'), + 'Object.keys includes normal alias'); + })) passed++; else failed++; + // Summary console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); diff --git a/tests/lib/utils.test.js b/tests/lib/utils.test.js index f7e8aace..fe067d5b 100644 --- a/tests/lib/utils.test.js +++ b/tests/lib/utils.test.js @@ -2232,6 +2232,82 @@ function runTests() { } })) passed++; else failed++; + // ── Round 125: readFile with binary content — returns garbled UTF-8, not null ── + console.log('\nRound 125: readFile (binary/non-UTF8 content — garbled, not null):'); + if (test('readFile with binary content returns garbled string (not null) because UTF-8 decode does not throw', () => { + // utils.js line 285: fs.readFileSync(filePath, 'utf8') — binary data gets UTF-8 decoded. + // Invalid byte sequences become U+FFFD replacement characters. The function does + // NOT return null for binary files (only returns null on ENOENT/permission errors). + // This means grepFile/countInFile would operate on corrupted content silently. + const tmpDir = fs.mkdtempSync(path.join(utils.getTempDir(), 'r125-binary-')); + const testFile = path.join(tmpDir, 'binary.dat'); + try { + // Write raw binary data (invalid UTF-8 sequences) + const binaryData = Buffer.from([0x00, 0x80, 0xFF, 0xFE, 0x48, 0x65, 0x6C, 0x6C, 0x6F]); + fs.writeFileSync(testFile, binaryData); + + const content = utils.readFile(testFile); + assert.ok(content !== null, + 'readFile should NOT return null for binary files'); + assert.ok(typeof content === 'string', + 'readFile always returns a string (or null for missing files)'); + // The string contains "Hello" (bytes 0x48-0x6F) somewhere in the garbled output + assert.ok(content.includes('Hello'), + 'ASCII subset of binary data should survive UTF-8 decode'); + // Content length may differ from byte length due to multi-byte replacement chars + assert.ok(content.length > 0, 'Non-empty content from binary file'); + + // grepFile on binary file — still works but on garbled content + const matches = utils.grepFile(testFile, 'Hello'); + assert.strictEqual(matches.length, 1, + 'grepFile finds "Hello" even in binary file (ASCII bytes survive)'); + + // Non-existent file — returns null (contrast with binary) + const missing = utils.readFile(path.join(tmpDir, 'no-such-file.txt')); + assert.strictEqual(missing, null, + 'Missing file returns null (not garbled content)'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + })) passed++; else failed++; + + // ── Round 125: output() with undefined, NaN, Infinity — non-object primitives logged directly ── + console.log('\nRound 125: output() (undefined/NaN/Infinity — typeof checks and JSON.stringify):'); + if (test('output() handles undefined, NaN, Infinity as non-objects — logs directly', () => { + // utils.js line 273: `if (typeof data === 'object')` — undefined/NaN/Infinity are NOT objects. + // typeof undefined → "undefined", typeof NaN → "number", typeof Infinity → "number" + // All three bypass JSON.stringify and go to console.log(data) directly. + const origLog = console.log; + const logged = []; + console.log = (...args) => logged.push(args); + try { + // undefined — typeof "undefined", logged directly + utils.output(undefined); + assert.strictEqual(logged[0][0], undefined, + 'output(undefined) logs undefined (not "undefined" string)'); + + // NaN — typeof "number", logged directly + utils.output(NaN); + assert.ok(Number.isNaN(logged[1][0]), + 'output(NaN) logs NaN directly (typeof "number", not "object")'); + + // Infinity — typeof "number", logged directly + utils.output(Infinity); + assert.strictEqual(logged[2][0], Infinity, + 'output(Infinity) logs Infinity directly'); + + // Object containing NaN — JSON.stringify converts NaN to null + utils.output({ value: NaN, count: Infinity }); + const parsed = JSON.parse(logged[3][0]); + assert.strictEqual(parsed.value, null, + 'JSON.stringify converts NaN to null inside objects'); + assert.strictEqual(parsed.count, null, + 'JSON.stringify converts Infinity to null inside objects'); + } finally { + console.log = origLog; + } + })) passed++; else failed++; + // Summary console.log('\n=== Test Results ==='); console.log(`Passed: ${passed}`);