diff --git a/pyodata/v2/service.py b/pyodata/v2/service.py index 97145a1..199ab04 100644 --- a/pyodata/v2/service.py +++ b/pyodata/v2/service.py @@ -297,13 +297,19 @@ def add_headers(self, value): def _build_request(self): if self._next_url: parsed_next = urlparse(self._next_url) + # OData v2 allows relative __next values; resolve them against the service root. + if not parsed_next.scheme and not parsed_next.netloc: + resolved = urljoin(self._url, self._next_url) + parsed_next = urlparse(resolved) + else: + resolved = self._next_url parsed_base = urlparse(self._url) if (parsed_next.scheme, parsed_next.netloc) != (parsed_base.scheme, parsed_base.netloc): raise PyODataException( f'cross-origin __next URL rejected: {self._next_url!r} differs from ' f'service root {self._url!r}' ) - url = self._next_url + url = resolved else: url = urljoin(self._url, self.get_path()) # pylint: disable=assignment-from-none diff --git a/tests/test_service_v2.py b/tests/test_service_v2.py index 24793bf..cb3daef 100644 --- a/tests/test_service_v2.py +++ b/tests/test_service_v2.py @@ -2463,6 +2463,42 @@ def test_next_url_same_origin_allowed(service): assert result[0].ID == 23 +@responses.activate +def test_next_url_relative_resolved_against_service_root(service): + """Relative __next value (valid OData v2) is resolved against the service root before dispatch.""" + # pylint: disable=redefined-outer-name + relative_next = "Employees?$skiptoken='opaque'" + expected_url = f"{service.url}/Employees?$skiptoken='opaque'" + + responses.add( + responses.GET, + expected_url, + json={'d': { + 'results': [ + {'ID': 42, 'NameFirst': 'Jane', 'NameLast': 'Doe'} + ] + }}, + status=200) + + request = service.entity_sets.Employees.get_entities().next_url(relative_next) + result = request.execute() + assert len(result) == 1 + assert result[0].ID == 42 + assert responses.calls[0].request.url == expected_url + + +def test_next_url_relative_cross_origin_raises(service): + """Relative __next that resolves to a different origin (e.g. protocol-relative) must be refused.""" + # pylint: disable=redefined-outer-name + # A protocol-relative URL has no scheme but does have a netloc — treated as absolute origin check. + cross_origin_relative = "//attacker.example.com/Employees?$skiptoken=x" + + request = service.entity_sets.Employees.get_entities().next_url(cross_origin_relative) + with pytest.raises(PyODataException, match="cross-origin"): + request.execute() + assert len(responses.calls) == 0 + + @responses.activate def test_count_with_chainable_filter_lt_operator(service): """Check getting $count with $filter with new filter syntax using multiple filters"""