fix: restore req/res prototypes when a mounted app hands control back - #7428
fix: restore req/res prototypes when a mounted app hands control back#7428lazerg wants to merge 4 commits into
Conversation
|
Great work on identifying the core prototype swap in The approach of capturing To make the fix completely airtight for maintainers and prevent future regressions, there are a few important edge cases that should also be covered in the test suite:
I've put together a PR in #7434 with these comprehensive test cases if you'd like to incorporate them or collaborate to help get this merged quickly! |
|
Added a test for the next(err) path (e418c6f): the parent's error middleware also gets the restored prototypes. The other two cases don't need separate assertions — req.app is defined on the same request object the prototype check already compares, and any custom methods on app.request/app.response come along with that same identity check. |
kilisamemarisaaa
left a comment
There was a problem hiding this comment.
I reproduced a compatibility regression in the callback wrapper added in lib/application.js.
The wrapper restores req and res to the prototypes that existed before app.handle. When an Express app is passed directly to a bare express.Router() via router.use(child), those incoming prototypes are Node's IncomingMessage/ServerResponse, not a parent Express application's prototypes. The downstream router middleware therefore loses the child's Express API entirely.
Reproduction (run from the PR head e418c6f0f4aa4e3a3f93a4ff61e379511e8ece05):
const express = require('./')
const request = require('supertest')
const child = express()
const router = express.Router()
child.use((req, res, next) => next())
router.use(child)
router.use((req, res) => res.end(JSON.stringify({
childProto: Object.getPrototypeOf(req) === child.request,
app: req.app === child,
hasIp: typeof req.ip === 'string',
resProto: Object.getPrototypeOf(res) === child.response
})))
const app = (req, res) => router.handle(req, res, err => {
if (err) { res.statusCode = 500; res.end(err.message) }
})
request(app).get('/').end((err, res) => {
if (err) throw err
console.log(res.status, res.text)
})PR head output: 200 {"childProto":false,"app":false,"hasIp":false,"resProto":false}. The same script on base 023767fe9872e029271df1418f73401bff20ff40 outputs all four values as true.
This is a regression beyond the reported Express-parent case: downstream middleware mounted on a bare router no longer has Express methods such as req.ip or res.json. The discussion on #7427 also states that router.use() treats an app as middleware and that middleware request/response modifications should persist.
Please narrow the restoration to a boundary that knows it is returning to an Express parent (or move the cleanup into the appropriate mounting layer), and add a bare-router regression test before merging.
|
Confirmed, thanks. Reproduced the exact output on e418c6f. app.handle now only restores prototypes when the "before" prototype already carries an .app (an own property that only exists on app.request/app.response). That's true when a real Express app already ran app.handle before this one, so restoring returns control to that Express parent's prototypes. It's false for a bare router, since no Express app.handle set that prototype, so nothing gets restored and the child's prototypes persist for downstream middleware. Fixed in 5f801a1, plus a regression test in test/app.use.js covering the bare-router case. Re-ran both prior mount-restore tests too, they still pass. |
kilisamemarisaaa
left a comment
There was a problem hiding this comment.
Re-reviewed the latest head 5f801a1 after the bare-router guard. On Windows/Node v24.12.0, the three prototype tests in test/app.use.js pass (3/3), and npm run lint passes. The full suite reaches 1262 passing with one unrelated Windows-only failure in the untouched express.static redirect-encoding test (test/express.static.js:510). The new reqProto.app check restores Express-parent prototypes while leaving child prototypes intact for a bare router, matching the intended mounting semantics. I found no further issues.
app.handleswaps thereq/resprototypes for its own, but onlyapp.use()puts them back, inside themounted_appclosure it wraps around a sub-app. Mount an app withrouter.use()instead and the swap is never undone, so every middleware that runs after the sub-app callsnext()readsreq.app,req.ip,req.secureandreq.hostnamethrough the sub-app's settings rather than the parent's.app.handlenow saves the prototypes it replaces and restores them before invoking the callback it was handed, so the cleanup happens on whichever path the app was mounted through. The issue suggested patchingrestore()insiderouter, but the swap originates here, and doing it here also covers any other caller that passes a callback without needing arouterrelease.This covers the prototype half of #7427 only. A router-mounted app still gets no
mountevent and so does not inherittrust proxy, which would needrouterto know about express apps.Fixes #7427