diff --git a/.gitignore b/.gitignore index ad46b30..59aff37 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,5 @@ typings/ # next.js build output .next + +sec-findings.md \ No newline at end of file diff --git a/lib/next.js b/lib/next.js index ed376f0..2c2ff90 100644 --- a/lib/next.js +++ b/lib/next.js @@ -10,6 +10,20 @@ * @param {Function} errorHandler - Error handler * @returns {*} Result of middleware execution */ + +/** + * Restore the original URL and path after leaving a nested router context. + * Safe to call multiple times; subsequent calls are no-ops. + */ +function restoreNestedUrl (req) { + if (req.preRouterUrl !== undefined) { + req.url = req.preRouterUrl + req.path = req.preRouterPath + delete req.preRouterUrl + delete req.preRouterPath + } +} + function next (middlewares, req, res, index, routers, defaultRoute, errorHandler) { // Fast path for end of middleware chain if (index >= middlewares.length) { @@ -26,6 +40,9 @@ function next (middlewares, req, res, index, routers, defaultRoute, errorHandler ? errorHandler(err, req, res) : next(middlewares, req, res, index + 1, routers, defaultRoute, errorHandler) } + // Expose the error handler so nested routers can bubble errors to the parent + // instead of being handled by their own default error handler. + step.errorHandler = errorHandler try { // Check if middleware is a router (has id) @@ -51,11 +68,21 @@ function next (middlewares, req, res, index, routers, defaultRoute, errorHandler } } - // Call router's lookup method - const result = middleware.lookup(req, res, step) - return result && typeof result.then === 'function' - ? result.catch(err => errorHandler(err, req, res)) - : result + try { + // Call router's lookup method + const result = middleware.lookup(req, res, step) + return result && typeof result.then === 'function' + ? result.catch(err => { + restoreNestedUrl(req) + return errorHandler(err, req, res) + }) + : result + } catch (err) { + // Sync error that escaped the nested router's own handling. + // Restore the parent URL context before invoking the error handler. + restoreNestedUrl(req) + return errorHandler(err, req, res) + } } // Regular middleware function diff --git a/lib/router/sequential.js b/lib/router/sequential.js index 864de3a..283e8a0 100644 --- a/lib/router/sequential.js +++ b/lib/router/sequential.js @@ -73,6 +73,30 @@ module.exports = (config = {}) => { const router = new Trouter() router.id = id + const _add = router.add.bind(router) + + /** + * Wrap router.add to normalize RegExp patterns. + * The 'g' (global) and 'y' (sticky) flags mutate lastIndex on exec/test, + * which causes alternating match/failure across requests when caching is + * disabled or when different paths are matched. Strip those flags while + * preserving case-insensitive, multiline, dotAll, unicode, etc. + */ + router.add = (method, pattern, ...handlers) => { + if (pattern instanceof RegExp && (pattern.global || pattern.sticky)) { + const safeFlags = pattern.flags.replace(/[gy]/g, '') + pattern = new RegExp(pattern.source, safeFlags) + } + return _add(method, pattern, ...handlers) + } + + // Trouter binds HTTP method shortcuts (get, post, ...) to the original add + // in its constructor. Rebind them so they use our normalized add wrapper. + const HTTP_METHODS = ['GET', 'HEAD', 'PATCH', 'POST', 'PUT', 'DELETE', 'OPTIONS'] + HTTP_METHODS.forEach(method => { + router[method.toLowerCase()] = router.add.bind(router, method) + }) + const _use = router.use /** @@ -127,6 +151,9 @@ module.exports = (config = {}) => { req.url ??= '/' req.originalUrl ??= req.url + // Hardening: ensure req.url is a string to avoid crashes from malformed/mock requests. + if (typeof req.url !== 'string') req.url = String(req.url) + // Parse query parameters using optimized utility queryparams(req, req.url) @@ -158,6 +185,25 @@ module.exports = (config = {}) => { middlewares = handlers } + // When this router is used as a nested router, the parent executor passes + // a step function that carries the parent's error handler. Use the parent's + // error handler so errors bubble up and are not silently handled by the + // nested router's own default error handler. + const activeErrorHandler = step?.errorHandler || errorHandler + + // Wrap the active error handler so URL restoration happens before the + // handler is invoked. This fixes state corruption when a nested router + // handler throws, calls next(err), or rejects asynchronously. + const errorHandlerWithCleanup = (err, req, res) => { + if (req.preRouterUrl !== undefined) { + req.url = req.preRouterUrl + req.path = req.preRouterPath + delete req.preRouterUrl + delete req.preRouterPath + } + return activeErrorHandler(err, req, res) + } + // Optimized parameter assignment with minimal overhead if (!req.params) { // Shallow-copy: the match (and its params) may be served from the LRU @@ -170,7 +216,7 @@ module.exports = (config = {}) => { Object.assign(req.params, params) } - return next(middlewares, req, res, 0, routers, defaultRoute, errorHandler) + return next(middlewares, req, res, 0, routers, defaultRoute, errorHandlerWithCleanup) } else { defaultRoute(req, res) } diff --git a/package.json b/package.json index f7c8d5a..42e9262 100644 --- a/package.json +++ b/package.json @@ -29,13 +29,13 @@ "homepage": "https://github.com/BackendStack21/0http#readme", "devDependencies": { "0http": "^4.4.0", - "@types/node": "^24.3.2", - "body-parser": "^2.2.0", + "@types/node": "^24.13.2", + "body-parser": "^2.3.0", "chai": "^6.0.1", "cross-env": "^10.0.0", "mitata": "^1.0.34", - "mocha": "^11.7.2", - "nyc": "^17.1.0", + "mocha": "^11.7.6", + "nyc": "^18.0.0", "supertest": "^7.1.4" }, "files": [ @@ -47,9 +47,14 @@ "lib/" ], "dependencies": { - "lru-cache": "^11.2.1", + "lru-cache": "^11.5.1", "regexparam": "^3.0.0", "trouter": "^4.0.0" }, + "overrides": { + "diff": "^9.0.0", + "js-yaml": "^4.2.0", + "serialize-javascript": "^7.0.6" + }, "types": "./index.d.ts" } \ No newline at end of file diff --git a/tests/nested-router-error.test.js b/tests/nested-router-error.test.js new file mode 100644 index 0000000..b244fd8 --- /dev/null +++ b/tests/nested-router-error.test.js @@ -0,0 +1,197 @@ +/* global describe, it */ +const expect = require('chai').expect +const request = require('supertest') +const sequential = require('../lib/router/sequential') +const zero = require('../index') + +describe('0http - Nested Router Error Handling', () => { + describe('req.url restoration on nested router errors', () => { + it('should restore req.url when a nested handler throws synchronously', (done) => { + let capturedUrl = null + let capturedOriginalUrl = null + + const errorHandler = (err, req, res) => { + expect(err).to.be.an('error') + capturedUrl = req.url + capturedOriginalUrl = req.originalUrl + res.statusCode = 500 + res.end('error') + } + + const parent = sequential({ errorHandler }) + const child = sequential() + child.get('/crash', (req, res) => { + throw new Error('boom') + }) + parent.use('/api', child) + + const req = { method: 'GET', url: '/api/crash', headers: {} } + const res = { statusCode: 200, finished: false, setHeader: () => {}, end: () => { res.finished = true } } + + parent.lookup(req, res) + + expect(capturedUrl).to.equal('/api/crash') + expect(capturedOriginalUrl).to.equal('/api/crash') + expect(req.url).to.equal('/api/crash') + expect(req.preRouterUrl).to.equal(undefined) + expect(res.statusCode).to.equal(500) + done() + }) + + it('should restore req.url when a nested handler calls next(err)', (done) => { + let capturedUrl = null + + const errorHandler = (err, req, res) => { + expect(err).to.be.an('error') + capturedUrl = req.url + res.statusCode = 500 + res.end('error') + } + + const parent = sequential({ errorHandler }) + const child = sequential() + child.get('/next-error', (req, res, next) => { + next(new Error('next error')) + }) + parent.use('/api', child) + + const req = { method: 'GET', url: '/api/next-error', headers: {} } + const res = { statusCode: 200, finished: false, setHeader: () => {}, end: () => { res.finished = true } } + + parent.lookup(req, res) + + expect(capturedUrl).to.equal('/api/next-error') + expect(req.url).to.equal('/api/next-error') + expect(req.preRouterUrl).to.equal(undefined) + done() + }) + + it('should restore req.url when a nested async handler rejects', (done) => { + let capturedUrl = null + + const errorHandler = (err, req, res) => { + expect(err).to.be.an('error') + capturedUrl = req.url + res.statusCode = 500 + res.end('error') + } + + const parent = sequential({ errorHandler }) + const child = sequential() + child.get('/async-crash', async (req, res) => { + throw new Error('async boom') + }) + parent.use('/api', child) + + const req = { method: 'GET', url: '/api/async-crash', headers: {} } + const res = { statusCode: 200, finished: false, setHeader: () => {}, end: () => { res.finished = true } } + + parent.lookup(req, res) + + setImmediate(() => { + expect(capturedUrl).to.equal('/api/async-crash') + expect(req.url).to.equal('/api/async-crash') + expect(req.preRouterUrl).to.equal(undefined) + done() + }) + }) + + it('should invoke the parent error handler, not the nested router default', (done) => { + let parentHandlerCalled = false + + const errorHandler = (err, req, res) => { + expect(err).to.be.an('error') + parentHandlerCalled = true + res.statusCode = 500 + res.end('parent handled') + } + + const parent = sequential({ errorHandler }) + const child = sequential() // uses default error handler + child.get('/crash', (req, res) => { + throw new Error('boom') + }) + parent.use('/api', child) + + const req = { method: 'GET', url: '/api/crash', headers: {} } + const res = { statusCode: 200, finished: false, setHeader: () => {}, end: (body) => { res._body = body; res.finished = true } } + + parent.lookup(req, res) + + expect(parentHandlerCalled).to.equal(true) + expect(res._body).to.equal('parent handled') + done() + }) + }) + + describe('full server integration', () => { + it('should return parent error response for nested router errors', (done) => { + const errorHandler = (err, req, res) => { + expect(err).to.be.an('error') + res.statusCode = 500 + res.end('parent-handled') + } + + const { router, server } = zero({ + router: sequential({ errorHandler }) + }) + + const child = sequential() + child.get('/crash', (req, res) => { + throw new Error('boom') + }) + router.use('/api', child) + + server.listen(0, () => { + const port = server.address().port + request(`http://127.0.0.1:${port}`) + .get('/api/crash') + .expect(500) + .end((err, res) => { + if (err) { + server.close(() => done(err)) + return + } + expect(res.text).to.equal('parent-handled') + server.close(done) + }) + }) + }) + + it('should not corrupt req.url in parent error handler logs', (done) => { + const loggedUrls = [] + + const errorHandler = (err, req, res) => { + expect(err).to.be.an('error') + loggedUrls.push(req.url) + res.statusCode = 500 + res.end('error') + } + + const { router, server } = zero({ + router: sequential({ errorHandler }) + }) + + const child = sequential() + child.get('/crash', (req, res) => { + throw new Error('boom') + }) + router.use('/api', child) + + server.listen(0, () => { + const port = server.address().port + request(`http://127.0.0.1:${port}`) + .get('/api/crash') + .expect(500) + .end((err) => { + if (err) { + server.close(() => done(err)) + return + } + expect(loggedUrls).to.deep.equal(['/api/crash']) + server.close(done) + }) + }) + }) + }) +}) diff --git a/tests/router-coverage.test.js b/tests/router-coverage.test.js index 3869fa6..695c2cc 100644 --- a/tests/router-coverage.test.js +++ b/tests/router-coverage.test.js @@ -188,6 +188,26 @@ describe('0http - Router Coverage', () => { expect(req.url).to.equal('/') }) + it('should coerce non-string URL to string in lookup without crashing', () => { + const router = require('../lib/router/sequential')() + + router.get('/test', (req, res, next) => { + res.called = true + next() + }) + + const req = { method: 'GET', url: 42 } + const res = { + end: () => {}, + statusCode: 200 + } + + // Should not throw; URL is stringified and normalized to start with '/' + router.lookup(req, res) + + expect(req.url).to.be.a('string') + }) + it('should handle undefined params', async () => { await request(baseUrl) .get('/empty-params') @@ -283,6 +303,57 @@ describe('0http - Router Coverage', () => { .expect(404) }) + it('should normalize RegExp global flag to avoid lastIndex corruption', () => { + const router = require('../lib/router/sequential')({ cacheSize: 0 }) + let hits = 0 + router.get(/^\/global-test$/g, (req, res, next) => { + hits++ + next() + }) + + for (let i = 0; i < 4; i++) { + const req = { method: 'GET', url: '/global-test' } + const res = { end: () => {}, statusCode: 200 } + router.lookup(req, res) + } + + expect(hits).to.equal(4) + }) + + it('should normalize RegExp sticky flag to avoid lastIndex corruption', () => { + const router = require('../lib/router/sequential')({ cacheSize: 0 }) + let hits = 0 + router.get(/^\/sticky-test$/y, (req, res, next) => { + hits++ + next() + }) + + for (let i = 0; i < 4; i++) { + const req = { method: 'GET', url: '/sticky-test' } + const res = { end: () => {}, statusCode: 200 } + router.lookup(req, res) + } + + expect(hits).to.equal(4) + }) + + it('should preserve safe RegExp flags while stripping global/sticky', () => { + const router = require('../lib/router/sequential')({ cacheSize: 0 }) + let hits = 0 + router.get(/^\/mixed-test$/gims, (req, res, next) => { + hits++ + next() + }) + + for (const url of ['/mixed-test', '/MIXED-TEST', '/mixed-test\nextra']) { + const req = { method: 'GET', url } + const res = { end: () => {}, statusCode: 200 } + router.lookup(req, res) + } + + expect(hits).to.equal(3) + }) + after(() => { server.close() }) @@ -462,5 +533,69 @@ describe('0http - Router Coverage', () => { expect(req.preRouterPath).to.equal('/prefix') expect(req.url).to.equal('/') }) + + it('should restore URL when nested router lookup throws synchronously', () => { + const req = { url: '/prefix/test', path: '/prefix/test' } + const res = {} + const nestedRouter = { + id: 'nested-router', + lookup: () => { + throw new Error('lookup failure') + } + } + const routers = { + 'nested-router': /^\/prefix/ + } + const defaultRoute = () => {} + const errorHandler = (err, req, res) => { + res.error = err.message + } + + next([nestedRouter], req, res, 0, routers, defaultRoute, errorHandler) + expect(res.error).to.equal('lookup failure') + expect(req.url).to.equal('/prefix/test') + expect(req.preRouterUrl).to.equal(undefined) + }) + + it('should restore URL when nested router lookup rejects asynchronously', async () => { + const req = { url: '/prefix/test', path: '/prefix/test' } + const res = {} + const nestedRouter = { + id: 'nested-router', + lookup: () => { + return Promise.reject(new Error('async lookup failure')) + } + } + const routers = { + 'nested-router': /^\/prefix/ + } + const defaultRoute = () => {} + const errorHandler = (err, req, res) => { + res.error = err.message + } + + await next([nestedRouter], req, res, 0, routers, defaultRoute, errorHandler) + expect(res.error).to.equal('async lookup failure') + expect(req.url).to.equal('/prefix/test') + expect(req.preRouterUrl).to.equal(undefined) + }) + + it('should expose the error handler on the step function for nested routers', () => { + const req = { url: '/test', path: '/test' } + const res = {} + let capturedStep = null + const nestedRouter = { + id: 'nested-router', + lookup: (req, res, step) => { + capturedStep = step + step() + } + } + const defaultRoute = () => {} + const errorHandler = () => {} + + next([nestedRouter], req, res, 0, {}, defaultRoute, errorHandler) + expect(capturedStep.errorHandler).to.equal(errorHandler) + }) }) }) diff --git a/tooling/deep-security-audit.js b/tooling/deep-security-audit.js new file mode 100644 index 0000000..790a5cb --- /dev/null +++ b/tooling/deep-security-audit.js @@ -0,0 +1,440 @@ +#!/usr/bin/env node +/** + * Deep Security Audit for 0http + * Probes edge cases and real-world exploit scenarios beyond the standard pentest. + */ + +const sequential = require('../lib/router/sequential') +const zero = require('../index') + +let total = 0 +let passed = 0 +let failed = 0 +const findings = [] + +function report (name, check, severity = 'INFO', details = '') { + total++ + if (check) { + passed++ + } else { + failed++ + findings.push({ name, severity, details }) + } + const marker = check ? '✓' : '✗' + console.log(` ${marker} [${severity.padEnd(8)}] ${name}${details ? ' — ' + details : ''}`) +} + +function resetProto () { + delete Object.prototype.polluted + delete Object.prototype.isAdmin + delete Object.prototype.role +} + +function createMockReq (method, reqUrl, headers = {}) { + return { method, url: reqUrl, headers } +} + +function createMockRes () { + const headers = {} + let body = '' + const res = { + statusCode: 200, + finished: false, + setHeader: (k, v) => { headers[k] = v }, + getHeader: (k) => headers[k], + removeHeader: (k) => { delete headers[k] }, + writeHead: (code, hdrs) => { + res.statusCode = code + if (hdrs) Object.assign(headers, hdrs) + }, + end: (chunk) => { + if (chunk) body += chunk + res.finished = true + }, + getHeaders: () => ({ ...headers }) + } + Object.defineProperty(res, '_body', { get: () => body, enumerable: true }) + return res +} + +// ═══════════════════════════════════════════════════════════════ +// 1. PROTOTYPE POLLUTION VIA ROUTE PARAMETERS +// ═══════════════════════════════════════════════════════════════ + +console.log('\n┌─────────────────────────────────────────────────┐') +console.log('│ 1. PROTOTYPE POLLUTION VIA ROUTE PARAMETERS │') +console.log('└─────────────────────────────────────────────────┘') + +;(() => { + resetProto() + const router = sequential() + router.get('/:__proto__', (req, res) => { + res.end('ok') + }) + const req = createMockReq('GET', '/pollute') + const res = createMockRes() + router.lookup(req, res) + report('PP-RP-1: __proto__ route param does not pollute Object.prototype', + !Object.prototype.polluted, 'CRITICAL', + Object.prototype.polluted ? 'Object.prototype.polluted set' : '') +})() + +;(() => { + resetProto() + const router = sequential() + router.get('/:constructor', (req, res) => { + res.end('ok') + }) + const req = createMockReq('GET', '/pollute') + const res = createMockRes() + router.lookup(req, res) + report('PP-RP-2: constructor route param does not pollute Object.prototype', + !Object.prototype.polluted, 'CRITICAL', + Object.prototype.polluted ? 'Object.prototype.polluted set' : '') +})() + +;(() => { + resetProto() + const router = sequential() + router.get('/:role/:__proto__', (req, res) => { + res.end('ok') + }) + const req = createMockReq('GET', '/user/pollute') + const res = createMockRes() + router.lookup(req, res) + report('PP-RP-3: __proto__ as second route param is safe', + !Object.prototype.polluted, 'CRITICAL', + Object.prototype.polluted ? 'Object.prototype.polluted set' : '') +})() + +;(() => { + resetProto() + const router = sequential() + // Regex route with named group __proto__ + router.get(/^\/(?<__proto__>[^/]+)$/, (req, res) => { + res.end('ok') + }) + const req = createMockReq('GET', '/pollute') + const res = createMockRes() + router.lookup(req, res) + report('PP-RP-4: __proto__ regex named group does not pollute', + !Object.prototype.polluted, 'CRITICAL', + Object.prototype.polluted ? 'Object.prototype.polluted set' : '') +})() + +// ═══════════════════════════════════════════════════════════════ +// 2. NESTED ROUTER STATE CORRUPTION ON ERROR +// ═══════════════════════════════════════════════════════════════ + +console.log('\n┌─────────────────────────────────────────────────┐') +console.log('│ 2. NESTED ROUTER STATE CORRUPTION ON ERROR │') +console.log('└─────────────────────────────────────────────────┘') + +;(() => { + const parent = sequential() + const child = sequential() + let capturedUrl = null + let capturedOriginalUrl = null + + child.get('/crash', (req, res) => { + throw new Error('boom') + }) + + parent.use('/api', child) + + const errorHandler = (err, req, res) => { + if (!err) console.error('expected an error but got none') + capturedUrl = req.url + capturedOriginalUrl = req.originalUrl + res.statusCode = 500 + res.end('error') + } + + // Need to recreate router with custom error handler + const router = sequential({ errorHandler }) + const nested = sequential() + nested.get('/crash', (req, res) => { + throw new Error('boom') + }) + router.use('/api', nested) + + const req = createMockReq('GET', '/api/crash') + const res = createMockRes() + router.lookup(req, res) + + report('NR-ERR-1: req.url restored after sync error in nested router', + capturedUrl === '/api/crash', 'HIGH', + `req.url was ${capturedUrl}, expected /api/crash`) + report('NR-ERR-2: req.originalUrl preserved after sync error in nested router', + capturedOriginalUrl === '/api/crash', 'MEDIUM', + `req.originalUrl was ${capturedOriginalUrl}`) +})() + +;(() => { + const errorHandler = (err, req, res) => { + if (!err) console.error('expected an error but got none') + req._capturedUrl = req.url + req._capturedPath = req.path + res.statusCode = 500 + res.end('error') + } + const router = sequential({ errorHandler }) + const nested = sequential() + nested.get('/async-crash', async (req, res) => { + throw new Error('async boom') + }) + router.use('/api', nested) + + const req = createMockReq('GET', '/api/async-crash') + const res = createMockRes() + router.lookup(req, res) + + // Async error handling needs a tick + setImmediate(() => { + report('NR-ERR-3: req.url restored after async error in nested router', + req._capturedUrl === '/api/async-crash', 'HIGH', + `req.url was ${req._capturedUrl}, expected /api/async-crash`) + }) +})() + +;(() => { + const errorHandler = (err, req, res) => { + if (!err) console.error('expected an error but got none') + req._capturedUrl = req.url + res.statusCode = 500 + res.end('error') + } + const router = sequential({ errorHandler }) + const nested = sequential() + nested.get('/next-error', (req, res, next) => { + next(new Error('next error')) + }) + router.use('/api', nested) + + const req = createMockReq('GET', '/api/next-error') + const res = createMockRes() + router.lookup(req, res) + + report('NR-ERR-4: req.url restored after next(error) in nested router', + req._capturedUrl === '/api/next-error', 'HIGH', + `req.url was ${req._capturedUrl}, expected /api/next-error`) +})() + +// ═══════════════════════════════════════════════════════════════ +// 3. CASE-SENSITIVITY / ACCESS CONTROL BYPASS +// ═══════════════════════════════════════════════════════════════ + +console.log('\n┌─────────────────────────────────────────────────┐') +console.log('│ 3. CASE-SENSITIVITY ROUTING │') +console.log('└─────────────────────────────────────────────────┘') + +;(() => { + const router = sequential() + let hits = 0 + router.get('/admin', (req, res) => { + hits++ + res.end('admin') + }) + + const cases = ['/admin', '/ADMIN', '/Admin', '/aDmIn'] + for (const c of cases) { + const req = createMockReq('GET', c) + const res = createMockRes() + router.lookup(req, res) + } + + report('CS-1: Routes are case-insensitive by default', + hits === 4, 'INFO', + `Matched ${hits}/4 cases — may be a security concern if case-sensitive paths are expected`) +})() + +// ═══════════════════════════════════════════════════════════════ +// 4. ROUTE PARAMETER OVERLAP WITH INTRINSIC PROPERTIES +// ═══════════════════════════════════════════════════════════════ + +console.log('\n┌─────────────────────────────────────────────────┐') +console.log('│ 4. ROUTE PARAM / INTRINSIC PROPERTY OVERLAP │') +console.log('└─────────────────────────────────────────────────┘') + +;(() => { + const router = sequential() + router.get('/:toString', (req, res) => { + res.end(typeof req.params.toString) + }) + const req = createMockReq('GET', '/hello') + const res = createMockRes() + router.lookup(req, res) + report('IP-1: toString route param does not break params object', + res._body === 'string', 'MEDIUM', + `Got ${res._body}`) +})() + +;(() => { + const router = sequential() + router.get('/:hasOwnProperty', (req, res) => { + res.end(typeof req.params.hasOwnProperty) + }) + const req = createMockReq('GET', '/hello') + const res = createMockRes() + router.lookup(req, res) + report('IP-2: hasOwnProperty route param does not break params object', + res._body === 'string', 'MEDIUM', + `Got ${res._body}`) +})() + +// ═══════════════════════════════════════════════════════════════ +// 5. CACHE KEY TYPE CONFUSION +// ═══════════════════════════════════════════════════════════════ + +console.log('\n┌─────────────────────────────────────────────────┐') +console.log('│ 5. CACHE KEY TYPE CONFUSION │') +console.log('└─────────────────────────────────────────────────┘') + +;(() => { + const router = sequential({ cacheSize: 100 }) + router.get('/test', (req, res) => res.end('ok')) + + let error = null + try { + const req = createMockReq(undefined, '/test') + const res = createMockRes() + router.lookup(req, res) + } catch (e) { + error = e.message + } + report('CK-1: Undefined method does not crash router', + !error, 'LOW', error ? `Crashed: ${error}` : '') +})() + +;(() => { + const router = sequential({ cacheSize: 100 }) + router.get('/test', (req, res) => res.end('ok')) + + let error = null + try { + const req = { method: 'GET', url: '/test', path: {} } + const res = createMockRes() + router.lookup(req, res) + } catch (e) { + error = e.message + } + report('CK-2: Non-string path does not crash router', + !error, 'LOW', error ? `Crashed: ${error}` : '') +})() + +// ═══════════════════════════════════════════════════════════════ +// 6. NESTED ROUTER URL REPLACEMENT EDGE CASES +// ═══════════════════════════════════════════════════════════════ + +console.log('\n┌─────────────────────────────────────────────────┐') +console.log('│ 6. NESTED ROUTER URL REPLACEMENT │') +console.log('└─────────────────────────────────────────────────┘') + +;(() => { + const parent = sequential() + const child = sequential() + let capturedUrl = null + + child.get('/item', (req, res) => { + capturedUrl = req.url + res.end('ok') + }) + + parent.use('/api/v1', child) + + const req = createMockReq('GET', '/api/v1/item') + const res = createMockRes() + parent.lookup(req, res) + + report('NR-URL-1: Static prefix stripped correctly', + capturedUrl === '/item', 'LOW', + `Got ${capturedUrl}`) +})() + +;(() => { + const parent = sequential() + const child = sequential() + let capturedUrl = null + + child.get('/item', (req, res) => { + capturedUrl = req.url + res.end('ok') + }) + + parent.use('/:version', child) + + const req = createMockReq('GET', '/v1/item') + const res = createMockRes() + parent.lookup(req, res) + + report('NR-URL-2: Dynamic prefix stripped correctly', + capturedUrl === '/item', 'LOW', + `Got ${capturedUrl}`) +})() + +// ═══════════════════════════════════════════════════════════════ +// 7. FULL-SERVER INTEGRATION: REAL HTTP REQUESTS +// ═══════════════════════════════════════════════════════════════ + +console.log('\n┌─────────────────────────────────────────────────┐') +console.log('│ 7. FULL-SERVER INTEGRATION │') +console.log('└─────────────────────────────────────────────────┘') + +;(() => { + const { router, server } = zero() + + const child = require('../lib/router/sequential')() + child.get('/crash', (req, res) => { + throw new Error('server boom') + }) + + router.use('/api', child) + + router.on = router.on || router.add + server.listen(0, () => { + const port = server.address().port + const http = require('http') + const req = http.get(`http://127.0.0.1:${port}/api/crash`, (res) => { + let body = '' + res.on('data', c => { body += c }) + res.on('end', () => { + report('FS-1: Server handles nested router sync error without crash', + res.statusCode === 500 && body === 'Internal Server Error', 'HIGH', + `Status ${res.statusCode}, body ${body}`) + server.close() + }) + }) + req.on('error', (err) => { + report('FS-1: Server handles nested router sync error without crash', + false, 'HIGH', `Request failed: ${err.message}`) + server.close() + }) + }) +})() + +// ═══════════════════════════════════════════════════════════════ +// 8. REPORT +// ═══════════════════════════════════════════════════════════════ + +setTimeout(() => { + console.log('\n\n┌─────────────────────────────────────────────────┐') + console.log('│ DEEP SECURITY AUDIT REPORT │') + console.log('└─────────────────────────────────────────────────┘') + console.log(`\n Total: ${total} | Passed: ${passed} | Failed: ${failed}`) + + if (findings.length > 0) { + console.log('\n Findings:') + for (const f of findings) { + console.log(` [${f.severity}] ${f.name}`) + if (f.details) console.log(` ${f.details}`) + } + } + + if (failed > 0) { + console.log('\n ❌ ISSUES FOUND') + process.exit(1) + } else { + console.log('\n ✅ ALL DEEP CHECKS PASSED') + process.exit(0) + } +}, 500) diff --git a/tooling/nested-router-error-poc.js b/tooling/nested-router-error-poc.js new file mode 100644 index 0000000..7b0d4d6 --- /dev/null +++ b/tooling/nested-router-error-poc.js @@ -0,0 +1,82 @@ +#!/usr/bin/env node +/** + * Proof of Concept: Nested router req.url state corruption on error + * + * When a handler inside a nested router throws (or calls next(err) or rejects), + * the parent router's cleanup middleware never runs. As a result, custom error + * handlers see a modified req.url instead of the original request URL. + */ + +const sequential = require('../lib/router/sequential') + +const errorHandler = (err, req, res) => { + console.log('Error handler saw err:', err.message) + console.log('Error handler saw req.url:', JSON.stringify(req.url)) + console.log('Error handler saw req.originalUrl:', JSON.stringify(req.originalUrl)) + console.log('Error handler saw req.preRouterUrl:', JSON.stringify(req.preRouterUrl)) + res.statusCode = 500 + res.end('Internal Server Error') +} + +const parent = sequential({ errorHandler }) +const child = sequential() + +child.get('/crash', (req, res) => { + console.log('Handler saw req.url:', JSON.stringify(req.url)) + throw new Error('boom') +}) + +parent.use('/api', child) + +console.log('=== Sync throw in nested router ===') +const req1 = { method: 'GET', url: '/api/crash', headers: {} } +const res1 = { + statusCode: 200, + finished: false, + setHeader: () => {}, + end: (chunk) => { res1.finished = true; if (chunk) res1._body = chunk } +} +parent.lookup(req1, res1) +console.log('After lookup req.url:', JSON.stringify(req1.url)) +console.log('Expected: "/api/crash"') +console.log() + +console.log('=== next(err) in nested router ===') +const req2 = { method: 'GET', url: '/api/next-error', headers: {} } +const res2 = { + statusCode: 200, + finished: false, + setHeader: () => {}, + end: (chunk) => { res2.finished = true; if (chunk) res2._body = chunk } +} +const child2 = sequential() +child2.get('/next-error', (req, res, next) => { + next(new Error('next error')) +}) +const parent2 = sequential({ errorHandler }) +parent2.use('/api', child2) +parent2.lookup(req2, res2) +console.log('After lookup req.url:', JSON.stringify(req2.url)) +console.log('Expected: "/api/next-error"') +console.log() + +console.log('=== Async rejection in nested router ===') +const req3 = { method: 'GET', url: '/api/async-crash', headers: {} } +const res3 = { + statusCode: 200, + finished: false, + setHeader: () => {}, + end: (chunk) => { res3.finished = true; if (chunk) res3._body = chunk } +} +const child3 = sequential() +child3.get('/async-crash', async (req, res) => { + throw new Error('async boom') +}) +const parent3 = sequential({ errorHandler }) +parent3.use('/api', child3) +parent3.lookup(req3, res3) + +setImmediate(() => { + console.log('After lookup req.url:', JSON.stringify(req3.url)) + console.log('Expected: "/api/async-crash"') +}) diff --git a/tooling/regex-audit.js b/tooling/regex-audit.js new file mode 100644 index 0000000..3508b88 --- /dev/null +++ b/tooling/regex-audit.js @@ -0,0 +1,230 @@ +#!/usr/bin/env node +/** + * Regex-specific security audit for 0http + */ + +const sequential = require('../lib/router/sequential') + +let total = 0 +let passed = 0 +let failed = 0 +const findings = [] + +function report (name, check, severity = 'INFO', details = '') { + total++ + if (check) { + passed++ + } else { + failed++ + findings.push({ name, severity, details }) + } + const marker = check ? '✓' : '✗' + console.log(` ${marker} [${severity.padEnd(8)}] ${name}${details ? ' — ' + details : ''}`) +} + +function createMockRes () { + return { + statusCode: 200, + finished: false, + setHeader: () => {}, + getHeader: () => undefined, + removeHeader: () => {}, + writeHead: (code) => { this.statusCode = code }, + end: function () { this.finished = true } + } +} + +console.log('\n┌─────────────────────────────────────────────────┐') +console.log('│ REGEX SECURITY AUDIT │') +console.log('└─────────────────────────────────────────────────┘') + +;(() => { + const router = sequential({ cacheSize: 0 }) + let hits = 0 + router.get(/^\/test$/g, (req, res) => { hits++; res.end('ok') }) + + const results = [] + for (let i = 0; i < 4; i++) { + const before = hits + const req = { method: 'GET', url: '/test', headers: {} } + const res = createMockRes() + router.lookup(req, res) + results.push(hits > before) + } + + report('REGEX-1: Global-flag regex matches consistently', + results.every(r => r), 'HIGH', + `Results: ${JSON.stringify(results)}`) +})() + +;(() => { + const router = sequential({ cacheSize: 0 }) + let hits = 0 + router.get(/^\/test$/y, (req, res) => { hits++; res.end('ok') }) + + const results = [] + for (let i = 0; i < 4; i++) { + const before = hits + const req = { method: 'GET', url: '/test', headers: {} } + const res = createMockRes() + router.lookup(req, res) + results.push(hits > before) + } + + report('REGEX-2: Sticky-flag regex matches consistently', + results.every(r => r), 'HIGH', + `Results: ${JSON.stringify(results)}`) +})() + +;(() => { + const router = sequential({ cacheSize: 0 }) + let hits = 0 + router.get(/^\/test$/i, (req, res) => { hits++; res.end('ok') }) + + const results = [] + for (const url of ['/test', '/TEST', '/Test']) { + const before = hits + const req = { method: 'GET', url, headers: {} } + const res = createMockRes() + router.lookup(req, res) + results.push(hits > before) + } + + report('REGEX-3: Case-insensitive regex matches all cases', + results.every(r => r), 'INFO', + `Results: ${JSON.stringify(results)}`) +})() + +;(() => { + const router = sequential({ cacheSize: 0 }) + let hits = 0 + router.get(/^\/admin$/, (req, res) => { hits++; res.end('ok') }) + + const results = [] + for (const url of ['/admin', '/sub/admin', '/admin/backup']) { + const before = hits + const req = { method: 'GET', url, headers: {} } + const res = createMockRes() + router.lookup(req, res) + results.push(hits > before) + } + + report('REGEX-4: Anchored regex does not match substrings', + results[0] && !results[1] && !results[2], 'LOW', + `Results: ${JSON.stringify(results)}`) +})() + +;(() => { + const router = sequential({ cacheSize: 0 }) + let hits = 0 + router.get(/admin/, (req, res) => { hits++; res.end('ok') }) + + const results = [] + for (const url of ['/admin', '/sub/admin', '/administrator']) { + const before = hits + const req = { method: 'GET', url, headers: {} } + const res = createMockRes() + router.lookup(req, res) + results.push(hits > before) + } + + report('REGEX-5: Unanchored regex matches substrings (user responsibility)', + results.every(r => r), 'INFO', + `Results: ${JSON.stringify(results)}`) +})() + +;(() => { + const router = sequential({ cacheSize: 0 }) + let body = '' + router.get(/^\/(?[^/]+)$/, (req, res) => { + body = JSON.stringify(req.params) + res.end('ok') + }) + const req = { method: 'GET', url: '/value', headers: {} } + const res = createMockRes() + router.lookup(req, res) + report('REGEX-6: Named group "constructor" does not pollute Object.prototype', + !Object.prototype.polluted, 'HIGH', + Object.prototype.polluted ? 'Object.prototype.polluted set' : `params: ${body}`) +})() + +;(() => { + const router = sequential({ cacheSize: 0 }) + let hits = 0 + // regexparam dist does not support custom inline patterns like :id([0-9]+); + // use an explicit RegExp route to validate numeric IDs. + router.get(/^\/user\/([0-9]+)$/, (req, res) => { hits++; res.end('ok') }) + + const results = [] + for (const url of ['/user/123', '/user/abc', '/user/']) { + const before = hits + const req = { method: 'GET', url, headers: {} } + const res = createMockRes() + router.lookup(req, res) + results.push(hits > before) + } + + report('REGEX-7: Explicit regex route matches only intended input', + results[0] && !results[1] && !results[2], 'LOW', + `Results: ${JSON.stringify(results)}`) +})() + +;(() => { + const router = sequential({ cacheSize: 0 }) + let hits = 0 + router.get('/files/*', (req, res) => { hits++; res.end('ok') }) + + const results = [] + for (const url of ['/files/report.pdf', '/files/../etc/passwd', '/files/.hidden']) { + const before = hits + const req = { method: 'GET', url, headers: {} } + const res = createMockRes() + router.lookup(req, res) + results.push(hits > before) + } + + report('REGEX-8: Wildcard routes match arbitrary file-like paths (user responsibility)', + results[0] && results[1] && results[2], 'INFO', + `Results: ${JSON.stringify(results)}`) +})() + +;(() => { + const router = sequential({ cacheSize: 0 }) + let hits = 0 + // This is the unsupported regexparam syntax; it silently falls back to [^/]+? + router.get('/user/:id([0-9]+)', (req, res) => { hits++; res.end('ok') }) + + const results = [] + for (const url of ['/user/123', '/user/abc']) { + const before = hits + const req = { method: 'GET', url, headers: {} } + const res = createMockRes() + router.lookup(req, res) + results.push(hits > before) + } + + report('REGEX-9: Inline :param(custom) patterns are not validated by regexparam', + results[0] && results[1], 'INFO', + `Results: ${JSON.stringify(results)} — use explicit RegExp routes if validation matters`) +})() + +console.log('\n\n┌─────────────────────────────────────────────────┐') +console.log('│ REGEX AUDIT REPORT │') +console.log('└─────────────────────────────────────────────────┘') +console.log(`\n Total: ${total} | Passed: ${passed} | Failed: ${failed}`) + +if (findings.length > 0) { + console.log('\n Findings:') + for (const f of findings) { + console.log(` [${f.severity}] ${f.name}`) + if (f.details) console.log(` ${f.details}`) + } +} + +if (failed > 0) { + console.log('\n ❌ ISSUES FOUND') + process.exit(1) +} else { + console.log('\n ✅ ALL CHECKS PASSED') + process.exit(0) +} diff --git a/tooling/type-confusion-audit.js b/tooling/type-confusion-audit.js new file mode 100644 index 0000000..e3477b0 --- /dev/null +++ b/tooling/type-confusion-audit.js @@ -0,0 +1,214 @@ +#!/usr/bin/env node +/** + * Type confusion and input validation audit for 0http + */ + +const sequential = require('../lib/router/sequential') + +let total = 0 +let passed = 0 +let failed = 0 +const findings = [] + +function report (name, check, severity = 'INFO', details = '') { + total++ + if (check) { + passed++ + } else { + failed++ + findings.push({ name, severity, details }) + } + const marker = check ? '✓' : '✗' + console.log(` ${marker} [${severity.padEnd(8)}] ${name}${details ? ' — ' + details : ''}`) +} + +function createMockRes () { + const res = { + statusCode: 200, + finished: false, + setHeader: () => {}, + getHeader: () => undefined, + removeHeader: () => {}, + writeHead: (code) => { res.statusCode = code }, + end: () => { res.finished = true } + } + return res +} + +console.log('\n┌─────────────────────────────────────────────────┐') +console.log('│ TYPE CONFUSION / INPUT VALIDATION │') +console.log('└─────────────────────────────────────────────────┘') + +;(() => { + const router = sequential() + router.get('/test', (req, res) => res.end('ok')) + let error = null + try { + const req = { method: 'GET', url: 12345, headers: {} } + router.lookup(req, createMockRes()) + } catch (e) { + error = e.message + } + report('TC-1: Numeric req.url does not crash router', + !error, 'LOW', error ? `Crashed: ${error}` : '') +})() + +;(() => { + const router = sequential() + router.get('/test', (req, res) => res.end('ok')) + let error = null + try { + const req = { method: 'GET', url: null, headers: {} } + router.lookup(req, createMockRes()) + } catch (e) { + error = e.message + } + report('TC-2: Null req.url is normalized to /', + !error, 'LOW', error ? `Crashed: ${error}` : '') +})() + +;(() => { + const router = sequential() + router.get('/test', (req, res) => res.end('ok')) + let error = null + try { + const req = { method: 'GET', url: { indexOf: () => 0, slice: () => '/test' }, headers: {} } + router.lookup(req, createMockRes()) + } catch (e) { + error = e.message + } + report('TC-3: Object with string-like methods does not crash router', + !error, 'LOW', error ? `Crashed: ${error}` : '') +})() + +;(() => { + const router = sequential({ cacheSize: 100 }) + router.get('/test', (req, res) => res.end('ok')) + let error = null + try { + const req = { method: { toString: () => 'GET' }, url: '/test', headers: {} } + router.lookup(req, createMockRes()) + } catch (e) { + error = e.message + } + report('TC-4: Method object with toString does not crash router', + !error, 'LOW', error ? `Crashed: ${error}` : '') +})() + +;(() => { + const router = sequential() + router.get('/test', (req, res) => res.end('ok')) + let error = null + try { + const req = { method: 'GET', url: '/test?x=' + 'a'.repeat(10 * 1024 * 1024), headers: {} } + router.lookup(req, createMockRes()) + } catch (e) { + error = e.message + } + report('TC-5: 10MB query string does not crash router', + !error, 'MEDIUM', error ? `Crashed: ${error}` : '') +})() + +;(() => { + const router = sequential() + router.get('/test', (req, res) => res.end('ok')) + let error = null + try { + const req = { method: 'GET', url: '/test?' + 'a=1&'.repeat(100000), headers: {} } + router.lookup(req, createMockRes()) + } catch (e) { + error = e.message + } + report('TC-6: 100K query parameters do not crash router', + !error, 'MEDIUM', error ? `Crashed: ${error}` : '') +})() + +;(() => { + const router = sequential() + router.get('/test', (req, res) => res.end('ok')) + let error = null + try { + const req = { method: 'GET', url: '/test', path: '/admin', headers: {} } + router.lookup(req, createMockRes()) + } catch (e) { + error = e.message + } + report('TC-7: Pre-set req.path is overwritten by router', + !error, 'LOW', error ? `Crashed: ${error}` : '') +})() + +;(() => { + const router = sequential() + let body = '' + router.get('/test#fragment', (req, res) => { + body = req.path + res.end('ok') + }) + const req = { method: 'GET', url: '/test#fragment', headers: {} } + router.lookup(req, createMockRes()) + report('TC-8: Fragment in URL is preserved as part of path', + body === '/test#fragment', 'INFO', + `path was ${body}`) +})() + +;(() => { + const router = sequential() + router.get('/test', (req, res) => res.end('ok')) + let error = null + try { + const req = { method: 'GET', url: '/test', headers: {}, params: { existing: 'value' } } + router.lookup(req, createMockRes()) + } catch (e) { + error = e.message + } + report('TC-9: Existing req.params does not crash router', + !error, 'LOW', error ? `Crashed: ${error}` : '') +})() + +;(() => { + const router = sequential() + let captured = null + router.get('/test/:existing', (req, res) => { + captured = req.params.existing + res.end('ok') + }) + const req = { method: 'GET', url: '/test/new', headers: {}, params: { existing: 'old' } } + router.lookup(req, createMockRes()) + report('TC-10: Existing req.params merged with route params', + captured === 'new', 'LOW', + `Got ${captured}`) +})() + +;(() => { + const router = sequential() + let error = null + try { + const req = { method: 'GET', url: '/test', headers: {}, params: '__proto__' } + router.lookup(req, createMockRes()) + } catch (e) { + error = e.message + } + report('TC-11: Non-object req.params handled safely', + !error, 'LOW', error ? `Crashed: ${error}` : '') +})() + +console.log('\n\n┌─────────────────────────────────────────────────┐') +console.log('│ TYPE CONFUSION AUDIT REPORT │') +console.log('└─────────────────────────────────────────────────┘') +console.log(`\n Total: ${total} | Passed: ${passed} | Failed: ${failed}`) + +if (findings.length > 0) { + console.log('\n Findings:') + for (const f of findings) { + console.log(` [${f.severity}] ${f.name}`) + if (f.details) console.log(` ${f.details}`) + } +} + +if (failed > 0) { + console.log('\n ❌ ISSUES FOUND') + process.exit(1) +} else { + console.log('\n ✅ ALL CHECKS PASSED') + process.exit(0) +}