From: Justin Wind Date: Tue, 19 Aug 2025 17:31:52 +0000 (-0700) Subject: add crossOriginProtectionCheck helper method X-Git-Url: https://git.squeep.com/?a=commitdiff_plain;h=a84aa5f8c07ed55672a097429c4eb22525d841ee;p=squeep-api-dingus add crossOriginProtectionCheck helper method --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a60937..f82f0e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/dingus.js b/lib/dingus.js index 9d2655e..1152032 100644 --- a/lib/dingus.js +++ b/lib/dingus.js @@ -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; diff --git a/package.json b/package.json index 5821857..6fa50e7 100644 --- a/package.json +++ b/package.json @@ -14,12 +14,6 @@ "files": [ "lib/**" ], - "pre-commit": [ - "audit", - "eslint", - "coverage", - "coverage-check" - ], "engines": { "node": ">=14.13.1" }, diff --git a/test/lib/dingus.js b/test/lib/dingus.js index 77cf5a2..d26236b 100644 --- a/test/lib/dingus.js +++ b/test/lib/dingus.js @@ -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