diff --git a/README.md b/README.md index b87086f4..4b514dd2 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ Professional support for Rdiffweb is available by contacting [IKUS Soft](https:/ # Changelog +## 2.4.9 (2002-09-28) + +This releases include a security fix. If you are using an earlier version, you should upgrade to this release immediately. + +* Add `Cache-Control` and other security headers [CVE-2022-3292](https://nvd.nist.gov/vuln/detail/CVE-2022-3292) + ## 2.4.8 (2022-09-26) This releases include a security fix. If you are using an earlier version, you should upgrade to this release immediately. diff --git a/rdiffweb/controller/dispatch.py b/rdiffweb/controller/dispatch.py index 1483a981..98e9d1f0 100644 --- a/rdiffweb/controller/dispatch.py +++ b/rdiffweb/controller/dispatch.py @@ -30,14 +30,6 @@ from rdiffweb.core.rdw_helpers import unquote_url -def empty(): - @cherrypy.expose - def handler(): - return None - - return handler - - def poppath(*args, **kwargs): """ A decorator for _cp_dispatch @@ -122,6 +114,7 @@ def static(path): @cherrypy.expose @cherrypy.tools.auth_form(on=False) @cherrypy.tools.sessions(on=False) + @cherrypy.tools.secure_headers(on=False) def handler(*args, **kwargs): if cherrypy.request.method not in ('GET', 'HEAD'): return None diff --git a/rdiffweb/controller/tests/test_secure_headers.py b/rdiffweb/controller/tests/test_secure_headers.py index 8255828c..e0b2c3dc 100644 --- a/rdiffweb/controller/tests/test_secure_headers.py +++ b/rdiffweb/controller/tests/test_secure_headers.py @@ -115,3 +115,69 @@ def test_clickjacking_defense(self): # Then the request is accepted with 200 OK self.assertStatus(200) self.assertHeaderItemValue('X-Frame-Options', 'DENY') + + def test_no_cache(self): + # Given a POST request made to rdiffweb + # When the request is made without an origin + self.getPage('/') + # Then the request is accepted with 200 OK + self.assertStatus(200) + self.assertHeaderItemValue('Cache-control', 'no-cache') + self.assertHeaderItemValue('Cache-control', 'no-store') + self.assertHeaderItemValue('Cache-control', 'must-revalidate') + self.assertHeaderItemValue('Cache-control', 'max-age=0') + self.assertHeaderItemValue('Pragma', 'no-cache') + self.assertHeaderItemValue('Expires', '0') + + def test_no_cache_with_static(self): + # Given a POST request made to rdiffweb + # When the request is made without an origin + self.getPage('/static/default.css') + # Then the request is accepted with 200 OK + self.assertStatus(200) + self.assertNoHeader('Cache-control') + self.assertNoHeader('Pragma') + self.assertNoHeader('Expires') + + def test_referrer_policy(self): + # Given a POST request made to rdiffweb + # When the request is made without an origin + self.getPage('/') + # Then the request is accepted with 200 OK + self.assertStatus(200) + self.assertHeaderItemValue('Referrer-Policy', 'same-origin') + + def test_nosniff(self): + # Given a POST request made to rdiffweb + # When the request is made without an origin + self.getPage('/') + # Then the request is accepted with 200 OK + self.assertStatus(200) + self.assertHeaderItemValue('X-Content-Type-Options', 'nosniff') + + def test_xss_protection(self): + # Given a POST request made to rdiffweb + # When the request is made without an origin + self.getPage('/') + # Then the request is accepted with 200 OK + self.assertStatus(200) + self.assertHeaderItemValue('X-XSS-Protection', '1; mode=block') + + def test_content_security_policy(self): + # Given a POST request made to rdiffweb + # When the request is made without an origin + self.getPage('/') + # Then the request is accepted with 200 OK + self.assertStatus(200) + self.assertHeaderItemValue( + 'Content-Security-Policy', + "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'", + ) + + def test_strict_transport_security(self): + # Given a POST request made to rdiffweb + # When the request is made without an origin + self.getPage('/', headers=[('X-Forwarded-Proto', 'https')]) + # Then the request is accepted with 200 OK + self.assertStatus(200) + self.assertHeaderItemValue('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') diff --git a/rdiffweb/tools/secure_headers.py b/rdiffweb/tools/secure_headers.py index c5d2bb70..48ba1847 100644 --- a/rdiffweb/tools/secure_headers.py +++ b/rdiffweb/tools/secure_headers.py @@ -31,7 +31,14 @@ http.cookies.Morsel._reserved['samesite'] = 'SameSite' -def set_headers(): +def set_headers( + xfo='DENY', + no_cache=True, + referrer='same-origin', + nosniff=True, + xxp='1; mode=block', + csp="default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'", +): """ This tool provide CSRF mitigation. @@ -39,20 +46,28 @@ def set_headers(): * Define Cookies SameSite=Lax * Define Cookies Secure when https is detected * Validate `Origin` and `Referer` on POST, PUT, PATCH, DELETE + * Define Cache-Control by default + * Define Referrer-Policy to 'same-origin' Ref.: https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html """ - if cherrypy.request.method in ['POST', 'PUT', 'PATCH', 'DELETE']: - # Check if Origin matches our target. - origin = cherrypy.request.headers.get('Origin', None) - if origin and not origin.startswith(cherrypy.request.base): + request = cherrypy.request + response = cherrypy.serving.response + + # Check if Origin matches our target. + if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']: + origin = request.headers.get('Origin', None) + if origin and not origin.startswith(request.base): raise cherrypy.HTTPError(403, 'Unexpected Origin header') - response = cherrypy.serving.response + # Check if https is enabled + https = request.base.startswith('https') + # Define X-Frame-Options to avoid Clickjacking - response.headers['X-Frame-Options'] = 'DENY' + if xfo: + response.headers['X-Frame-Options'] = xfo # Enforce security on cookies cookie = response.cookie.get('session_id', None) @@ -61,10 +76,34 @@ def set_headers(): # https://github.com/cherrypy/cherrypy/issues/1767 # Force SameSite to Lax cookie['samesite'] = 'Lax' - # Check if https is enabled - https = cherrypy.request.base.startswith('https') if https: cookie['secure'] = 1 + # Add Cache-Control to avoid storing sensible information in Browser cache. + if no_cache: + response.headers['Cache-control'] = 'no-cache, no-store, must-revalidate, max-age=0' + response.headers['Pragma'] = 'no-cache' + response.headers['Expires'] = '0' + + # Add Referrer-Policy + if referrer: + response.headers['Referrer-Policy'] = referrer + + # Add X-Content-Type-Options to avoid browser to "sniff" to content-type + if nosniff: + response.headers['X-Content-Type-Options'] = 'nosniff' + + # Add X-XSS-Protection to enabled XSS protection + if xxp: + response.headers['X-XSS-Protection'] = xxp + + # Add Content-Security-Policy + if csp: + response.headers['Content-Security-Policy'] = csp + + # Add Strict-Transport-Security to force https use. + if https: + response.headers['Strict-Transport-Security'] = "max-age=31536000; includeSubDomains" + cherrypy.tools.secure_headers = cherrypy.Tool('before_request_body', set_headers, priority=71)