Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,5 @@ typings/

# next.js build output
.next

sec-findings.md
37 changes: 32 additions & 5 deletions lib/next.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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
Expand Down
48 changes: 47 additions & 1 deletion lib/router/sequential.js
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
15 changes: 10 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand All @@ -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"
}
197 changes: 197 additions & 0 deletions tests/nested-router-error.test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
})
})
Loading
Loading