add crossOriginProtectionCheck helper method v2.2-dev
authorJustin Wind <justin.wind+git@gmail.com>
Tue, 19 Aug 2025 17:31:52 +0000 (10:31 -0700)
committerJustin Wind <justin.wind+git@gmail.com>
Tue, 19 Aug 2025 17:32:29 +0000 (10:32 -0700)
CHANGELOG.md
lib/dingus.js
package.json
test/lib/dingus.js

index 8a60937a0ad92a975eaf91dae19c958d4465dd82..f82f0e0bbc6f621105ae12360b9a0947fcac32c1 100644 (file)
@@ -9,8 +9,10 @@ Releases and notable changes to this project are documented here.
 - support naming route paths, and rendering those named paths with parameter substitution as relative, absolute, or external links
 - helper for defining routes for static files (opinionated)
 - send Allow header on 405 Method Not Allowed responses
-- reference to Dingus added to context
-- RFC3230 digest header support
+- reference to Dingus instance added to handler contexts as non-enumerable property 'dingus'
+- RFC3230 and RFC 9530 digest header support
+- more headers, throw when accessing undefined header
+- crossOriginProtectionCheck method added for CSRF avoidance
 
 ## [v2.1.3] - 2025-03-28
 
index 9d2655ed22c44f5f228f5607f53cc0763c56e06a..1152032a9d6b74f6d6554e14bed33704589d6a91 100644 (file)
@@ -1042,6 +1042,57 @@ class Dingus {
     throw new ResponseError(Enum.ErrorResponse.InternalServerError);
   }
 
+
+  /**
+   * Modern CSRF checking for browser-submission endpoints.
+   * (see https://words.filippo.io/csrf/ )
+   * @param {http.ClientRequest} req request
+   * @param {string[]} trustedOrigins trusted origins
+   */
+  crossOriginProtectionCheck(req, trustedOrigins) {
+    const _scope = _fileScope('crossOriginProtectionCheck');
+
+    // allow safe methods: GET, HEAD, OPTIONS
+    if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
+      return;
+    }
+
+    const origin = req.getHeader(Enum.Header.Origin);
+    const secFetchSite = req.getHeader(Enum.Header.SecFetchSite);
+
+    // allow if no Origin nor Sec-Fetch-Site header
+    if (!origin && !secFetchSite) {
+      return;
+    }
+
+    // allow if Origin header matches trusted list, https://example.com
+    if (origin && (trustedOrigins ?? []).includes(origin)) {
+      return;
+    }
+
+    // if Sec-Fetch-Site header present
+    // allow if same-origin or none (user-initiated), otherwise reject
+    if (secFetchSite) {
+      if (['same-origin', 'none'].includes(secFetchSite)) {
+        return;
+      }
+      const problem = 'cross-origin request detected from Sec-Fetch-Site header';
+      this.logger.debug(_scope, problem, { req });
+      throw new ResponseError(Enum.ErrorResponse.Forbiddent, problem);
+    }
+
+    // allow if Origin header host/port matches Host header else reject
+    const host = req.getHeader(Enum.Header.Host);
+    const originUrl = new URL(origin);
+    if (originUrl.host === host) {
+      return;
+    }
+
+    const problem = 'cross-origin request detected or browser out of date: Sec-Fetch-Site header missing and Origin does not match Host';
+    this.logger.debug(_scope, problem, { req });
+    throw new ResponseError(Enum.ErrorResponse.Forbiddent, problem);
+  }
+
 }
 
 module.exports = Dingus;
index 58218574f354b9cc9eaf78f1b8edc52737a8f373..6fa50e709639411cdd3c4002dced0b34cffe0e0a 100644 (file)
   "files": [
     "lib/**"
   ],
-  "pre-commit": [
-    "audit",
-    "eslint",
-    "coverage",
-    "coverage-check"
-  ],
   "engines": {
     "node": ">=14.13.1"
   },
index 77cf5a2a8a0d7109075cb014070bf5285ad88b76..d26236bb2b1230ee6b7e5340148c376cf24bdcbd 100644 (file)
@@ -1317,4 +1317,61 @@ Content-Type: image/sgi
       assert(dingus.serveFile.called);
     });
   }); // handlerGetStaticFile
+
+  describe('crossOriginProtectionCheck', function () {
+    let req, trustedOrigins;
+    beforeEach(function () {
+      trustedOrigins = undefined;
+      req = {
+        getHeader: sinon.stub()
+          .onCall(0).returns() // Origin
+          .onCall(1).returns() // Sec-Fetch-Site
+          .onCall(2).returns(), // Host
+        method: 'POST',
+      };
+    });
+    it('allows safe methods (GET)', function () {
+      req.method = 'GET';
+      dingus.crossOriginProtectionCheck(req, trustedOrigins);
+    });
+    it('allows safe methods (HEAD)', function () {
+      req.method = 'HEAD';
+      dingus.crossOriginProtectionCheck(req, trustedOrigins);
+    });
+    it('allows safe methods (OPTIONS)', function () {
+      req.method = 'OPTIONS';
+      dingus.crossOriginProtectionCheck(req, trustedOrigins);
+    });
+    it('allows if neither Origin nor Sec-Fetch-Site', function () {
+      dingus.crossOriginProtectionCheck(req, trustedOrigins);
+    });
+    it('allows if Origin is trusted', function () {
+      trustedOrigins = ['https://example.com'];
+      req.getHeader.onCall(0).returns('https://example.com');
+      dingus.crossOriginProtectionCheck(req, trustedOrigins);
+    });
+    it('allows if Sec-Fetch-Site is valid (none)', function () {
+      req.getHeader.onCall(1).returns('none');
+      dingus.crossOriginProtectionCheck(req, trustedOrigins);
+    });
+    it('allows if Sec-Fetch-Site is valid (same-origin)', function () {
+      req.getHeader.onCall(1).returns('same-origin');
+      dingus.crossOriginProtectionCheck(req, trustedOrigins);
+    });
+    it('rejects if Sec-Fetch-Site is invalid (cross-site)', function () {
+      req.getHeader.onCall(1).returns('cross-site');
+      assert.throws(() => dingus.crossOriginProtectionCheck(req, trustedOrigins));
+    });
+    it('allows if Origin matches Host', function () {
+      req.getHeader.onCall(0).returns('https://wark:1080');
+      req.getHeader.onCall(2).returns('wark:1080');
+      dingus.crossOriginProtectionCheck(req, trustedOrigins);
+    });
+    it('rejects if Origin does not match Host', function () {
+      req.getHeader.onCall(0).returns('https://example.com');
+      req.getHeader.onCall(2).returns('wark:1080');
+      assert.throws(() => dingus.crossOriginProtectionCheck(req, trustedOrigins));
+    });
+  }); // crossOriginProtectionCheck
+
 }); // Dingus