diff --git a/changedetectionio/flask_app.py b/changedetectionio/flask_app.py index 639bd0a14..7704a1953 100644 --- a/changedetectionio/flask_app.py +++ b/changedetectionio/flask_app.py @@ -804,6 +804,19 @@ def changedetection_app(config=None, datastore_o=None): else: return login_manager.unauthorized() + # #4299: werkzeug's send_file() (via make_conditional) injects a Date + # header into the WSGI response for conditional/static responses, and the + # Werkzeug built-in server (allow_unsafe_werkzeug=True) then writes its own + # Date via BaseHTTPRequestHandler.send_response() — emitting the Date + # header line twice, which RFC 9110 forbids and nginx rejects ("upstream + # sent duplicate header line"). Strip the application-side copy so the + # server's single header is what reaches the wire. + @app.after_request + def strip_duplicate_date_header(response): + if request.environ.get('SERVER_SOFTWARE', '').startswith('Werkzeug'): + response.headers.pop("Date", None) + return response + watch_api.add_resource( WatchHistoryDiff, '/api/v1/watch//difference//', diff --git a/changedetectionio/tests/test_duplicate_date_header.py b/changedetectionio/tests/test_duplicate_date_header.py new file mode 100644 index 000000000..5ba74847c --- /dev/null +++ b/changedetectionio/tests/test_duplicate_date_header.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" +Issue #4299: static resources went out with two Date header lines. + +werkzeug's send_file() (via make_conditional) puts a Date header on the WSGI +response, and the built-in server we run in production - started through +socketio.run(..., allow_unsafe_werkzeug=True) - writes its own Date in +BaseHTTPRequestHandler.send_response() before copying the app's headers +through verbatim, so the response on the wire carried Date twice. RFC 9110 +forbids that and nginx drops the whole field with "upstream sent duplicate +header line". + +The duplicate is only visible over a real socket, hence live_server and +http.client here: the Flask test client talks to the WSGI app directly and +never sees the server-added header, and requests/urllib3 would merge the two +header lines into one before we could count them. +""" + +import http.client +import re +from urllib.parse import urlparse + + +def test_no_duplicate_date_header_on_static_resources(live_server): + # Served by send_from_directory(), which is the path that makes werkzeug + # attach its own Date - any static file exercises the same code. + url = urlparse(live_server.url('/static/styles/styles.css')) + conn = http.client.HTTPConnection(url.hostname, url.port, timeout=10) + try: + conn.request('GET', url.path) + response = conn.getresponse() + response.read() + # getheaders() keeps repeated header lines as separate entries + headers = response.getheaders() + status = response.status + finally: + conn.close() + + assert status == 200, f"expected 200 for the static file, got {status}" + + dates = [value for key, value in headers if key.lower() == 'date'] + assert len(dates) == 1, ( + f"expected exactly 1 Date header, got {len(dates)}: {dates!r} - RFC 9110 forbids a " + f"duplicated Date, and nginx logs 'upstream sent duplicate header line' and ignores it" + ) + assert re.match(r'^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$', dates[0]), ( + f"Date header is not a valid IMF-fixdate: {dates[0]!r}" + )