From ea2df75274c3db32eb67f79fc5f36ee2f473ca3d Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 12 Nov 2026 00:32:06 +0100 Subject: [PATCH 1/2] fix(API): prevent TypeError and handle integer overflow in paginate() When a huge page number (e.g. PHP_INT_MAX) is requested via ?page=, the offset calculation `($page - 1) * $perPage` overflows the int type and becomes a float. Passing that float to `BaseBuilder::limit(?int, ?int)` raises a TypeError, which `paginate()` reports as a 500 response ("Paginate Error"). Clamp the requested page to the available range, so the offset always stays within the int type: $page = max(1, min($page, max(1, $totalPages))); This also aligns the Query Builder path with the Pager used by the Model path, which already limits the current page to the page count. --- system/API/ResponseTrait.php | 12 +++-- tests/system/API/ResponseTraitTest.php | 75 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index 0449e91bf07c..22deafc5fdc7 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -416,15 +416,19 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20, ?str ]; } else { // Query Builder, we need to handle pagination manually - $offset = ($page - 1) * $perPage; - $total = (clone $resource)->countAllResults(); - $data = $resource->limit($perPage, $offset)->get()->getResultArray(); + // The page is clamped to the available range so that the offset + // cannot overflow the int type when a huge page is requested. + $total = (clone $resource)->countAllResults(); + $totalPages = (int) ceil($total / $perPage); + $page = max(1, min($page, max(1, $totalPages))); + $offset = ($page - 1) * $perPage; + $data = $resource->limit($perPage, $offset)->get()->getResultArray(); $meta = [ 'page' => $page, 'perPage' => $perPage, 'total' => $total, - 'totalPages' => (int) ceil($total / $perPage), + 'totalPages' => $totalPages, ]; } diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index f5d33491a655..a78a71e25bea 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -799,6 +799,81 @@ public function testPaginateWithQueryBuilder(): void $this->assertSame(3, $responseBody['meta']['totalPages']); } + public function testPaginateWithQueryBuilderPageOverflow(): void + { + // Mock the database and builder + $db = $this->createStub(BaseConnection::class); + + $builder = $this->getMockBuilder(BaseBuilder::class) + ->setConstructorArgs(['test_table', $db]) + ->onlyMethods(['countAllResults', 'limit', 'get']) + ->getMock(); + + $result = $this->createMock(BaseResult::class); + + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + // Mock the query builder chain + $builder->method('countAllResults')->willReturn(50); + $builder->expects($this->once()) + ->method('limit') + ->with(20, 40) + ->willReturnSelf(); + $builder->method('get')->willReturn($result); + $result->method('getResultArray')->willReturn($data); + + $controller = $this->makeController('/api/items?page=' . PHP_INT_MAX); + $this->request->setGlobal('get', ['page' => (string) PHP_INT_MAX]); + + $this->invoke($controller, 'paginate', [$builder, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + $this->assertSame(3, $responseBody['meta']['page']); + $this->assertSame(20, $responseBody['meta']['perPage']); + $this->assertSame(50, $responseBody['meta']['total']); + $this->assertSame(3, $responseBody['meta']['totalPages']); + $this->assertSame($data, $responseBody['data']); + $this->assertStringContainsString('page=3', $responseBody['links']['self']); + $this->assertNull($responseBody['links']['next']); + } + + public function testPaginateWithQueryBuilderZeroResultsPageOverflow(): void + { + // Mock the database and builder + $db = $this->createStub(BaseConnection::class); + + $builder = $this->getMockBuilder(BaseBuilder::class) + ->setConstructorArgs(['test_table', $db]) + ->onlyMethods(['countAllResults', 'limit', 'get']) + ->getMock(); + + $result = $this->createMock(BaseResult::class); + + // Mock the query builder chain + $builder->method('countAllResults')->willReturn(0); + $builder->expects($this->once()) + ->method('limit') + ->with(20, 0) + ->willReturnSelf(); + $builder->method('get')->willReturn($result); + $result->method('getResultArray')->willReturn([]); + + $controller = $this->makeController('/api/items?page=' . PHP_INT_MAX); + $this->request->setGlobal('get', ['page' => (string) PHP_INT_MAX]); + + $this->invoke($controller, 'paginate', [$builder, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + $this->assertSame(1, $responseBody['meta']['page']); + $this->assertSame(0, $responseBody['meta']['totalPages']); + $this->assertSame([], $responseBody['data']); + } + public function testPaginateWithCustomPerPage(): void { $data = [ From dd83f29e92f92e892067bba954da16080525d531 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Thu, 12 Nov 2026 01:12:06 +0100 Subject: [PATCH 2/2] test(API): satisfy StringCastAssertStringContainsStringRector in paginate overflow test --- tests/system/API/ResponseTraitTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index a78a71e25bea..22f2e0b2f6ef 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -837,7 +837,7 @@ public function testPaginateWithQueryBuilderPageOverflow(): void $this->assertSame(50, $responseBody['meta']['total']); $this->assertSame(3, $responseBody['meta']['totalPages']); $this->assertSame($data, $responseBody['data']); - $this->assertStringContainsString('page=3', $responseBody['links']['self']); + $this->assertStringContainsString('page=3', (string) $responseBody['links']['self']); $this->assertNull($responseBody['links']['next']); }