Skip to content

Commit 3dacd58

Browse files
Address pagination review: tolerate proxy origins + empty bodies
Two blockers from review: - _paginate raised "unrecognised list payload" on a 204/empty body because None falls through to _results_or_raise. Restore the old (result or {}).get("results", []) behaviour: an empty body returns []. A next link that yields an empty body ends pagination; the count guard still flags a genuine short read. - The same-origin check compared scheme+host+port, so a TLS-terminating proxy emitting http:// (or off-port) next links for an https:// client aborted every paginated list. Compare host only -- the boundary the bearer key is actually scoped to -- so the key still can't leak to another host while legitimate proxy setups keep working. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018HXYFVCZGi7YN9qjDGyJCQ
1 parent c9f6357 commit 3dacd58

2 files changed

Lines changed: 45 additions & 12 deletions

File tree

src/unstract/clone/client.py

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,27 +128,28 @@ def _origin(url: str) -> tuple[str, str, int | None]:
128128
port = parsed.port or {"http": 80, "https": 443}.get(scheme)
129129
return (scheme, host, port)
130130

131-
def _assert_same_origin(self, url: str, label: str) -> None:
132-
"""Reject a pagination ``next`` link that leaves the platform origin.
131+
def _assert_same_host(self, url: str, label: str) -> None:
132+
"""Reject a pagination ``next`` link that points at a different host.
133133
134134
DRF builds ``next`` from the request host, but a compromised or
135135
misconfigured response must not redirect the bearer key elsewhere.
136-
Compared on normalised origin so equivalent hosts (case, default port)
137-
aren't rejected as off-site.
136+
Only the host is compared: a TLS-terminating proxy legitimately emits
137+
``http://`` next links (or a non-default port) for an ``https://``
138+
client, and rejecting those would break paginated lists on a backend
139+
misconfiguration the client can neither see nor fix. The host is the
140+
security boundary the bearer key is scoped to.
138141
"""
139142
try:
140-
link_origin = self._origin(url)
143+
link_host = self._origin(url)[1]
141144
except ValueError as e:
142145
# urlparse raises on a non-numeric / out-of-range port only when
143146
# ``.port`` is read, so a malformed ``next`` surfaces here.
144147
raise PlatformAPIError(
145148
f"GET {label} pagination 'next' is a malformed URL: {e}"
146149
) from e
147-
if link_origin != self._origin(self.endpoint.base_url):
148-
scheme, host, _ = link_origin
150+
if link_host != self._origin(self.endpoint.base_url)[1]:
149151
raise PlatformAPIError(
150-
f"GET {label} pagination 'next' left the platform origin: "
151-
f"{scheme}://{host}"
152+
f"GET {label} pagination 'next' left the platform host: {link_host}"
152153
)
153154

154155
@staticmethod
@@ -173,6 +174,10 @@ def _paginate(
173174
page is worse than one that fails.
174175
"""
175176
result = self._request("GET", path, params=dict(params or {}))
177+
# A 204 / empty body means "no rows", matching the pre-pagination
178+
# ``(result or {}).get("results", [])`` guard the call sites relied on.
179+
if result is None:
180+
return []
176181
if isinstance(result, list):
177182
return result
178183

@@ -187,8 +192,12 @@ def _paginate(
187192
if next_url in seen:
188193
raise PlatformAPIError(f"GET {path} pagination looped at {next_url}")
189194
seen.add(next_url)
190-
self._assert_same_origin(next_url, path)
195+
self._assert_same_host(next_url, path)
191196
result = self._send("GET", next_url, path)
197+
# A ``next`` link that yields an empty body ends pagination; the
198+
# count guard below still flags it as a short read.
199+
if result is None:
200+
break
192201

193202
if expected is not None and len(rows) != expected:
194203
raise PlatformAPIError(

tests/clone/test_client.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,12 +204,12 @@ def test_paginate_rejects_offsite_next():
204204
# A ``next`` pointing at another host must not receive the bearer key.
205205
page1 = {"count": 3, "next": "https://evil.example.com/next", "results": [1, 2]}
206206
client, _ = _client_with_pages(page1)
207-
with pytest.raises(PlatformAPIError, match="left the platform origin"):
207+
with pytest.raises(PlatformAPIError, match="left the platform host"):
208208
client.list_tags()
209209

210210

211211
def test_paginate_follows_equivalent_origin_next():
212-
# Same origin with uppercase host + explicit default port must be followed,
212+
# Same host with uppercase + explicit default port must be followed,
213213
# not rejected as off-site.
214214
page1 = {
215215
"count": 3,
@@ -221,6 +221,30 @@ def test_paginate_follows_equivalent_origin_next():
221221
assert client.list_tags() == [1, 2, 3]
222222

223223

224+
def test_paginate_follows_next_with_different_scheme_or_port():
225+
# A TLS-terminating proxy emits an http:// (and/or off-port) next link for
226+
# an https:// client. Same host → must be followed, not rejected.
227+
page1 = {
228+
"count": 3,
229+
"next": "http://api.example.com:8080/next?page=2",
230+
"results": [1, 2],
231+
}
232+
page2 = {"count": 3, "next": None, "results": [3]}
233+
client, _ = _client_with_pages(page1, page2)
234+
assert client.list_tags() == [1, 2, 3]
235+
236+
237+
def test_paginate_empty_body_returns_empty_list():
238+
# A 204 / empty first page means "no rows", not a malformed payload — it
239+
# must return [] like the pre-pagination ``(result or {}).get`` guard did.
240+
client = PlatformClient(_endpoint())
241+
empty = MagicMock()
242+
empty.status_code = 204
243+
empty.content = b""
244+
client._session.request = MagicMock(return_value=empty)
245+
assert client.list_tags() == []
246+
247+
224248
def test_paginate_raises_on_malformed_port_in_next():
225249
# A `next` URL with a non-numeric port makes urlparse raise ValueError on
226250
# `.port`; it must surface as PlatformAPIError, not an incidental traceback.

0 commit comments

Comments
 (0)