From d2d8d2321be8e65d6d3669818ff6d127a6e8670b Mon Sep 17 00:00:00 2001 From: Olivier Desenfans Date: Tue, 7 Apr 2026 17:44:37 +0200 Subject: [PATCH 1/2] fix: enforce query parameter bounds --- src/aleph/db/accessors/balances.py | 12 ++----- src/aleph/db/accessors/cost.py | 3 +- src/aleph/db/accessors/files.py | 3 +- src/aleph/db/accessors/messages.py | 13 +++---- src/aleph/db/accessors/posts.py | 4 +-- src/aleph/schemas/addresses_query_params.py | 5 +-- src/aleph/schemas/api/accounts.py | 22 +++++++----- src/aleph/schemas/messages_query_params.py | 10 +++--- tests/api/test_address_stats.py | 40 ++++++--------------- tests/api/test_list_messages.py | 4 +-- tests/db/test_address_stats.py | 8 ++--- 11 files changed, 48 insertions(+), 76 deletions(-) diff --git a/src/aleph/db/accessors/balances.py b/src/aleph/db/accessors/balances.py index 41537c45f..547f0ceb2 100644 --- a/src/aleph/db/accessors/balances.py +++ b/src/aleph/db/accessors/balances.py @@ -62,10 +62,7 @@ def make_balances_by_chain_query( query = query.filter(AlephBalanceDb.balance >= min_balance) query = query.offset((page - 1) * pagination) - - # If pagination == 0, return all matching results - if pagination: - query = query.limit(pagination) + query = query.limit(pagination) return query @@ -397,9 +394,7 @@ def get_credit_balances( query = query.filter(AlephCreditBalanceDb.balance >= min_balance) query = query.offset((page - 1) * pagination) - - if pagination: - query = query.limit(pagination) + query = query.limit(pagination) # Return results in the expected format (address, credits) results = session.execute(query).all() @@ -697,8 +692,7 @@ def get_address_credit_history( if payment_method is not None: query = query.where(AlephCreditHistoryDb.payment_method == payment_method) - if pagination > 0: - query = query.offset((page - 1) * pagination).limit(pagination) + query = query.offset((page - 1) * pagination).limit(pagination) return session.execute(query).scalars().all() diff --git a/src/aleph/db/accessors/cost.py b/src/aleph/db/accessors/cost.py index f5b52ebe8..698c4b6f9 100644 --- a/src/aleph/db/accessors/cost.py +++ b/src/aleph/db/accessors/cost.py @@ -248,8 +248,7 @@ def get_resources_with_costs( select_stmt = select_stmt.where(AccountCostsDb.payment_type == payment_type) select_stmt = select_stmt.offset((page - 1) * pagination) - if pagination: - select_stmt = select_stmt.limit(pagination) + select_stmt = select_stmt.limit(pagination) return list(session.execute(select_stmt).all()) diff --git a/src/aleph/db/accessors/files.py b/src/aleph/db/accessors/files.py index e214aa06d..05a93d4ef 100644 --- a/src/aleph/db/accessors/files.py +++ b/src/aleph/db/accessors/files.py @@ -247,8 +247,7 @@ def get_address_files_for_api( .where(MessageFilePinDb.owner == owner) ) - if pagination: - select_stmt = select_stmt.limit(pagination).offset((page - 1) * pagination) + select_stmt = select_stmt.limit(pagination).offset((page - 1) * pagination) if sort_order == SortOrder.DESCENDING: order_by_columns: Tuple[UnaryExpression[Any], UnaryExpression[Any]] = ( diff --git a/src/aleph/db/accessors/messages.py b/src/aleph/db/accessors/messages.py index e52a90740..e8a5518ab 100644 --- a/src/aleph/db/accessors/messages.py +++ b/src/aleph/db/accessors/messages.py @@ -197,10 +197,8 @@ def make_matching_messages_query( select_stmt = select_stmt.order_by(*order_by_columns) - # If pagination == 0, return all matching results - if pagination: - # Fetch +1 for has_more detection when using cursor - select_stmt = select_stmt.limit(pagination + 1 if cursor else pagination) + # Fetch +1 for has_more detection when using cursor + select_stmt = select_stmt.limit(pagination + 1 if cursor else pagination) return select_stmt @@ -388,8 +386,7 @@ def get_message_stats_by_address( else: stmt = stmt.order_by(sort_column.desc(), subquery.c.address.asc()) - if pagination: - stmt = stmt.limit(pagination).offset((page - 1) * pagination) + stmt = stmt.limit(pagination).offset((page - 1) * pagination) return session.execute(stmt).all() @@ -892,9 +889,7 @@ def make_matching_hashes_query( select_stmt = select_stmt.order_by(*order_by_columns) select_stmt = select_stmt.offset((page - 1) * pagination) - - if pagination: - select_stmt = select_stmt.limit(pagination) + select_stmt = select_stmt.limit(pagination) return select_stmt diff --git a/src/aleph/db/accessors/posts.py b/src/aleph/db/accessors/posts.py index cc1b8f57f..578f07a9c 100644 --- a/src/aleph/db/accessors/posts.py +++ b/src/aleph/db/accessors/posts.py @@ -285,9 +285,7 @@ def filter_post_select_stmt( ) select_stmt = select_stmt.order_by(*order_by_columns) - # If pagination == 0, return all matching results - if pagination: - select_stmt = select_stmt.limit(pagination) + select_stmt = select_stmt.limit(pagination) if page: select_stmt = select_stmt.offset((page - 1) * pagination) diff --git a/src/aleph/schemas/addresses_query_params.py b/src/aleph/schemas/addresses_query_params.py index c3f23fd0a..5a5701a50 100644 --- a/src/aleph/schemas/addresses_query_params.py +++ b/src/aleph/schemas/addresses_query_params.py @@ -45,8 +45,9 @@ class AddressesQueryParams(BaseModel): # Pagination pagination: int = Field( default=DEFAULT_MESSAGES_PER_PAGE, - ge=0, - description="Maximum number of address to return. Specifying 0 removes this limit.", + ge=1, + le=1000, + description="Maximum number of addresses to return.", ) page: int = Field( default=DEFAULT_PAGE, ge=1, description="Offset in pages. Starts at 1." diff --git a/src/aleph/schemas/api/accounts.py b/src/aleph/schemas/api/accounts.py index 6340b9d26..e8a1afca9 100644 --- a/src/aleph/schemas/api/accounts.py +++ b/src/aleph/schemas/api/accounts.py @@ -32,8 +32,9 @@ class GetAccountBalanceResponse(BaseModel): class GetAccountFilesQueryParams(BaseModel): pagination: int = Field( default=100, - ge=0, - description="Maximum number of files to return. Specifying 0 removes this limit.", + ge=1, + le=1000, + description="Maximum number of files to return.", ) page: int = Field( default=DEFAULT_PAGE, ge=1, description="Offset in pages. Starts at 1." @@ -51,8 +52,9 @@ class GetBalancesChainsQueryParams(BaseModel): ) pagination: int = Field( default=100, - ge=0, - description="Maximum number of files to return. Specifying 0 removes this limit.", + ge=1, + le=1000, + description="Maximum number of balances to return.", ) page: int = Field( default=DEFAULT_PAGE, ge=1, description="Offset in pages. Starts at 1." @@ -77,8 +79,9 @@ class AddressBalanceResponse(BaseModel): class GetCreditBalancesQueryParams(BaseModel): pagination: int = Field( default=100, - ge=0, - description="Maximum number of credit balances to return. Specifying 0 removes this limit.", + ge=1, + le=1000, + description="Maximum number of credit balances to return.", ) page: int = Field( default=DEFAULT_PAGE, ge=1, description="Offset in pages. Starts at 1." @@ -116,9 +119,10 @@ class GetAccountFilesResponse(BaseModel): class GetAccountCreditHistoryQueryParams(BaseModel): pagination: int = Field( - default=0, - ge=0, - description="Maximum number of credit history entries to return. Specifying 0 returns all entries.", + default=100, + ge=1, + le=1000, + description="Maximum number of credit history entries to return.", ) page: int = Field( default=DEFAULT_PAGE, ge=1, description="Offset in pages. Starts at 1." diff --git a/src/aleph/schemas/messages_query_params.py b/src/aleph/schemas/messages_query_params.py index 19b7698e7..b8f046624 100644 --- a/src/aleph/schemas/messages_query_params.py +++ b/src/aleph/schemas/messages_query_params.py @@ -157,8 +157,9 @@ def split_str(cls, v): class MessageQueryParams(BaseMessageQueryParams): pagination: int = Field( default=DEFAULT_MESSAGES_PER_PAGE, - ge=0, - description="Maximum number of messages to return. Specifying 0 removes this limit.", + ge=1, + le=1000, + description="Maximum number of messages to return.", ) page: int = Field( default=DEFAULT_PAGE, ge=1, description="Offset in pages. Starts at 1." @@ -198,8 +199,9 @@ class MessageHashesQueryParams(BaseModel): ) pagination: int = Field( default=DEFAULT_MESSAGES_PER_PAGE, - ge=0, - description="Maximum number of messages to return. Specifying 0 removes this limit.", + ge=1, + le=1000, + description="Maximum number of messages to return.", ) start_date: float = Field( default=0, diff --git a/tests/api/test_address_stats.py b/tests/api/test_address_stats.py index 7a4bdda6f..4e45309a8 100644 --- a/tests/api/test_address_stats.py +++ b/tests/api/test_address_stats.py @@ -253,7 +253,6 @@ async def test_address_stats_endpoint_basic( [ (2, 1, 2), (1, 1, 2), - (0, 1, 2), # all items in one page ], ) async def test_address_stats_pagination( @@ -280,20 +279,14 @@ async def test_address_stats_pagination( # Basic pagination assertions assert data_page1["pagination_page"] == page1 - if per_page > 0: - assert data_page1["pagination_per_page"] == per_page - assert data_page2["pagination_page"] == page2 + assert data_page1["pagination_per_page"] == per_page + assert data_page2["pagination_page"] == page2 - # Should not return the same addresses across pages - page1_addresses = set(data_page1["data"].keys()) - page2_addresses = set(data_page2["data"].keys()) + # Should not return the same addresses across pages + page1_addresses = set(data_page1["data"].keys()) + page2_addresses = set(data_page2["data"].keys()) - assert len(page1_addresses.intersection(page2_addresses)) == 0 - - else: - # per_page == 0 then everything in one page - assert data_page1["pagination_per_page"] == 0 - assert len(data_page2["data"]) == 5 + assert len(page1_addresses.intersection(page2_addresses)) == 0 @pytest.mark.asyncio @@ -423,25 +416,12 @@ async def test_address_stats_all_message_types( @pytest.mark.asyncio -async def test_address_stats_request_all_items( +async def test_address_stats_pagination_zero_rejected( ccn_api_client, fixture_address_stats_messages ): - """Test requesting all items without pagination.""" - # Get count of all addresses - response_normal = await ccn_api_client.get(ADDRESSES_STATS_URI_V1) - assert response_normal.status == 200 - data_normal = await response_normal.json() - total_count = data_normal["pagination_total"] - - # Request all items with pagination=0 - response_all = await ccn_api_client.get(ADDRESSES_STATS_URI_V1 + "?pagination=0") - assert response_all.status == 200 - data_all = await response_all.json() - - # Should return all items - assert len(data_all["data"]) == total_count - assert data_all["pagination_per_page"] == 0 - assert data_all["pagination_total"] == total_count + """Test that pagination=0 is rejected.""" + response = await ccn_api_client.get(ADDRESSES_STATS_URI_V1 + "?pagination=0") + assert response.status == 422 @pytest.mark.asyncio diff --git a/tests/api/test_list_messages.py b/tests/api/test_list_messages.py index 916888d54..18dcf13b6 100644 --- a/tests/api/test_list_messages.py +++ b/tests/api/test_list_messages.py @@ -426,9 +426,9 @@ async def test_pagination(fixture_messages, ccn_api_client): ) assert_messages_equal(messages=messages, expected_messages=fixture_messages) - # All the messages + # All the messages (use a large pagination value) messages = await fetch_messages_with_pagination_expect_success( - ccn_api_client, page=1, pagination=0 + ccn_api_client, page=1, pagination=1000 ) assert_messages_equal(messages=messages, expected_messages=fixture_messages) diff --git a/tests/db/test_address_stats.py b/tests/db/test_address_stats.py index 14dce4762..ca709c36d 100644 --- a/tests/db/test_address_stats.py +++ b/tests/db/test_address_stats.py @@ -214,8 +214,8 @@ async def test_fetch_stats_address_query(session_factory: DbSessionFactory): @pytest.mark.asyncio -async def test_zero_per_page_returns_all(session_factory: DbSessionFactory): - """Test that setting pagination=0 returns all results without pagination.""" +async def test_large_pagination_returns_all(session_factory: DbSessionFactory): + """Test that a large pagination value returns all results.""" with session_factory() as session: test_messages = create_test_messages() session.add_all(test_messages) @@ -226,13 +226,13 @@ async def test_zero_per_page_returns_all(session_factory: DbSessionFactory): # Count total addresses total_count = count_address_stats(session) - # Get all results with pagination=0 + # Get all results with a large pagination value all_stats = get_message_stats_by_address( session=session, sort_by=SortByMessageType.TOTAL, sort_order=SortOrder.DESCENDING, page=1, - pagination=0, # This should return all results + pagination=1000, ) # Should have all addresses From 68f4138220ef6bc2eb67a8473c9a4eef4c202460 Mon Sep 17 00:00:00 2001 From: Olivier Desenfans Date: Tue, 7 Apr 2026 18:03:19 +0200 Subject: [PATCH 2/2] fix: restore internal pagination behavior --- src/aleph/db/accessors/balances.py | 12 +++++++++--- src/aleph/db/accessors/cost.py | 3 ++- src/aleph/db/accessors/files.py | 3 ++- src/aleph/db/accessors/messages.py | 13 +++++++++---- src/aleph/db/accessors/posts.py | 4 +++- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/aleph/db/accessors/balances.py b/src/aleph/db/accessors/balances.py index 547f0ceb2..41537c45f 100644 --- a/src/aleph/db/accessors/balances.py +++ b/src/aleph/db/accessors/balances.py @@ -62,7 +62,10 @@ def make_balances_by_chain_query( query = query.filter(AlephBalanceDb.balance >= min_balance) query = query.offset((page - 1) * pagination) - query = query.limit(pagination) + + # If pagination == 0, return all matching results + if pagination: + query = query.limit(pagination) return query @@ -394,7 +397,9 @@ def get_credit_balances( query = query.filter(AlephCreditBalanceDb.balance >= min_balance) query = query.offset((page - 1) * pagination) - query = query.limit(pagination) + + if pagination: + query = query.limit(pagination) # Return results in the expected format (address, credits) results = session.execute(query).all() @@ -692,7 +697,8 @@ def get_address_credit_history( if payment_method is not None: query = query.where(AlephCreditHistoryDb.payment_method == payment_method) - query = query.offset((page - 1) * pagination).limit(pagination) + if pagination > 0: + query = query.offset((page - 1) * pagination).limit(pagination) return session.execute(query).scalars().all() diff --git a/src/aleph/db/accessors/cost.py b/src/aleph/db/accessors/cost.py index 698c4b6f9..f5b52ebe8 100644 --- a/src/aleph/db/accessors/cost.py +++ b/src/aleph/db/accessors/cost.py @@ -248,7 +248,8 @@ def get_resources_with_costs( select_stmt = select_stmt.where(AccountCostsDb.payment_type == payment_type) select_stmt = select_stmt.offset((page - 1) * pagination) - select_stmt = select_stmt.limit(pagination) + if pagination: + select_stmt = select_stmt.limit(pagination) return list(session.execute(select_stmt).all()) diff --git a/src/aleph/db/accessors/files.py b/src/aleph/db/accessors/files.py index 05a93d4ef..e214aa06d 100644 --- a/src/aleph/db/accessors/files.py +++ b/src/aleph/db/accessors/files.py @@ -247,7 +247,8 @@ def get_address_files_for_api( .where(MessageFilePinDb.owner == owner) ) - select_stmt = select_stmt.limit(pagination).offset((page - 1) * pagination) + if pagination: + select_stmt = select_stmt.limit(pagination).offset((page - 1) * pagination) if sort_order == SortOrder.DESCENDING: order_by_columns: Tuple[UnaryExpression[Any], UnaryExpression[Any]] = ( diff --git a/src/aleph/db/accessors/messages.py b/src/aleph/db/accessors/messages.py index e8a5518ab..e52a90740 100644 --- a/src/aleph/db/accessors/messages.py +++ b/src/aleph/db/accessors/messages.py @@ -197,8 +197,10 @@ def make_matching_messages_query( select_stmt = select_stmt.order_by(*order_by_columns) - # Fetch +1 for has_more detection when using cursor - select_stmt = select_stmt.limit(pagination + 1 if cursor else pagination) + # If pagination == 0, return all matching results + if pagination: + # Fetch +1 for has_more detection when using cursor + select_stmt = select_stmt.limit(pagination + 1 if cursor else pagination) return select_stmt @@ -386,7 +388,8 @@ def get_message_stats_by_address( else: stmt = stmt.order_by(sort_column.desc(), subquery.c.address.asc()) - stmt = stmt.limit(pagination).offset((page - 1) * pagination) + if pagination: + stmt = stmt.limit(pagination).offset((page - 1) * pagination) return session.execute(stmt).all() @@ -889,7 +892,9 @@ def make_matching_hashes_query( select_stmt = select_stmt.order_by(*order_by_columns) select_stmt = select_stmt.offset((page - 1) * pagination) - select_stmt = select_stmt.limit(pagination) + + if pagination: + select_stmt = select_stmt.limit(pagination) return select_stmt diff --git a/src/aleph/db/accessors/posts.py b/src/aleph/db/accessors/posts.py index 578f07a9c..cc1b8f57f 100644 --- a/src/aleph/db/accessors/posts.py +++ b/src/aleph/db/accessors/posts.py @@ -285,7 +285,9 @@ def filter_post_select_stmt( ) select_stmt = select_stmt.order_by(*order_by_columns) - select_stmt = select_stmt.limit(pagination) + # If pagination == 0, return all matching results + if pagination: + select_stmt = select_stmt.limit(pagination) if page: select_stmt = select_stmt.offset((page - 1) * pagination)