Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/msg/extensions/hosting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion src/msg/transports/http_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions tests/test_hosting_same_origin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
70 changes: 70 additions & 0 deletions tests/test_http_range_bounds.py
Original file line number Diff line number Diff line change
@@ -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'
Loading