mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-27 15:56:45 +00:00
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:
co-authored by
Claude Opus 5
dgtlmoon
dgtlmoon
parent
3d51a04a19
commit
77fb923de6
@@ -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}"
|
||||
)
|
||||
Reference in New Issue
Block a user