fix(http): send a single Date header on werkzeug built-in server (#4372)

* fix(http): send a single Date header on werkzeug built-in server

Static resources served via werkzeug send_from_directory/send_file get a
Date header injected into the WSGI response by make_conditional()
(werkzeug/wrappers/response.py:752-757). When the app runs on Werkzeug's
built-in server -- the default path started through
socketio.run(..., allow_unsafe_werkzeug=True) in changedetectionio/__init__.py:694
and used by the docker entrypoint -- BaseHTTPRequestHandler.send_response()
(werkzeug/serving.py:271) emits its own Date header line as well, so the
wire response carries two Date headers. RFC 9110 forbids this and nginx
rejects the response with "upstream sent duplicate header line" (issue
#4299, see also #4101).

Fix: a global after_request hook pops the application-side Date copy so
only the server's single header reaches the wire. Verified safe on
gunicorn too, which also emits its own Date header.

Test: new tests/test_duplicate_date_header.py hits the live_server over
real HTTP with http.client (the Flask test client talks to the WSGI app
directly and never sees the server-added header) and asserts the Date
header appears exactly once, on the exact static resources named in the
issue. Fails on unfixed code with two identical Date lines; passes with
the fix.

Fixes #4299

* Apply suggestion from @dgtlmoon

* Tidy the #4299 Date header fix and its test

flask_app.py: the applied suggestion landed with a 3-space indent and
trailing whitespace - the latter was the only W291 in the file, which
.ruff.toml selects.

test_duplicate_date_header.py:
- drop the 10s socket wait loop, pytest-flask's live_server already
  blocks until the port accepts connections
- drop the unused `app` fixture argument (live_server depends on it)
- stop hardcoding jquery-3.6.0.min.js: asserting 200 on a vendored
  filename turns a jQuery bump into a failure in a file about HTTP
  headers. Any send_from_directory() response exercises the same path,
  so styles.css alone is enough.

Still red before the fix (two identical Date lines) and green after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: dgtlmoon <leigh@morresi.net>
Co-authored-by: dgtlmoon <dgtlmoon@gmail.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang-404-Q
2026-09-17 07:16:00 +02:00
committed by GitHub
co-authored by Claude Opus 5 dgtlmoon dgtlmoon
parent 3d51a04a19
commit 77fb923de6
2 changed files with 61 additions and 0 deletions
+13
View File
@@ -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/<uuid_str:uuid>/difference/<string:from_timestamp>/<string:to_timestamp>',
@@ -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}"
)