From d51fc3b95451893c7afd8b937553b7f69d596b11 Mon Sep 17 00:00:00 2001 From: MOHAN Date: Thu, 16 Jul 2026 22:11:14 +0530 Subject: [PATCH] Add pagination abuse risk signals --- src/abuse/abuse.service.ts | 62 +++++++++++++++++++++++++++++++++++++ src/view/view.controller.ts | 10 ++++-- src/view/view.service.ts | 15 ++++++--- test/abuse.service.spec.ts | 33 ++++++++++++++++++++ test/utils/mock-prisma.ts | 1 + 5 files changed, 114 insertions(+), 7 deletions(-) diff --git a/src/abuse/abuse.service.ts b/src/abuse/abuse.service.ts index 9ca9f2b..f96abfc 100644 --- a/src/abuse/abuse.service.ts +++ b/src/abuse/abuse.service.ts @@ -15,6 +15,7 @@ type RecordAbuseEventInput = RequestContext & { }; const RISK_WINDOW_HOURS = 24; +const PAGINATION_EVENT_TYPES = ["PAGINATION_LIMIT_CLAMPED", "PAGINATION_DEEP_SCAN"] as const; @Injectable() export class AbuseService { @@ -91,6 +92,67 @@ export class AbuseService { } } + async recordPaginationActivity( + userId: string, + resource: string, + page: number, + requestedLimit: number | undefined, + appliedLimit: number, + context?: RequestContext, + ) { + const normalizedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1; + const normalizedRequestedLimit = Number.isFinite(requestedLimit) && requestedLimit ? Math.floor(requestedLimit) : appliedLimit; + const isLimitClamped = normalizedRequestedLimit > appliedLimit; + const isDeepScan = normalizedPage >= 20; + if (!isLimitClamped && !isDeepScan) return; + + const since = new Date(Date.now() - 60 * 60 * 1000); + const recentSignals = await this.prisma.abuseEvent.count({ + where: { + userId, + eventType: { in: [...PAGINATION_EVENT_TYPES] }, + createdAt: { gte: since }, + }, + }); + const repeated = recentSignals >= 10; + + if (isLimitClamped) { + await this.recordEvent({ + userId, + eventType: "PAGINATION_LIMIT_CLAMPED", + riskPoints: repeated ? 10 : 3, + severity: repeated ? "medium" : "low", + ipAddress: context?.ipAddress, + userAgent: context?.userAgent, + metadata: { + resource, + page: normalizedPage, + requestedLimit: normalizedRequestedLimit, + appliedLimit, + recentSignals, + }, + }); + } + + if (isDeepScan) { + await this.recordEvent({ + userId, + eventType: "PAGINATION_DEEP_SCAN", + riskPoints: repeated || normalizedPage >= 100 ? 20 : 8, + severity: repeated || normalizedPage >= 100 ? "high" : "medium", + ipAddress: context?.ipAddress, + userAgent: context?.userAgent, + metadata: { + resource, + page: normalizedPage, + requestedLimit: normalizedRequestedLimit, + appliedLimit, + recentSignals, + }, + }); + } + } + async getRiskProfile(userId: string) { const since = new Date(Date.now() - RISK_WINDOW_HOURS * 60 * 60 * 1000); const events = await this.prisma.abuseEvent.findMany({ diff --git a/src/view/view.controller.ts b/src/view/view.controller.ts index f757858..dfc55eb 100644 --- a/src/view/view.controller.ts +++ b/src/view/view.controller.ts @@ -1,6 +1,8 @@ -import { Controller, Get, Query } from "@nestjs/common"; +import { Controller, Get, Query, Req } from "@nestjs/common"; +import { Request } from "express"; import { CurrentUser } from "../common/decorators/current-user.decorator"; import { ok } from "../common/response"; +import { requestContextFrom } from "../abuse/abuse.types"; import { ViewService } from "./view.service"; @Controller("view") @@ -12,13 +14,15 @@ export class ViewController { @CurrentUser() userId: string, @Query("page") page = 1, @Query("limit") limit = 25, + @Req() req: Request, ) { - return ok(await this.viewService.accounts(userId, +page, +limit)); + return ok(await this.viewService.accounts(userId, +page, +limit, requestContextFrom(req))); } @Get("transactions") async transactions( @CurrentUser() userId: string, + @Req() req: Request, @Query("start_date") startDate?: string, @Query("end_date") endDate?: string, @Query("search") search?: string, @@ -41,6 +45,6 @@ export class ViewController { includeHidden, page: +page, limit: +limit, - })); + }, requestContextFrom(req))); } } diff --git a/src/view/view.service.ts b/src/view/view.service.ts index 0d43a55..428803b 100644 --- a/src/view/view.service.ts +++ b/src/view/view.service.ts @@ -1,7 +1,9 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, Optional } from "@nestjs/common"; import { Prisma } from "@prisma/client"; import { PrismaService } from "../prisma/prisma.service"; import { ViewRefService } from "../common/view-ref.service"; +import { AbuseService } from "../abuse/abuse.service"; +import { RequestContext } from "../abuse/abuse.types"; const VIEW_PAGE_SIZE_LIMIT = 25; @@ -10,11 +12,14 @@ export class ViewService { constructor( private readonly prisma: PrismaService, private readonly viewRefs: ViewRefService, + @Optional() private readonly abuseService?: AbuseService, ) {} - async accounts(userId: string, page = 1, limit = VIEW_PAGE_SIZE_LIMIT) { + async accounts(userId: string, page = 1, limit = VIEW_PAGE_SIZE_LIMIT, context?: RequestContext) { const take = this.clampLimit(limit); - const skip = (Math.max(page, 1) - 1) * take; + const normalizedPage = Math.max(page, 1); + await this.abuseService?.recordPaginationActivity(userId, "view.accounts", normalizedPage, limit, take, context); + const skip = (normalizedPage - 1) * take; const [accounts, total] = await Promise.all([ this.prisma.account.findMany({ where: { userId, isActive: true }, @@ -65,7 +70,7 @@ export class ViewService { lastSyncAttemptAt: account.lastSyncAttemptAt, })), total, - page: Math.max(page, 1), + page: normalizedPage, limit: take, }; } @@ -84,6 +89,7 @@ export class ViewService { page?: number; limit?: number; }, + context?: RequestContext, ) { const end = filters.endDate ? new Date(filters.endDate) : new Date(); const start = filters.startDate @@ -114,6 +120,7 @@ export class ViewService { const take = this.clampLimit(filters.limit); const page = Math.max(filters.page ?? 1, 1); + await this.abuseService?.recordPaginationActivity(userId, "view.transactions", page, filters.limit, take, context); const skip = (page - 1) * take; const [rows, total] = await Promise.all([ this.prisma.transactionRaw.findMany({ diff --git a/test/abuse.service.spec.ts b/test/abuse.service.spec.ts index eb69e96..40c1331 100644 --- a/test/abuse.service.spec.ts +++ b/test/abuse.service.spec.ts @@ -4,6 +4,7 @@ const createService = () => { const prisma = { abuseEvent: { create: jest.fn(), + count: jest.fn(), findMany: jest.fn(), }, exportLog: { @@ -46,4 +47,36 @@ describe("AbuseService", () => { data: expect.objectContaining({ eventType: "EXPORT_REPEATED", riskPoints: 20, severity: "high" }), })); }); + + it("records pagination abuse signals for oversized and deep page requests", async () => { + const { service, prisma } = createService(); + prisma.abuseEvent.count.mockResolvedValue(11); + prisma.abuseEvent.create.mockResolvedValue({ id: "evt_1" }); + + await service.recordPaginationActivity("user_1", "view.transactions", 100, 500, 25, { + ipAddress: "127.0.0.1", + userAgent: "jest", + }); + + expect(prisma.abuseEvent.count).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ + userId: "user_1", + eventType: { in: ["PAGINATION_LIMIT_CLAMPED", "PAGINATION_DEEP_SCAN"] }, + }), + })); + expect(prisma.abuseEvent.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + eventType: "PAGINATION_LIMIT_CLAMPED", + riskPoints: 10, + severity: "medium", + }), + })); + expect(prisma.abuseEvent.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + eventType: "PAGINATION_DEEP_SCAN", + riskPoints: 20, + severity: "high", + }), + })); + }); }); diff --git a/test/utils/mock-prisma.ts b/test/utils/mock-prisma.ts index a2e55f1..c1282ee 100644 --- a/test/utils/mock-prisma.ts +++ b/test/utils/mock-prisma.ts @@ -82,6 +82,7 @@ export const createPrismaMock = () => ({ }, abuseEvent: { create: jest.fn(), + count: jest.fn(), findMany: jest.fn() }, notificationPreference: {