Skip to content

Commit 00e73d8

Browse files
Merge pull request #118 from keyqcloud/feature/190-p1-pagination-guardrails
feat(190): pagination guardrails — max page size + unbounded-list backstop (P1)
2 parents 219ee0d + 8329299 commit 00e73d8

4 files changed

Lines changed: 87 additions & 3 deletions

File tree

src/Core/Api.php

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,15 @@ class Api
175175
'STRICT_TYPING' => true,
176176
'AUTH_STRATEGY_DISPATCHER' => 'off',
177177
'KYTE_ACTIVITY_LOG_MAX_FIELD_BYTES' => 16384,
178+
// Pagination guardrails (KYTE-#190). KYTE_MAX_PAGE_SIZE clamps a
179+
// client-supplied X-Kyte-Page-Size (industry norm ~100). When a list
180+
// request carries no page index it would otherwise return every row;
181+
// KYTE_MAX_UNBOUNDED_ROWS is a generous backstop so it can't pull a
182+
// whole large table (measure the truncation log, then tighten toward
183+
// paginate-by-default). Controller-layer only — internal Model::retrieve
184+
// stays unbounded. Both overridable per install in config.php.
185+
'KYTE_MAX_PAGE_SIZE' => 100,
186+
'KYTE_MAX_UNBOUNDED_ROWS' => 10000,
178187
];
179188

180189
/**
@@ -369,6 +378,27 @@ public static function addKyteAttributes(&$modeldef) {
369378
];
370379
}
371380

381+
/**
382+
* Clamp a requested page size to a sane range (KYTE-#190 pagination
383+
* guardrail): capped at $max, and falling back to $default for a
384+
* non-positive value. Pure/static for unit testing.
385+
*
386+
* @param mixed $requested Raw client-supplied page size
387+
* @param int $max Hard maximum (KYTE_MAX_PAGE_SIZE)
388+
* @param int $default Fallback when the request is non-positive
389+
* @return int
390+
*/
391+
public static function clampPageSize($requested, $max, $default) {
392+
$requested = intval($requested);
393+
if ($requested < 1) {
394+
return intval($default);
395+
}
396+
if ($requested > $max) {
397+
return intval($max);
398+
}
399+
return $requested;
400+
}
401+
372402
/**
373403
* Sets database credentials to the default database.
374404
*/
@@ -1040,8 +1070,15 @@ private function validateRequest()
10401070
}
10411071
}
10421072

1043-
// set page size
1044-
$this->page_size = isset($_SERVER['HTTP_X_KYTE_PAGE_SIZE']) ? intval($_SERVER['HTTP_X_KYTE_PAGE_SIZE']) : PAGE_SIZE;
1073+
// set page size — pagination guardrail (KYTE-#190): clamp a client-
1074+
// supplied X-Kyte-Page-Size to KYTE_MAX_PAGE_SIZE (falling back to the
1075+
// default for a non-positive value) so no request can demand an
1076+
// oversized page.
1077+
$this->page_size = self::clampPageSize(
1078+
isset($_SERVER['HTTP_X_KYTE_PAGE_SIZE']) ? $_SERVER['HTTP_X_KYTE_PAGE_SIZE'] : 0,
1079+
KYTE_MAX_PAGE_SIZE,
1080+
PAGE_SIZE
1081+
);
10451082
// get page num from header
10461083
$this->page_num = isset($_SERVER['HTTP_X_KYTE_PAGE_IDX']) ? intval($_SERVER['HTTP_X_KYTE_PAGE_IDX']) : 0;
10471084

src/Mvc/Controller/ModelController.php

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -798,13 +798,39 @@ public function get($field, $value)
798798
if ($projection !== null) {
799799
$objs->select($projection);
800800
}
801-
$objs->retrieve($field, $value, $isLike, $conditions, $all, $order);
801+
802+
// Pagination guardrail (KYTE-#190): an unbounded HTTP list (no page
803+
// index) is capped by a backstop so a client can't pull an entire
804+
// large table. Paginated requests (page_num >= 1) manage their own
805+
// LIMIT. A caller can opt out of the cap with X-Kyte-All: true (the
806+
// truncation log below still records unbounded reads). Internal
807+
// Model::retrieve calls don't pass through here, so they stay
808+
// unbounded-capable.
809+
$backstop = 0;
810+
$optOutUnbounded = isset($_SERVER['HTTP_X_KYTE_ALL']) && $_SERVER['HTTP_X_KYTE_ALL'] === 'true';
811+
if (!$this->api->page_num && !$optOutUnbounded) {
812+
$backstop = KYTE_MAX_UNBOUNDED_ROWS;
813+
}
814+
$objs->retrieve($field, $value, $isLike, $conditions, $all, $order, $backstop);
802815

803816
// get total count
804817
$this->api->total_count = $objs->total;
805818
$this->api->total_filtered = $objs->total_filtered;
806819
$this->api->page_total = ceil($this->api->total_filtered / $this->api->page_size);
807820

821+
// Log when the backstop actually truncated an unbounded list, so the
822+
// real blast radius can be measured before the ceiling is tightened
823+
// toward paginate-by-default.
824+
if ($backstop > 0 && $objs->total_filtered > $backstop) {
825+
error_log(sprintf(
826+
"KYTE-#190 pagination backstop: '%s' unbounded list capped to %d of %d rows (uri=%s)",
827+
$this->model['name'],
828+
$backstop,
829+
$objs->total_filtered,
830+
isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : ''
831+
));
832+
}
833+
808834
if ($this->failOnNull && count($objs->objects) < 1) {
809835
throw new \Exception($this->exceptionMessages['get']['failOnNull']);
810836
}

tests/ApiTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,20 @@
66
class APiTest extends TestCase
77
{
88

9+
public function testClampPageSize() {
10+
// within range -> unchanged
11+
$this->assertSame(25, \Kyte\Core\Api::clampPageSize(25, 100, 50));
12+
// over max -> clamped to max
13+
$this->assertSame(100, \Kyte\Core\Api::clampPageSize(1000000, 100, 50));
14+
$this->assertSame(100, \Kyte\Core\Api::clampPageSize(101, 100, 50));
15+
// exactly max -> unchanged
16+
$this->assertSame(100, \Kyte\Core\Api::clampPageSize(100, 100, 50));
17+
// non-positive / non-numeric -> default
18+
$this->assertSame(50, \Kyte\Core\Api::clampPageSize(0, 100, 50));
19+
$this->assertSame(50, \Kyte\Core\Api::clampPageSize(-5, 100, 50));
20+
$this->assertSame(50, \Kyte\Core\Api::clampPageSize('abc', 100, 50));
21+
}
22+
923
public function testInitApiSuccess() {
1024
$api = new \Kyte\Core\Api();
1125

tests/ModelTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,13 @@ public function testCreateTable() {
289289
$this->assertTrue($model->retrieve('category', 'Test', false, null, true));
290290
$this->assertEquals(3, $model->count());
291291

292+
// test retrieve with a row limit (KYTE-#190 pagination-backstop
293+
// mechanism): a $limit caps the returned rows even for an unbounded,
294+
// non-paginated retrieve. Three rows match; limit 1 returns one.
295+
$model = new \Kyte\Core\Model(TestTable);
296+
$this->assertTrue($model->retrieve('category', 'Test', false, null, true, null, 1));
297+
$this->assertEquals(1, $model->count());
298+
292299
// test retrieve order by
293300
$model = new \Kyte\Core\Model(TestTable);
294301
$this->assertTrue($model->retrieve('category', 'Test', false, null, true, ['field' => 'category', 'direction' => 'asc']));

0 commit comments

Comments
 (0)