Add pagination abuse risk signals

This commit is contained in:
MOHAN 2026-07-16 22:11:14 +05:30
parent 5dc1cd796b
commit d51fc3b954
5 changed files with 114 additions and 7 deletions

View File

@ -15,6 +15,7 @@ type RecordAbuseEventInput = RequestContext & {
}; };
const RISK_WINDOW_HOURS = 24; const RISK_WINDOW_HOURS = 24;
const PAGINATION_EVENT_TYPES = ["PAGINATION_LIMIT_CLAMPED", "PAGINATION_DEEP_SCAN"] as const;
@Injectable() @Injectable()
export class AbuseService { 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) { async getRiskProfile(userId: string) {
const since = new Date(Date.now() - RISK_WINDOW_HOURS * 60 * 60 * 1000); const since = new Date(Date.now() - RISK_WINDOW_HOURS * 60 * 60 * 1000);
const events = await this.prisma.abuseEvent.findMany({ const events = await this.prisma.abuseEvent.findMany({

View File

@ -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 { CurrentUser } from "../common/decorators/current-user.decorator";
import { ok } from "../common/response"; import { ok } from "../common/response";
import { requestContextFrom } from "../abuse/abuse.types";
import { ViewService } from "./view.service"; import { ViewService } from "./view.service";
@Controller("view") @Controller("view")
@ -12,13 +14,15 @@ export class ViewController {
@CurrentUser() userId: string, @CurrentUser() userId: string,
@Query("page") page = 1, @Query("page") page = 1,
@Query("limit") limit = 25, @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") @Get("transactions")
async transactions( async transactions(
@CurrentUser() userId: string, @CurrentUser() userId: string,
@Req() req: Request,
@Query("start_date") startDate?: string, @Query("start_date") startDate?: string,
@Query("end_date") endDate?: string, @Query("end_date") endDate?: string,
@Query("search") search?: string, @Query("search") search?: string,
@ -41,6 +45,6 @@ export class ViewController {
includeHidden, includeHidden,
page: +page, page: +page,
limit: +limit, limit: +limit,
})); }, requestContextFrom(req)));
} }
} }

View File

@ -1,7 +1,9 @@
import { Injectable } from "@nestjs/common"; import { Injectable, Optional } from "@nestjs/common";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import { ViewRefService } from "../common/view-ref.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; const VIEW_PAGE_SIZE_LIMIT = 25;
@ -10,11 +12,14 @@ export class ViewService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly viewRefs: ViewRefService, 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 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([ const [accounts, total] = await Promise.all([
this.prisma.account.findMany({ this.prisma.account.findMany({
where: { userId, isActive: true }, where: { userId, isActive: true },
@ -65,7 +70,7 @@ export class ViewService {
lastSyncAttemptAt: account.lastSyncAttemptAt, lastSyncAttemptAt: account.lastSyncAttemptAt,
})), })),
total, total,
page: Math.max(page, 1), page: normalizedPage,
limit: take, limit: take,
}; };
} }
@ -84,6 +89,7 @@ export class ViewService {
page?: number; page?: number;
limit?: number; limit?: number;
}, },
context?: RequestContext,
) { ) {
const end = filters.endDate ? new Date(filters.endDate) : new Date(); const end = filters.endDate ? new Date(filters.endDate) : new Date();
const start = filters.startDate const start = filters.startDate
@ -114,6 +120,7 @@ export class ViewService {
const take = this.clampLimit(filters.limit); const take = this.clampLimit(filters.limit);
const page = Math.max(filters.page ?? 1, 1); 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 skip = (page - 1) * take;
const [rows, total] = await Promise.all([ const [rows, total] = await Promise.all([
this.prisma.transactionRaw.findMany({ this.prisma.transactionRaw.findMany({

View File

@ -4,6 +4,7 @@ const createService = () => {
const prisma = { const prisma = {
abuseEvent: { abuseEvent: {
create: jest.fn(), create: jest.fn(),
count: jest.fn(),
findMany: jest.fn(), findMany: jest.fn(),
}, },
exportLog: { exportLog: {
@ -46,4 +47,36 @@ describe("AbuseService", () => {
data: expect.objectContaining({ eventType: "EXPORT_REPEATED", riskPoints: 20, severity: "high" }), 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",
}),
}));
});
}); });

View File

@ -82,6 +82,7 @@ export const createPrismaMock = () => ({
}, },
abuseEvent: { abuseEvent: {
create: jest.fn(), create: jest.fn(),
count: jest.fn(),
findMany: jest.fn() findMany: jest.fn()
}, },
notificationPreference: { notificationPreference: {