diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index b3ddd6b23ae..627a4c005a2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -13,6 +13,9 @@ on:
     paths-ignore:
       - '*.md'
 
+permissions:
+  contents: read
+
 # Cancel in progress workflows
 # in the scenario where we already had a run going for that PR/branch/tag but then triggered a new run
 concurrency:
@@ -25,14 +28,13 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v4
-      - name: Setup Node.js {{ matrix.node-version }}
+      - name: Setup Node.js
         uses: actions/setup-node@v4
         with:
           node-version: 'lts/*'
-          persist-credentials: false
 
       - name: Install dependencies
-        run: npm install --ignore-scripts --only=dev
+        run: npm install --ignore-scripts --include=dev
 
       - name: Run lint
         run: npm run lint
@@ -65,6 +67,8 @@ jobs:
           - "20"
           - "21"
           - "22"
+          - "23"
+          - "24"
         # Use supported versions of our testing tools under older versions of Node
         # Install npm in some specific cases where we need to
         include:
@@ -93,22 +97,25 @@ jobs:
             npm-i: "mocha@6.2.2 nyc@14.1.1 supertest@6.1.6"
 
           - node-version: "8"
-            npm-i: "mocha@7.2.0 nyc@14.1.1"
+            npm-i: "mocha@7.2.0 nyc@14.1.1 supertest@6.1.6"
 
           - node-version: "9"
-            npm-i: "mocha@7.2.0 nyc@14.1.1"
+            npm-i: "mocha@7.2.0 nyc@14.1.1 supertest@6.1.6"
 
           - node-version: "10"
-            npm-i: "mocha@8.4.0"
+            npm-i: "mocha@8.4.0 supertest@6.1.6"
 
           - node-version: "11"
-            npm-i: "mocha@8.4.0"
+            npm-i: "mocha@8.4.0 supertest@6.1.6"
 
           - node-version: "12"
-            npm-i: "mocha@9.2.2"
+            npm-i: "mocha@9.2.2 supertest@6.1.6"
 
           - node-version: "13"
-            npm-i: "mocha@9.2.2"
+            npm-i: "mocha@9.2.2 supertest@6.1.6"
+
+          - node-version: "15"
+            npm-i: "supertest@6.1.6"
 
     runs-on: ${{ matrix.os }}
     steps:
@@ -130,13 +137,13 @@ jobs:
           npm config set loglevel error
         shell: bash
 
-      - name: Install dependencies
-        run: npm install
-
       - name: Install Node version specific dev deps
         if: ${{ matrix.npm-i != '' }}
         run: npm install --save-dev ${{ matrix.npm-i }}
 
+      - name: Install dependencies
+        run: npm install
+
       - name: Remove non-test dependencies
         run: npm rm --silent --save-dev connect-redis
 
@@ -147,44 +154,39 @@ jobs:
 
       - name: Run tests
         shell: bash
-        run: |
-          npm run test-ci
-          cp coverage/lcov.info "coverage/${{ matrix.node-version }}.lcov"
-
-      - name: Collect code coverage
-        run: |
-          mv ./coverage "./${{ matrix.node-version }}"
-          mkdir ./coverage
-          mv "./${{ matrix.node-version }}" "./coverage/${{ matrix.node-version }}"
+        run: npm run test-ci
 
       - name: Upload code coverage
-        uses: actions/upload-artifact@v3
+        uses: actions/upload-artifact@v4
         with:
-          name: coverage
-          path: ./coverage
+          name: coverage-node-${{ matrix.node-version }}-${{ matrix.os }}
+          path: ./coverage/lcov.info
           retention-days: 1
 
   coverage:
     needs: test
     runs-on: ubuntu-latest
+    permissions:
+      contents: read
+      checks: write
     steps:
-    - uses: actions/checkout@v4
-
-    - name: Install lcov
-      shell: bash
-      run: sudo apt-get -y install lcov
-
-    - name: Collect coverage reports
-      uses: actions/download-artifact@v3
-      with:
-        name: coverage
-        path: ./coverage
-
-    - name: Merge coverage reports
-      shell: bash
-      run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./coverage/lcov.info
-
-    - name: Upload coverage report
-      uses: coverallsapp/github-action@master
-      with:
-        github-token: ${{ secrets.GITHUB_TOKEN }}
+      - uses: actions/checkout@v4
+
+      - name: Install lcov
+        shell: bash
+        run: sudo apt-get -y install lcov
+
+      - name: Collect coverage reports
+        uses: actions/download-artifact@v4
+        with:
+          path: ./coverage
+          pattern: coverage-node-*
+
+      - name: Merge coverage reports
+        shell: bash
+        run: find ./coverage -name lcov.info -exec printf '-a %q\n' {} \; | xargs lcov -o ./lcov.info
+
+      - name: Upload coverage report
+        uses: coverallsapp/github-action@v2
+        with:
+          file: ./lcov.info
diff --git a/History.md b/History.md
index 887a38f182d..c234f529cea 100644
--- a/History.md
+++ b/History.md
@@ -1,3 +1,26 @@
+4.21.2 / 2024-11-06
+==========
+
+  * deps: path-to-regexp@0.1.12
+    - Fix backtracking protection
+  * deps: path-to-regexp@0.1.11
+    - Throws an error on invalid path values
+
+4.21.1 / 2024-10-08
+==========
+
+  * Backported a fix for [CVE-2024-47764](https://nvd.nist.gov/vuln/detail/CVE-2024-47764)
+
+
+4.21.0 / 2024-09-11
+==========
+
+  * Deprecate `res.location("back")` and `res.redirect("back")` magic string
+  * deps: serve-static@1.16.2
+    * includes send@0.19.0
+  * deps: finalhandler@1.3.1
+  * deps: qs@6.13.0
+
 4.20.0 / 2024-09-10
 ==========
   * deps: serve-static@0.16.0
diff --git a/examples/params/index.js b/examples/params/index.js
index f3cd8457eb5..2a2eec9399d 100644
--- a/examples/params/index.js
+++ b/examples/params/index.js
@@ -32,7 +32,8 @@ app.param(['to', 'from'], function(req, res, next, num, name){
 // Load user by id
 
 app.param('user', function(req, res, next, id){
-  if (req.user = users[id]) {
+  req.user = users[id];
+  if (req.user) {
     next();
   } else {
     next(createError(404, 'failed to find user'));
diff --git a/lib/response.js b/lib/response.js
index 76b6b54a3b8..2b654f4c662 100644
--- a/lib/response.js
+++ b/lib/response.js
@@ -916,6 +916,7 @@ res.location = function location(url) {
 
   // "back" is an alias for the referrer
   if (url === 'back') {
+    deprecate('res.location("back"): use res.location(req.get("Referrer") || "/") and refer to https://dub.sh/security-redirect for best practices');
     loc = this.req.get('Referrer') || '/';
   } else {
     loc = String(url);
diff --git a/package.json b/package.json
index bffa70a6f1c..f0db3d8b9f2 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "express",
   "description": "Fast, unopinionated, minimalist web framework",
-  "version": "4.20.0",
+  "version": "4.21.2",
   "author": "TJ Holowaychuk <tj@vision-media.ca>",
   "contributors": [
     "Aaron Heckmann <aaron.heckmann+github@gmail.com>",
@@ -15,6 +15,10 @@
   "license": "MIT",
   "repository": "expressjs/express",
   "homepage": "http://expressjs.com/",
+  "funding": {
+    "type": "opencollective",
+    "url": "https://opencollective.com/express"
+  },
   "keywords": [
     "express",
     "framework",
@@ -33,27 +37,27 @@
     "body-parser": "1.20.3",
     "content-disposition": "0.5.4",
     "content-type": "~1.0.4",
-    "cookie": "0.6.0",
+    "cookie": "0.7.1",
     "cookie-signature": "1.0.6",
     "debug": "2.6.9",
     "depd": "2.0.0",
     "encodeurl": "~2.0.0",
     "escape-html": "~1.0.3",
     "etag": "~1.8.1",
-    "finalhandler": "1.2.0",
+    "finalhandler": "1.3.1",
     "fresh": "0.5.2",
     "http-errors": "2.0.0",
     "merge-descriptors": "1.0.3",
     "methods": "~1.1.2",
     "on-finished": "2.4.1",
     "parseurl": "~1.3.3",
-    "path-to-regexp": "0.1.10",
+    "path-to-regexp": "0.1.12",
     "proxy-addr": "~2.0.7",
-    "qs": "6.11.0",
+    "qs": "6.13.0",
     "range-parser": "~1.2.1",
     "safe-buffer": "5.2.1",
     "send": "0.19.0",
-    "serve-static": "1.16.0",
+    "serve-static": "1.16.2",
     "setprototypeof": "1.2.0",
     "statuses": "2.0.1",
     "type-is": "~1.6.18",
@@ -71,11 +75,11 @@
     "hbs": "4.2.0",
     "marked": "0.7.0",
     "method-override": "3.0.0",
-    "mocha": "10.2.0",
+    "mocha": "^6.2.2",
     "morgan": "1.10.0",
-    "nyc": "15.1.0",
+    "nyc": "^14.1.1",
     "pbkdf2-password": "1.2.1",
-    "supertest": "6.3.0",
+    "supertest": "^6.1.6",
     "vhost": "~3.0.2"
   },
   "engines": {
diff --git a/test/app.router.js b/test/app.router.js
index 8e427bd6dc7..fdf20f33d2f 100644
--- a/test/app.router.js
+++ b/test/app.router.js
@@ -55,6 +55,17 @@ describe('app.router', function(){
         .expect(200, done)
       })
 
+      it('should not support ' + method.toUpperCase() + ' without a path', function () {
+        if (method === 'get' || (method === 'query' && shouldSkipQuery(process.versions.node))) {
+          this.skip();
+        }
+        var app = express();
+
+        assert.throws(function () {
+          app[method](function (req, res) { });
+        });
+      });
+
       it('should reject numbers for app.' + method, function(){
         var app = express();
         assert.throws(app[method].bind(app, '/', 3), /Number/)
diff --git a/test/support/utils.js b/test/support/utils.js
index 5ad4ca98410..cac1e024e7c 100644
--- a/test/support/utils.js
+++ b/test/support/utils.js
@@ -77,10 +77,10 @@ function getMajorVersion(versionString) {
 }
 
 function shouldSkipQuery(versionString) {
-  // Skipping HTTP QUERY tests on Node 21, it is reported in http.METHODS on 21.7.2 but not supported
-  // update this implementation to run on supported versions of 21 once they exist
+  // Skipping HTTP QUERY tests below Node 22, QUERY wasn't fully supported by Node until 22
+  // we could update this implementation to run on supported versions of 21 once they exist
   // upstream tracking https://github.com/nodejs/node/issues/51562
   // express tracking issue: https://github.com/expressjs/express/issues/5615
-  return Number(getMajorVersion(versionString)) === 21
+  return Number(getMajorVersion(versionString)) < 22
 }
 
