From 33dde9b234cb3105f8b0edd46c3621554be5424e Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Wed, 30 Sep 2026 16:07:17 +0800 Subject: [PATCH] Bound HTTP Range parsing before numeric conversion --- src/msg/extensions/hosting.py | 2 +- src/msg/transports/http_routes.py | 6 ++- tests/test_hosting_same_origin.py | 9 ++++ tests/test_http_range_bounds.py | 70 +++++++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 tests/test_http_range_bounds.py diff --git a/src/msg/extensions/hosting.py b/src/msg/extensions/hosting.py index 602b31d8..368a79c2 100644 --- a/src/msg/extensions/hosting.py +++ b/src/msg/extensions/hosting.py @@ -330,7 +330,7 @@ def hosted_range(request, size, etag): requested = request.headers.get('range') if not requested or request.headers.get('if-range') not in {None, etag}: return (0, size), 200, {} - match = re.fullmatch(r'bytes=(\d*)-(\d*)', requested) + match = re.fullmatch(r'bytes=(\d*)-(\d*)', requested) if len(requested) <= 128 else None require(match is not None and any(match.groups()), 'range_not_satisfiable') left, right = match.groups() if left: diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index de81cb19..fee84e1f 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -2431,7 +2431,11 @@ async def dispatch(request: Request): status = 200 requested_range = request.headers.get('range') if requested_range and (request.headers.get('if-range') in {None, etag}): - match = re.fullmatch(r'bytes=(\d*)-(\d*)', requested_range) + match = ( + re.fullmatch(r'bytes=(\d*)-(\d*)', requested_range) + if len(requested_range) <= 128 + else None + ) require(match is not None and any(match.groups()), 'range_not_satisfiable') left, right = match.groups() if left: diff --git a/tests/test_hosting_same_origin.py b/tests/test_hosting_same_origin.py index da692d4b..f30e98ed 100644 --- a/tests/test_hosting_same_origin.py +++ b/tests/test_hosting_same_origin.py @@ -91,6 +91,15 @@ async def test_same_domain_hosted_html_head_304_range_raw_and_no_write_route(ins partial = await http.get('/@web-owner/web/index.html', headers={'Range': 'bytes=0-14'}) assert partial.status_code == 206 and partial.content == html[:15] isolated(partial) + for malformed in ( + 'bytes=' + '9' * 5000 + '-', + 'bytes=0-' + '9' * 5000, + 'bytes=-' + '9' * 5000, + ): + refused = await http.get('/@web-owner/web/index.html', headers={'Range': malformed}) + assert refused.status_code == 416 + assert refused.json()['error'] == 'range_not_satisfiable' + isolated(refused) raw = await http.get(f'/_read/{published}/raw') assert raw.status_code == 200 assert 'attachment' in raw.headers['content-disposition'] diff --git a/tests/test_http_range_bounds.py b/tests/test_http_range_bounds.py new file mode 100644 index 00000000..1f0329a7 --- /dev/null +++ b/tests/test_http_range_bounds.py @@ -0,0 +1,70 @@ +"""Malformed Range fields have bounded parsing and stable client errors.""" + +import pytest +from starlette.requests import Request + +from msg.core.errors import Failure +from msg.extensions.hosting import hosted_range + + +@pytest.mark.parametrize( + 'value', + ['bytes=' + '9' * 5000 + '-', 'bytes=0-' + '9' * 5000, 'bytes=-' + '9' * 5000], + ids=['start', 'end', 'suffix'], +) +def test_hosted_range_rejects_oversized_numbers_with_contract_error(value): + request = Request({'type': 'http', 'headers': [(b'range', value.encode())]}) + with pytest.raises(Failure, match='range_not_satisfiable'): + hosted_range(request, 12, '"etag"') + + +@pytest.mark.parametrize( + 'value,expected', + [('bytes=0-4', (0, 5)), ('bytes=5-', (5, 12)), ('bytes=-5', (7, 12)), ('bytes=-99', (0, 12))], +) +def test_hosted_range_keeps_normal_prefix_open_and_suffix_ranges(value, expected): + request = Request({'type': 'http', 'headers': [(b'range', value.encode())]}) + interval, status, _ = hosted_range(request, 12, '"etag"') + assert interval == expected and status == 206 + + +def test_stale_if_range_ignores_range_before_parsing(): + request = Request({ + 'type': 'http', + 'headers': [(b'range', b'bytes=' + b'9' * 5000 + b'-'), (b'if-range', b'"stale"')], + }) + assert hosted_range(request, 12, '"etag"') == ((0, 12), 200, {}) + + +@pytest.mark.asyncio +async def test_raw_http_rejects_oversized_range_without_read_effects(installed, monkeypatch): + import httpx + from read_only_evidence import readonly_evidence + from test_service import call, register + + from msg.transports.http import create_app + + app, _ = installed + key, subject, _ = await register(app, 'range-reader') + created = await call( + app, + 'content.post_create', + {'parent': '/main', 'body': 'range content'}, + key=key, + subject=subject, + ) + assert created.status == 'ok' + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=create_app(app)), base_url=app.settings.service_url + ) as http: + async with readonly_evidence(app, monkeypatch): + for value in ( + 'bytes=' + '9' * 5000 + '-', + 'bytes=0-' + '9' * 5000, + 'bytes=-' + '9' * 5000, + ): + result = await http.get( + '/_id/' + created.resources[0].id + '/raw', headers={'Range': value} + ) + assert result.status_code == 416 + assert result.json()['error']['code'] == 'range_not_satisfiable'