diff --git a/package-lock.json b/package-lock.json index 52a8d5a..fdc49b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@syscoin/syscoin-js": "git+https://github.com/syscoin/syscoind-rpc.git#637a1de10522d1ce34a4e0f283c59b9d55bde814", "axios": "^1.15.2", "better-sqlite3": "^12.9.0", - "body-parser": "^2.2.2", + "body-parser": "2.4.0", "cookie-parser": "^1.4.7", "cors": "2.8.5", "csv-parser": "3.0.0", @@ -31,7 +31,7 @@ "pretty-ms": "6.0.1", "syscoinjs-lib": "^1.0.274", "websocket": "1.0.34", - "zod": "^4.3.6" + "zod": "4.6.7" }, "devDependencies": { "@types/jest": "^30.0.0", @@ -2976,20 +2976,20 @@ "license": "MIT" }, "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.4.0.tgz", + "integrity": "sha512-Ifk1KcLrxaGt1occQagQNFNNOqRt1LFuehEnHHzB8Z6cRgIxRU1TBaiIsUiCdA+gy5PL7Y1IX31DNKkRmxtXjQ==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", - "content-type": "^1.0.5", + "content-type": "^2.1.0", "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.3", "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" + "qs": "^6.16.1", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { "node": ">=18" @@ -2999,6 +2999,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3017,9 +3030,9 @@ } }, "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -4548,9 +4561,9 @@ "license": "MIT" }, "node_modules/express/node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -4561,7 +4574,7 @@ "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", - "qs": "~6.15.1", + "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" @@ -4572,12 +4585,13 @@ } }, "node_modules/express/node_modules/body-parser/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -7727,12 +7741,16 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/memdown": { @@ -8563,12 +8581,13 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -8949,13 +8968,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -8967,12 +8987,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -9522,17 +9543,34 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/type-is/node_modules/mime-db": { @@ -9960,9 +9998,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 62b563f..15f3ff8 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@syscoin/syscoin-js": "git+https://github.com/syscoin/syscoind-rpc.git#637a1de10522d1ce34a4e0f283c59b9d55bde814", "axios": "^1.15.2", "better-sqlite3": "^12.9.0", - "body-parser": "^2.2.2", + "body-parser": "2.4.0", "cookie-parser": "^1.4.7", "cors": "2.8.5", "csv-parser": "3.0.0", @@ -54,7 +54,7 @@ "pretty-ms": "6.0.1", "syscoinjs-lib": "^1.0.274", "websocket": "1.0.34", - "zod": "^4.3.6" + "zod": "4.6.7" }, "devDependencies": { "@types/jest": "^30.0.0", diff --git a/tests/compressedJson.test.js b/tests/compressedJson.test.js new file mode 100644 index 0000000..18d97d0 --- /dev/null +++ b/tests/compressedJson.test.js @@ -0,0 +1,25 @@ +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { z } = require('zod'); + +// Small bounded payloads reproduce GHSA-27r3-jxmh-p828. A subprocess keeps a +// regression to the vulnerable decompressor from crashing the Jest runner. +test.each(['production', 'standalone'])('%s parser survives compressed trailing bytes and rejects malformed gzip', (mode) => { + const result = spawnSync(process.execPath, [path.join(__dirname, 'helpers/compressedJsonChild.cjs'), mode], { + timeout: 10000, encoding: 'utf8', maxBuffer: 64 * 1024, + env: { ...process.env, NODE_ENV: 'test', RECOVERY_ALERTS_ENABLED: 'false' }, + }); + expect(result.error).toBeUndefined(); + if (result.status !== 0) throw new Error(result.stderr || `Child exited with ${result.signal || result.status}`); + expect(result.signal).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual({ mode, statuses: [401, 401, 400, 401] }); +}); + +test('oversized constrained arrays reject before validating their elements', () => { + let visited = 0; + const schema = z.array(z.custom(() => { visited++; return false; })).max(2); + const result = schema.safeParse([1, 2, 3, 4]); + expect(result.success).toBe(false); + expect(result.error.issues[0].code).toBe('too_big'); + expect(visited).toBe(0); +}); diff --git a/tests/helpers/compressedJsonChild.cjs b/tests/helpers/compressedJsonChild.cjs new file mode 100644 index 0000000..626e039 --- /dev/null +++ b/tests/helpers/compressedJsonChild.cjs @@ -0,0 +1,92 @@ +'use strict'; + +// Isolate the process-crash regression from Jest. Only an ephemeral loopback +// server, an in-memory database and the real auth router are used. +const assert = require('node:assert/strict'); +const http = require('node:http'); +const { once } = require('node:events'); +const { gzipSync, deflateSync } = require('node:zlib'); +const express = require('express'); +const bodyParser = require('body-parser'); +const cookieParser = require('cookie-parser'); +const { openDatabase } = require('../../lib/db'); +const { createMailer } = require('../../lib/mailer'); +const { buildServices, mountAuthAndVault, createApp } = require('../../lib/appFactory'); + +process.env.NODE_ENV = 'test'; +process.env.SYSNODE_AUTH_PEPPER = 'a'.repeat(64); +const db = openDatabase(':memory:'); +const mailer = createMailer({ transport: 'memory', from: 'test@example.com' }); +const mode = process.argv[2]; +let app; +if (mode === 'production') { + app = express(); + // server.js's production parser, followed by its actual auth route wiring. + app.use(bodyParser.json({ limit: '256kb' })); + app.use(cookieParser()); + mountAuthAndVault(app, { services: buildServices({ db, secureCookies: false }), mailer, disableRateLimit: true }); +} else if (mode === 'standalone') { + app = createApp({ db, mailer, secureCookies: false, disableRateLimit: true }).app; +} else { + throw new Error('unknown_test_mode'); +} + +// The deflate frame must finish before trailing request bytes arrive. This +// schedules the final write from the real parser's completion callback, as in +// the upstream GHSA regression, without intercepting uncaught exceptions. +let afterParse = null; +const parserLayer = app._router.stack.find((layer) => layer.name === 'jsonParser'); +assert(parserLayer, 'real JSON parser is mounted'); +const parser = parserLayer.handle; +parserLayer.handle = (req, res, next) => parser(req, res, (error) => { + if (afterParse) { const finish = afterParse; afterParse = null; finish(); } + next(error); +}); +// Suppress Express's default stderr stack; preserve the middleware status. +app.use((error, _req, res, _next) => res.status(error.status || 500).json({ error: 'invalid_body' })); + +async function main() { + const server = app.listen(0, '127.0.0.1'); + await once(server, 'listening'); + async function send({ encoding, first, trailing, path = '/auth/login', method = 'POST' }) { + return new Promise((resolve, reject) => { + const size = first ? first.length + (trailing?.length || 0) : 0; + const req = http.request({ hostname: '127.0.0.1', port: server.address().port, + path, method, agent: false, headers: first ? { + 'Content-Type': 'application/json', 'Content-Encoding': encoding, + 'Content-Length': size, + } : {} }, (res) => { + let body = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { body += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, body })); + }); + req.on('error', reject); + req.setTimeout(2000, () => req.destroy(new Error('compressed_request_timeout'))); + if (trailing) { + afterParse = () => req.end(trailing); + req.write(first); + } else req.end(first); + }); + } + try { + const loginBody = JSON.stringify({ email: 'nobody@example.com', authHash: '0'.repeat(64) }); + const deflate = await send({ encoding: 'deflate', + first: Buffer.concat([deflateSync(loginBody), Buffer.from([0])]), trailing: Buffer.alloc(1024) }); + assert.equal(deflate.status, 401); // Parsed credentials reached the real auth route. + const gzip = await send({ encoding: 'gzip', + first: Buffer.concat([gzipSync(loginBody), Buffer.from([0]), Buffer.alloc(100 * 1024, 0x58)]) }); + assert.equal(gzip.status, 401); + const malformed = await send({ encoding: 'gzip', first: Buffer.from([0x01, 0x02, 0x03]) }); + assert.equal(malformed.status, 400); + // Late decompressor errors must not crash or poison the next request. + await new Promise((resolve) => setTimeout(resolve, 200)); + const followup = await send({ method: 'GET', path: '/auth/me' }); + assert.equal(followup.status, 401); + process.stdout.write(JSON.stringify({ mode, statuses: [deflate.status, gzip.status, malformed.status, followup.status] })); + } finally { + await new Promise((resolve) => server.close(resolve)); + db.close(); + } +} +main().catch((error) => { console.error(error); process.exitCode = 1; });