- 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
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;
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