fix(search): move pagination clamp to controller (fixes cleanup regression)

Phase 5 re-validation caught a functional regression: the V-10 clamp in
SearchService.SearchAsync also capped CleanupService's internal target resolution
(pageSize 10000 -> 200), silently limiting bulk cleanup-by-query to 200 emails.

The clamp belongs at the user-facing trust boundary, not the shared service: move
MaxPageSize (200) enforcement into SearchController (both the POST body and GET query
paths). Internal callers of ISearchService now request large pages unhindered, while
user requests are still bounded. Adds a regression test proving SearchService returns
a 250-row page uncapped. No security regressions per Phase 5. All 39 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-01 00:29:45 +02:00
parent c3bca051ea
commit 1e15b84dce
3 changed files with 68 additions and 11 deletions
@@ -17,17 +17,11 @@ public class SearchService : ISearchService
private readonly AppDbContext _db;
public SearchService(AppDbContext db) => _db = db;
/// <summary>Hard upper bound on a user-facing page of results (V-10: DoS via huge pageSize).</summary>
public const int MaxPageSize = 200;
public async Task<PagedResult<EmailSummaryDto>> SearchAsync(Guid userId, SearchRequestDto r, CancellationToken ct = default)
{
// V-10: clamp pagination at the user-facing chokepoint (covers both the GET
// query-string path and the POST body path) so a caller cannot request an
// unbounded materialisation. Internal callers (e.g. cleanup target resolution)
// do not go through this service, so their larger pages are unaffected.
r = r with { Page = Math.Max(1, r.Page), PageSize = Math.Clamp(r.PageSize, 1, MaxPageSize) };
// NOTE: pagination is clamped at the user-facing trust boundary (SearchController),
// NOT here, so internal callers (e.g. CleanupService target resolution, which
// legitimately requests large pages) are unaffected. See V-10 fix.
var q = _db.Emails.AsNoTracking().Where(e => e.UserId == userId);
if (!string.IsNullOrWhiteSpace(r.Sender))