Skip to content

Commit f6be852

Browse files
authored
fix: reject non-positive expires_in in signed public URLs (#1182)
`create_items_public_url` and `create_keys_public_url` (sync and async) now raise `ValueError` when `expires_in` is zero or negative. The check runs before any request. Without it, `timedelta(0)` produced a URL that expired immediately and a negative value one that was already expired. The JS client rejects these values too. Relates to apify/apify-client-js#1183 *✍️ Drafted by Claude Code*
1 parent 4fe2812 commit f6be852

3 files changed

Lines changed: 109 additions & 4 deletions

File tree

‎src/apify_client/_resource_clients/dataset.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -605,12 +605,15 @@ def create_items_public_url(
605605
skip_hidden: If True, then hidden fields are skipped from the output.
606606
flatten: A list of fields that should be flattened.
607607
view: Name of the dataset view to be used.
608-
expires_in: How long the signed URL should be valid.
608+
expires_in: How long the signed URL should be valid. Must be positive. If `None`, the URL never expires.
609609
timeout: Timeout for the API HTTP request.
610610
611611
Returns:
612612
The public dataset items URL.
613613
"""
614+
if expires_in is not None and expires_in.total_seconds() <= 0:
615+
raise ValueError(f'expires_in must be positive, got {expires_in}')
616+
614617
dataset = self.get(timeout=timeout)
615618

616619
request_params = self._build_params(
@@ -1197,12 +1200,15 @@ async def create_items_public_url(
11971200
skip_hidden: If True, then hidden fields are skipped from the output.
11981201
flatten: A list of fields that should be flattened.
11991202
view: Name of the dataset view to be used.
1200-
expires_in: How long the signed URL should be valid.
1203+
expires_in: How long the signed URL should be valid. Must be positive. If `None`, the URL never expires.
12011204
timeout: Timeout for the API HTTP request.
12021205
12031206
Returns:
12041207
The public dataset items URL.
12051208
"""
1209+
if expires_in is not None and expires_in.total_seconds() <= 0:
1210+
raise ValueError(f'expires_in must be positive, got {expires_in}')
1211+
12061212
dataset = await self.get(timeout=timeout)
12071213

12081214
request_params = self._build_params(

‎src/apify_client/_resource_clients/key_value_store.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -476,12 +476,16 @@ def create_keys_public_url(
476476
exclusive_start_key: All keys up to this one (including) are skipped from the result.
477477
collection: The name of the collection in store schema to list keys from.
478478
prefix: The prefix of the keys to be listed.
479-
expires_in: How long the signed URL should be valid from the time it is generated.
479+
expires_in: How long the signed URL should be valid from the time it is generated. Must be positive.
480+
If `None`, the URL never expires.
480481
timeout: Timeout for the API HTTP request.
481482
482483
Returns:
483484
The public key-value store keys URL.
484485
"""
486+
if expires_in is not None and expires_in.total_seconds() <= 0:
487+
raise ValueError(f'expires_in must be positive, got {expires_in}')
488+
485489
metadata = self.get(timeout=timeout)
486490

487491
request_params = self._build_params(
@@ -916,12 +920,16 @@ async def create_keys_public_url(
916920
exclusive_start_key: All keys up to this one (including) are skipped from the result.
917921
collection: The name of the collection in store schema to list keys from.
918922
prefix: The prefix of the keys to be listed.
919-
expires_in: How long the signed URL should be valid from the time it is generated.
923+
expires_in: How long the signed URL should be valid from the time it is generated. Must be positive.
924+
If `None`, the URL never expires.
920925
timeout: Timeout for the API HTTP request.
921926
922927
Returns:
923928
The public key-value store keys URL.
924929
"""
930+
if expires_in is not None and expires_in.total_seconds() <= 0:
931+
raise ValueError(f'expires_in must be positive, got {expires_in}')
932+
925933
metadata = await self.get(timeout=timeout)
926934

927935
request_params = self._build_params(

‎tests/unit/test_url_generation.py‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

33
import json
4+
from base64 import urlsafe_b64decode
5+
from datetime import timedelta
46
from unittest import mock
57
from unittest.mock import Mock
68
from urllib.parse import parse_qs, urlparse
@@ -308,3 +310,92 @@ async def test_api_url_version_appended_once_async(api_url: str, expected_base_u
308310

309311
assert call_mock.call_args.kwargs['url'] == f'{expected_base_url}/key-value-stores/{MOCKED_KVS_ID}'
310312
assert public_url == f'{expected_base_url}/key-value-stores/{MOCKED_KVS_ID}/records/some_key'
313+
314+
315+
parametrized_non_positive_expires_in = pytest.mark.parametrize(
316+
'expires_in',
317+
[
318+
pytest.param(timedelta(0), id='zero'),
319+
pytest.param(timedelta(seconds=-1), id='negative'),
320+
],
321+
)
322+
323+
324+
@parametrized_non_positive_expires_in
325+
def test_dataset_public_url_rejects_non_positive_expires_in_sync(expires_in: timedelta) -> None:
326+
"""A non-positive `expires_in` raises `ValueError` before any request is sent, with sync client."""
327+
client = ApifyClient(token='dummy-token')
328+
329+
dataset = client.dataset('someID')
330+
331+
with (
332+
mock.patch.object(client._http_client, 'call') as call_mock,
333+
pytest.raises(ValueError, match='expires_in must be positive'),
334+
):
335+
dataset.create_items_public_url(expires_in=expires_in)
336+
337+
call_mock.assert_not_called()
338+
339+
340+
@parametrized_non_positive_expires_in
341+
async def test_dataset_public_url_rejects_non_positive_expires_in_async(expires_in: timedelta) -> None:
342+
"""A non-positive `expires_in` raises `ValueError` before any request is sent, with async client."""
343+
client = ApifyClientAsync(token='dummy-token')
344+
345+
dataset = client.dataset('someID')
346+
347+
with (
348+
mock.patch.object(client._http_client, 'call') as call_mock,
349+
pytest.raises(ValueError, match='expires_in must be positive'),
350+
):
351+
await dataset.create_items_public_url(expires_in=expires_in)
352+
353+
call_mock.assert_not_called()
354+
355+
356+
@parametrized_non_positive_expires_in
357+
def test_kvs_public_url_rejects_non_positive_expires_in_sync(expires_in: timedelta) -> None:
358+
"""A non-positive `expires_in` raises `ValueError` before any request is sent, with sync client."""
359+
client = ApifyClient(token='dummy-token')
360+
361+
kvs = client.key_value_store(MOCKED_KVS_ID)
362+
363+
with (
364+
mock.patch.object(client._http_client, 'call') as call_mock,
365+
pytest.raises(ValueError, match='expires_in must be positive'),
366+
):
367+
kvs.create_keys_public_url(expires_in=expires_in)
368+
369+
call_mock.assert_not_called()
370+
371+
372+
@parametrized_non_positive_expires_in
373+
async def test_kvs_public_url_rejects_non_positive_expires_in_async(expires_in: timedelta) -> None:
374+
"""A non-positive `expires_in` raises `ValueError` before any request is sent, with async client."""
375+
client = ApifyClientAsync(token='dummy-token')
376+
377+
kvs = client.key_value_store(MOCKED_KVS_ID)
378+
379+
with (
380+
mock.patch.object(client._http_client, 'call') as call_mock,
381+
pytest.raises(ValueError, match='expires_in must be positive'),
382+
):
383+
await kvs.create_keys_public_url(expires_in=expires_in)
384+
385+
call_mock.assert_not_called()
386+
387+
388+
def test_dataset_public_url_signs_positive_expires_in() -> None:
389+
"""A positive `expires_in` produces a signature with a non-zero expiry."""
390+
client = ApifyClient(token='dummy-token')
391+
dataset = client.dataset('someID')
392+
393+
mock_response = Mock()
394+
mock_response.json.return_value = json.loads(MOCKED_DATASET_RESPONSE)
395+
396+
with mock.patch.object(client._http_client, 'call', return_value=mock_response):
397+
public_url = dataset.create_items_public_url(expires_in=timedelta(seconds=1))
398+
399+
signature = parse_qs(urlparse(public_url).query)['signature'][0]
400+
_, expires_at, _ = urlsafe_b64decode(signature).decode().split('.')
401+
assert int(expires_at) > 0

0 commit comments

Comments
 (0)