Skip to content

fix: enforce trailing slash in mount path for .use() layers#189

Open
DukeDeSouth wants to merge 1 commit intopillarjs:masterfrom
DukeDeSouth:fix/strict-routing-root-slash
Open

fix: enforce trailing slash in mount path for .use() layers#189
DukeDeSouth wants to merge 1 commit intopillarjs:masterfrom
DukeDeSouth:fix/strict-routing-root-slash

Conversation

@DukeDeSouth
Copy link

@DukeDeSouth DukeDeSouth commented Feb 8, 2026

Human View

Problem

When a middleware is mounted with a trailing slash (e.g. .use('/api/', handler)), requests without the trailing slash (e.g. GET /api) incorrectly match and are dispatched to the handler. This breaks strict routing in sub-routers because req.url is set to '/' regardless of whether the original request had a trailing slash.

This is a well-known 11-year-old bug confirmed by @dougwilson.

Root Cause

loosen() strips trailing slashes from mount paths for non-strict matching. This is correct for sub-path matching (e.g. /api/users should match mount /api/), but it also allows /api (without trailing slash) to match mount /api/. When this happens, trimPrefix() sets req.url to '/' (because the empty string is replaced with /), and the sub-router cannot distinguish between requests that originally had a trailing slash and those that didn't.

Fix

Store the original (un-loosened) mount path in Layer.originalPath. In trimPrefix(), after existing path validation, check if the original mount path ended with a trailing slash that was stripped by loosen(). If so, verify the request path also includes that trailing slash. If not, skip the layer.

The check is: layer.originalPath === layerPath + '/' (was trailing slash stripped?) AND !path.startsWith(layer.originalPath) (does request include it?).

Changes

  • lib/layer.js: Add this.originalPath = path to preserve original mount path (+1 line)
  • index.js: Add path boundary validation in trimPrefix() (+12 lines)
  • test/router.js: Update 1 existing test, add 9 new tests for comprehensive coverage (+201 lines)

Test Results

  • 703 passing (694 original, 9 new)
  • 0 failing
  • Standard lint: clean

Before/After

var router = Router({ strict: true })
router.get('/', handler)
app.use('/api/', router)
Request Before After
GET /api/ 200 200
GET /api 200 (bug) 404 (fixed)
GET /api/users 200 200

Breaking Change Note

This is an intentional behavior change for .use() with trailing-slash mount paths. Requests without the trailing slash will no longer match. This is consistent with the 5.x label on expressjs/express#2281. Users who relied on the old behavior can remove the trailing slash from their mount path (e.g. .use('/api', handler) instead of .use('/api/', handler)).

Fixes: expressjs/express#2281


AI View (DCCE Protocol v1.0)

Metadata

  • Generator: Claude (Anthropic) via M7 MCP methodology
  • Confidence: 0.95
  • M7 Cycle ID: cycle_fix-expressjs-2281-r_374157
  • Phases completed: INTAKE → DISCOVERY → ARCHITECTURE → SOLUTION → IMPACT → SIMULATION → IMPLEMENTATION
  • Documentation: 7 M7 artifacts produced (INTAKE.md, DISCOVERY.md, ARCHITECTURE.md, SOLUTION.md, IMPACT.md, SIMULATION.md, IMPLEMENTATION.md)

AI Contribution Summary

  • Root cause analysis through code tracing and reproduction
  • Solution design with verification matrix for 7+ scenarios
  • Risk assessment (0 critical, 2 major with mitigations)
  • Implementation with 9 comprehensive test cases
  • 3x full test suite runs for stability verification

Verification Steps Performed

  1. Reproduced bug on Express.js 5.2.1 (master)
  2. Traced execution path through router → Layer → trimPrefix
  3. Identified root cause in loosen() + trimPrefix interaction
  4. Prototyped 5+ approaches before selecting final solution
  5. Ran 694 existing tests (693 pass, 1 intentionally updated)
  6. Wrote 9 new tests covering: strict sub-routers, non-strict sub-routers, sub-paths, query strings, nested mounts, HTTP methods, baseUrl, direct handlers
  7. Verified fix in both pillarjs/router AND Express.js 5.2.1
  8. Ran stress tests: 3-level nesting, middleware chains, error handlers
  9. 3 consecutive full test suite runs: 703/703/703 passing
  10. Standard linter: clean

Human Review Guidance

  • Focus on the trimPrefix check in index.js — this is the core fix
  • Verify the breaking change is acceptable for the router package
  • Consider if parameterized mount paths (e.g. /:version/) should also be affected
  • The test/router.js changes show both the updated existing test and all new coverage

Made with M7 Cursor

When a middleware is mounted with a trailing slash (e.g. `.use('/api/', handler)`),
requests without the trailing slash (e.g. `GET /api`) should not be dispatched to
the handler. Previously, `loosen()` stripped the trailing slash from the mount path
for matching purposes, which caused requests without the trailing slash to match
and receive an artificial `'/'` as `req.url`, breaking strict routing in sub-routers.

This fix adds a check in `trimPrefix()` that compares the request path against the
original (un-loosened) mount path. When the original mount path ends with a trailing
slash that was stripped by `loosen()`, the request must also include that trailing
slash to proceed. Otherwise, the layer is skipped.

Fixes: expressjs/express#2281
Co-authored-by: Cursor <cursoragent@cursor.com>
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.

'/' route breaks strict routing

1 participant