Add pagination abuse risk signals
This commit is contained in:
parent
5dc1cd796b
commit
d51fc3b954
@ -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({
|
||||||
|
|||||||
@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user