Skip to content

fix: normalize all-slash mount paths in app.use to '/'#7286

Open
youcefzemmar wants to merge 1 commit into
expressjs:masterfrom
youcefzemmar:fix/app-use-path-does-not-mount-at
Open

fix: normalize all-slash mount paths in app.use to '/'#7286
youcefzemmar wants to merge 1 commit into
expressjs:masterfrom
youcefzemmar:fix/app-use-path-does-not-mount-at

Conversation

@youcefzemmar

Copy link
Copy Markdown
## What

Normalize string mount paths that are only slashes (`'/'`, `'//'`, `'///'`, …) to `'/'` inside `app.use()` before handing them to the underlying router.

Fixes #4557`app.use('//', subApp)` no longer takes a divergent path through the router that depends on `path-to-regexp` matching the empty pattern.

## Root cause

`router`'s `Layer` constructor sets a `/`-fast-path flag with `this.slash = path === '/' && opts.end === false`, then runs the pattern through `loosen()`:

```js
const TRAILING_SLASH_REGEXP = /\/+$/
// ...
return String(path).replace(TRAILING_SLASH_REGEXP, '')

For '//', loosen strips both slashes to ''. The layer's slash flag is false (because path !== '/'), so matching falls through to pathRegexp.match('', { end: false, trailing: true }). That matcher matches every request — the layer behaves like a wildcard prefix, just without the fast-path metadata. Mounting at '///' and longer reduces to the same case.

In the issue, the reporter mounted dev/test routes under '//' expecting them to be unreachable from /, but the layer matched anyway and exposed those routes.

Fix

One guard, right after app.use resolves the path arg:

if (typeof path === 'string' && /^\/+$/.test(path)) {
  path = '/';
}

The fix stays scoped to all-slash strings. Inner repeated slashes like '/foo//bar' are deliberately left alone — collapsing them is a broader behavior change, the same one that took PR #7054 out of scope.

Testing

  • npm test — 1250 passing, including the new should treat all-slash paths as a root mount case in test/app.use.js that exercises '//' and '///' end-to-end.
  • npm run lint — clean.

The new test locks in three things: / and /secret reach the sub-app, an unrelated path like /missing still 404s (i.e. the layer is not a global wildcard), and '///' normalizes the same way as '//'.

Notes

Root cause technically lives in router's loosen() (treating '//' as ''). Fixing it in express is a surface workaround — if router ever normalizes this itself, the guard here is harmless but redundant.

Fixes #4557.

Mounting with `app.use('//', subApp)` previously slipped past the
router's `/`-fast-path because `loosen('//')` strips trailing slashes
to `''`, leaving an empty pattern that `path-to-regexp` happily matches
against every request. The router then treats the layer as "any path"
without setting the `slash` fast-path flag — a behavior that diverges
from `app.use('/', subApp)` and surprised the reporter in expressjs#4557.

Collapse any all-slash string path (`'/'`, `'//'`, `'///'`, …) to `'/'`
in `app.use` before handing it to the router. Inner repeated slashes
(`'/foo//bar'`) are intentionally left alone — that is a broader
behavior change outside the scope of this issue.

Fixes expressjs#4557.
@youcefzemmar youcefzemmar marked this pull request as ready for review May 28, 2026 13:09

@notadev99 notadev99 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really thorough writeup — the trace through loosen() and the "surface workaround vs. fixing it in router" framing are exactly right, and the guard is correctly scoped (/^\/+$/ leaves /foo//bar alone). A few things:

Does the new test fail on main? Before the fix, '//' matched every request and routed into sub. Since sub only has / and /secret, it looks like /->sub-root, /secret->sub-secret, and /missing->404 would all pass even without the guard — meaning the test pins the new behavior but may not catch the regression. Is there a request whose status or body differs before vs. after? Asserting on that is what makes it a true regression test.

Array mount paths. app.use(['//'], sub) skips the typeof path === 'string' guard, so the all-slash exposure still exists for the array form. Intentionally out of scope, or should normalization run per element?

Resulting semantics. Normalizing '//' to '/' makes the sub-app reachable at root, whereas #4557's reporter expected those routes to be unreachable. This makes the behavior predictable rather than hidden, which seems right — just worth confirming root-mount is the intended semantics over erroring on an all-slash path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App.use(path, ...) Does Not Mount at '//'

2 participants