diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bde5402..cd733471 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ - Test with Python 3.13 - Use ruff instead of black/isort +## 4.3.6 / Unreleased + +- [#365](https://github.com/mar10/wsgidav/pull/366) + CORS: Access-Control-Expose-Headers is sent on the preflight response instead of the actual response (@padawan) + ## 4.3.5 / 2026-06-27 - Fix Blind SQL injection in WsgiDAV MySQL provider [CVE-2026-55509](https://github.com/mar10/wsgidav/security/advisories/GHSA-p6gw-4frg-j7jw) diff --git a/tests/test_cors.py b/tests/test_cors.py new file mode 100644 index 00000000..69deb698 --- /dev/null +++ b/tests/test_cors.py @@ -0,0 +1,87 @@ +# (c) 2009-2024 Martin Wendt and contributors; see WsgiDAV https://github.com/mar10/wsgidav +# Licensed under the MIT license: +# http://www.opensource.org/licenses/mit-license.php +""" +Unit tests for the CORS middleware (wsgidav.mw.cors). + +Uses webtest.TestApp to send fake requests through the WSGI stack. +""" + +import unittest + +import pytest + +from tests.util import create_test_folder +from wsgidav.fs_dav_provider import FilesystemProvider +from wsgidav.wsgidav_app import WsgiDAVApp + +try: + import webtest +except ImportError: + raise pytest.skip( + "Skip tests that require WebTest", allow_module_level=True + ) from None + +ORIGIN = "https://example.org" + + +class CorsTest(unittest.TestCase): + """Test the CORS middleware header placement.""" + + def setUp(self): + self.root_path = create_test_folder("wsgidav-cors-test") + provider = FilesystemProvider(self.root_path) + config = { + "provider_mapping": {"/": provider}, + "http_authenticator": {"domain_controller": None}, + "simple_dc": {"user_mapping": {"*": True}}, # anonymous access + # "verbose": 1, # changing the log level may break subsequent logger tests + "logging": {"enable_loggers": []}, + "property_manager": None, + "lock_storage": True, + "cors": { + "allow_origin": "*", + "allow_methods": "GET, HEAD, OPTIONS, PROPFIND", + "allow_headers": "Authorization, Content-Type, Depth", + "expose_headers": "WWW-Authenticate", + "allow_credentials": True, + }, + } + self.app = webtest.TestApp(WsgiDAVApp(config)) + + def tearDown(self): + del self.app + + def test_expose_headers_on_actual_response(self): + """`Access-Control-Expose-Headers` must be sent on the actual response. + + Per the Fetch standard it applies to a CORS request that is *not* a + preflight request, so cross-origin script can read the listed header. + """ + res = self.app.get("/", headers={"Origin": ORIGIN}, status=200) + self.assertEqual(res.headers.get("Access-Control-Allow-Origin"), "*") + self.assertEqual( + res.headers.get("Access-Control-Expose-Headers"), + "WWW-Authenticate", + "Access-Control-Expose-Headers must be present on the actual response", + ) + + def test_expose_headers_not_on_preflight(self): + """`Access-Control-Expose-Headers` is meaningless on the preflight. + + The preflight only carries Allow-Methods / Allow-Headers / Max-Age. + """ + res = self.app.options( + "/", + headers={ + "Origin": ORIGIN, + "Access-Control-Request-Method": "PROPFIND", + }, + status="*", + ) + # Sanity: this really is a handled preflight. + self.assertIsNotNone(res.headers.get("Access-Control-Allow-Methods")) + self.assertIsNone( + res.headers.get("Access-Control-Expose-Headers"), + "Access-Control-Expose-Headers must not be sent on the preflight", + ) diff --git a/tests/test_logging b/tests/test_logging deleted file mode 100644 index c5f576bd..00000000 --- a/tests/test_logging +++ /dev/null @@ -1,59 +0,0 @@ -import logging - -logger = logging.getLogger("wsgidav") - -# _logger = logging.getLogger(__name__) - -# logging.basicConfig(level=logging.DEBUG, -# format='%(asctime)s %(name)-15s %(levelname)-8s %(message)s', -# # datefmt='%Y-%m-%d %H:%M:%S', -# ) - -# _logger.addHandler(logging.NullHandler()) -# _logger2 = logging.getLogger("wsgidav.server") -# -# -# def test_logger(logger, msg=""): -# logger.debug("Debug {}".format(msg)) -# logger.info("Info {}".format(msg)) -# logger.warn("Warn {}".format(msg)) -# logger.error("Error {}".format(msg)) -# -# -# def setLogger(): -# pass -# -# test_logger(_logger, "1") -# test_logger(_logger2, "2") -# -# _logger.setLevel(logging.WARN) -# test_logger(_logger, "1w") -# test_logger(_logger2, "2w") - -from cheroot import wsgi - -from wsgidav.wsgidav_app import WsgiDAVApp -from wsgidav.fs_dav_provider import FilesystemProvider - -provider = FilesystemProvider("/Users/martin/pub") -config = { - "host": "0.0.0.0", - "port": 8080, - # "mount_path": "/dav", - "provider_mapping": {"/": provider}, - "verbose": 3, -} - -app = WsgiDAVApp(config) - -server_args = { - "bind_addr": (config["host"], config["port"]), - "wsgi_app": app, -} -server = wsgi.Server(**server_args) -try: - server.start() -except KeyboardInterrupt: - print("Received Ctrl-C, stopping...") -finally: - server.stop() diff --git a/tests/test_util.py b/tests/test_util.py index 77c598ee..999c1d93 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -229,6 +229,11 @@ def testDefault(self): """By default, there should be no logging.""" _baseLogger = logging.getLogger(BASE_LOGGER_NAME) + # If this fails, some previous test probably changed the default logging level. + assert _baseLogger.getEffectiveLevel() == logging.INFO, ( + "Default base logger level should be INFO" + ) + _baseLogger.debug("_baseLogger.debug") _baseLogger.info("_baseLogger.info") _baseLogger.warning("_baseLogger.warning") diff --git a/wsgidav/mw/cors.py b/wsgidav/mw/cors.py index 35d9f82c..b2e0ed4e 100644 --- a/wsgidav/mw/cors.py +++ b/wsgidav/mw/cors.py @@ -50,7 +50,9 @@ def __init__(self, wsgidav_app, next_app, config): add_non_preflight = add_always[:] if expose_headers: - add_always.append(("Access-Control-Expose-Headers", expose_headers)) + add_non_preflight.append( + ("Access-Control-Expose-Headers", expose_headers) + ) add_preflight = add_always[:] if allow_headers: