From 2c3e06231a4d97ba1f6b0f1620274cd85f854633 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 23 Oct 2025 18:44:14 +0200 Subject: [PATCH 1/3] Avoid adding Content-Type to non-body responses The current code sets the content-type header for all responses to the result's content_type property if upstream does not set a content_type. The default value for content_type is "application/octet-stream". For responses that do not have a body (like 204 No Content or 304 Not Modified), setting a content-type header is unnecessary and potentially misleading. Follow HTTP standards by only adding the content-type header to responses that actually contain a body. --- supervisor/api/ingress.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/supervisor/api/ingress.py b/supervisor/api/ingress.py index bbafc14d9c4..2fdf9766dc8 100644 --- a/supervisor/api/ingress.py +++ b/supervisor/api/ingress.py @@ -253,6 +253,16 @@ async def _handle_request( skip_auto_headers={hdrs.CONTENT_TYPE}, ) as result: headers = _response_header(result) + + # Empty body responses (304, 204, HEAD, etc.) should not be streamed, + # otherwise aiohttp < 3.9.0 may generate an invalid "0\r\n\r\n" chunk + # This also avoids setting content_type for empty responses. + if must_be_empty_body(request.method, result.status): + return web.Response( + headers=headers, + status=result.status, + ) + # Avoid parsing content_type in simple cases for better performance if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): content_type = (maybe_content_type.partition(";"))[0].strip() @@ -260,11 +270,7 @@ async def _handle_request( content_type = result.content_type # Simple request if ( - # empty body responses should not be streamed, - # otherwise aiohttp < 3.9.0 may generate - # an invalid "0\r\n\r\n" chunk instead of an empty response. - must_be_empty_body(request.method, result.status) - or hdrs.CONTENT_LENGTH in result.headers + hdrs.CONTENT_LENGTH in result.headers and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4_194_000 ): # Return Response From b4f2a5dca64f42291126da9c19985351f12a9aa8 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 6 Nov 2025 09:38:28 +0100 Subject: [PATCH 2/3] Add pytest for ingress proxy --- tests/api/test_ingress.py | 138 +++++++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 2 deletions(-) diff --git a/tests/api/test_ingress.py b/tests/api/test_ingress.py index 0cfd1077fe6..c42ac97de7b 100644 --- a/tests/api/test_ingress.py +++ b/tests/api/test_ingress.py @@ -1,12 +1,28 @@ """Test ingress API.""" -from unittest.mock import AsyncMock, patch +from collections.abc import AsyncGenerator +from unittest.mock import AsyncMock, MagicMock, patch -from aiohttp.test_utils import TestClient +import aiohttp +from aiohttp import hdrs, web +from aiohttp.test_utils import TestClient, TestServer +import pytest +from supervisor.addons.addon import Addon from supervisor.coresys import CoreSys +@pytest.fixture(name="real_websession") +async def fixture_real_websession( + coresys: CoreSys, +) -> AsyncGenerator[aiohttp.ClientSession]: + """Fixture for real aiohttp ClientSession for ingress proxy tests.""" + session = aiohttp.ClientSession() + coresys._websession = session # pylint: disable=W0212 + yield session + await session.close() + + async def test_validate_session(api_client: TestClient, coresys: CoreSys): """Test validating ingress session.""" with patch("aiohttp.web_request.BaseRequest.__getitem__", return_value=None): @@ -86,3 +102,121 @@ async def test_validate_session_with_user_id( assert ( coresys.ingress.get_session_data(session).user.display_name == "Some Name" ) + + +async def test_ingress_proxy_no_content_type_for_empty_body_responses( + api_client: TestClient, coresys: CoreSys, real_websession: aiohttp.ClientSession +): + """Test that empty body responses don't get Content-Type header.""" + + # Create a mock add-on backend server that returns various status codes + async def mock_addon_handler(request: web.Request) -> web.Response: + """Mock add-on handler that returns different status codes based on path.""" + path = request.path + + if path == "/204": + # 204 No Content - should not have Content-Type + return web.Response(status=204) + elif path == "/304": + # 304 Not Modified - should not have Content-Type + return web.Response(status=304) + elif path == "/100": + # 100 Continue - should not have Content-Type + return web.Response(status=100) + elif path == "/head": + # HEAD request - should not have Content-Type + return web.Response(body=b"test", content_type="text/html") + elif path == "/200": + # 200 OK with body - should have Content-Type + return web.Response(body=b"test content", content_type="text/plain") + elif path == "/200-no-content-type": + # 200 OK without explicit Content-Type - should get default + return web.Response(body=b"test content") + elif path == "/200-json": + # 200 OK with JSON - should preserve Content-Type + return web.Response( + body=b'{"key": "value"}', content_type="application/json" + ) + else: + return web.Response(body=b"default", content_type="text/html") + + # Create test server for mock add-on + app = web.Application() + app.router.add_route("*", "/{tail:.*}", mock_addon_handler) + addon_server = TestServer(app) + await addon_server.start_server() + + try: + # Create ingress session + resp = await api_client.post("/ingress/session") + result = await resp.json() + session = result["data"]["session"] + + # Create a mock add-on + mock_addon = MagicMock(spec=Addon) + mock_addon.slug = "test_addon" + mock_addon.ip_address = addon_server.host + mock_addon.ingress_port = addon_server.port + mock_addon.ingress_stream = False + + # Generate an ingress token and register the add-on + ingress_token = coresys.ingress.create_session() + with patch.object(coresys.ingress, "get", return_value=mock_addon): + # Test 204 No Content - should NOT have Content-Type + resp = await api_client.get( + f"/ingress/{ingress_token}/204", + cookies={"ingress_session": session}, + ) + assert resp.status == 204 + assert hdrs.CONTENT_TYPE not in resp.headers + + # Test 304 Not Modified - should NOT have Content-Type + resp = await api_client.get( + f"/ingress/{ingress_token}/304", + cookies={"ingress_session": session}, + ) + assert resp.status == 304 + assert hdrs.CONTENT_TYPE not in resp.headers + + # Test HEAD request - should NOT have Content-Type + resp = await api_client.head( + f"/ingress/{ingress_token}/head", + cookies={"ingress_session": session}, + ) + assert resp.status == 200 + assert hdrs.CONTENT_TYPE not in resp.headers + + # Test 200 OK with body - SHOULD have Content-Type + resp = await api_client.get( + f"/ingress/{ingress_token}/200", + cookies={"ingress_session": session}, + ) + assert resp.status == 200 + assert hdrs.CONTENT_TYPE in resp.headers + assert resp.headers[hdrs.CONTENT_TYPE] == "text/plain" + body = await resp.read() + assert body == b"test content" + + # Test 200 OK without explicit Content-Type - SHOULD get default + resp = await api_client.get( + f"/ingress/{ingress_token}/200-no-content-type", + cookies={"ingress_session": session}, + ) + assert resp.status == 200 + assert hdrs.CONTENT_TYPE in resp.headers + # Should get application/octet-stream as default from aiohttp ClientResponse + assert "application/octet-stream" in resp.headers[hdrs.CONTENT_TYPE] + + # Test 200 OK with JSON - SHOULD preserve Content-Type + resp = await api_client.get( + f"/ingress/{ingress_token}/200-json", + cookies={"ingress_session": session}, + ) + assert resp.status == 200 + assert hdrs.CONTENT_TYPE in resp.headers + assert "application/json" in resp.headers[hdrs.CONTENT_TYPE] + body = await resp.read() + assert body == b'{"key": "value"}' + + finally: + await addon_server.close() From 683588c1ab6d5fd91cf7795f05385d9fa3e4e478 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 6 Nov 2025 10:49:31 +0100 Subject: [PATCH 3/3] Preserve Content-Type header for HEAD requests in ingress API --- supervisor/api/ingress.py | 14 +++++++++----- tests/api/test_ingress.py | 11 ++++++++--- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/supervisor/api/ingress.py b/supervisor/api/ingress.py index 2fdf9766dc8..bf2d56ffe35 100644 --- a/supervisor/api/ingress.py +++ b/supervisor/api/ingress.py @@ -254,20 +254,24 @@ async def _handle_request( ) as result: headers = _response_header(result) + # Avoid parsing content_type in simple cases for better performance + if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): + content_type = (maybe_content_type.partition(";"))[0].strip() + else: + content_type = result.content_type + # Empty body responses (304, 204, HEAD, etc.) should not be streamed, # otherwise aiohttp < 3.9.0 may generate an invalid "0\r\n\r\n" chunk # This also avoids setting content_type for empty responses. if must_be_empty_body(request.method, result.status): + # If upstream contains content-type, preserve it (e.g. for HEAD requests) + if maybe_content_type: + headers[hdrs.CONTENT_TYPE] = content_type return web.Response( headers=headers, status=result.status, ) - # Avoid parsing content_type in simple cases for better performance - if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): - content_type = (maybe_content_type.partition(";"))[0].strip() - else: - content_type = result.content_type # Simple request if ( hdrs.CONTENT_LENGTH in result.headers diff --git a/tests/api/test_ingress.py b/tests/api/test_ingress.py index c42ac97de7b..f3007879edb 100644 --- a/tests/api/test_ingress.py +++ b/tests/api/test_ingress.py @@ -124,7 +124,7 @@ async def mock_addon_handler(request: web.Request) -> web.Response: # 100 Continue - should not have Content-Type return web.Response(status=100) elif path == "/head": - # HEAD request - should not have Content-Type + # HEAD request - should have Content-Type (same as GET would) return web.Response(body=b"test", content_type="text/html") elif path == "/200": # 200 OK with body - should have Content-Type @@ -178,13 +178,18 @@ async def mock_addon_handler(request: web.Request) -> web.Response: assert resp.status == 304 assert hdrs.CONTENT_TYPE not in resp.headers - # Test HEAD request - should NOT have Content-Type + # Test HEAD request - SHOULD have Content-Type (same as GET) + # per RFC 9110: HEAD should return same headers as GET resp = await api_client.head( f"/ingress/{ingress_token}/head", cookies={"ingress_session": session}, ) assert resp.status == 200 - assert hdrs.CONTENT_TYPE not in resp.headers + assert hdrs.CONTENT_TYPE in resp.headers + assert "text/html" in resp.headers[hdrs.CONTENT_TYPE] + # Body should be empty for HEAD + body = await resp.read() + assert body == b"" # Test 200 OK with body - SHOULD have Content-Type resp = await api_client.get(