Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,19 @@ RESEND_FROM_EMAIL="noreply@yourdomain.com"
# Public app URL used in invite/task links inside emails.
APP_URL="http://localhost:3000"

# Rate limiting (milliseconds for *_TTL_MS).
RATE_LIMIT_DEFAULT_LIMIT="120"
RATE_LIMIT_DEFAULT_TTL_MS="60000"
RATE_LIMIT_AUTH_VERIFY_LIMIT="20"
RATE_LIMIT_AUTH_VERIFY_TTL_MS="60000"
RATE_LIMIT_TEAM_INVITE_LIMIT="6"
RATE_LIMIT_TEAM_INVITE_TTL_MS="600000"
RATE_LIMIT_TEAM_JOIN_LIMIT="20"
RATE_LIMIT_TEAM_JOIN_TTL_MS="600000"
RATE_LIMIT_TASK_WRITE_LIMIT="90"
RATE_LIMIT_TASK_WRITE_TTL_MS="60000"
RATE_LIMIT_CHAT_WRITE_LIMIT="120"
RATE_LIMIT_CHAT_WRITE_TTL_MS="60000"

# NestJS API port.
PORT="4000"
9 changes: 5 additions & 4 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,17 @@
"test:cov": "jest --coverage"
},
"dependencies": {
"@repo/types": "workspace:*",
"@react-email/components": "^0.0.28",
"@react-email/render": "^1.0.1",
"@nestjs/common": "^10.4.5",
"@nestjs/core": "^10.4.5",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.6",
"@nestjs/passport": "^10.0.3",
"@nestjs/core": "^10.4.5",
"@nestjs/platform-express": "^10.4.5",
"@nestjs/throttler": "^5.2.0",
"@prisma/client": "^5.20.0",
"@react-email/components": "^0.0.28",
"@react-email/render": "^1.0.1",
"@repo/types": "workspace:*",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"passport": "^0.7.0",
Expand Down
5 changes: 3 additions & 2 deletions apps/api/src/app.module.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ import { MODULE_METADATA } from "@nestjs/common/constants";
import { RolesGuard } from "./auth/guards/roles.guard";
import { JwtAuthGuard } from "./auth/guards/jwt-auth.guard";
import { AppModule } from "./app.module";
import { AppThrottlerGuard } from "./security/guards/app-throttler.guard";

describe("AppModule guard registration", () => {
it("registers only JwtAuthGuard as APP_GUARD", () => {
it("registers JwtAuthGuard and AppThrottlerGuard as APP_GUARD", () => {
const providers = (Reflect.getMetadata(MODULE_METADATA.PROVIDERS, AppModule) ?? []) as Array<{
provide?: unknown;
useClass?: unknown;
Expand All @@ -16,7 +17,7 @@ describe("AppModule guard registration", () => {
.filter((provider) => provider.provide === APP_GUARD)
.map((provider) => provider.useClass);

expect(appGuards).toEqual([JwtAuthGuard]);
expect(appGuards).toEqual([JwtAuthGuard, AppThrottlerGuard]);
expect(appGuards).not.toContain(RolesGuard);
});
});
8 changes: 8 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { ThrottlerModule } from "@nestjs/throttler";

import { AuthModule } from "./auth/auth.module";
import { JwtAuthGuard } from "./auth/guards/jwt-auth.guard";
import { MailModule } from "./mail/mail.module";
import { PrismaModule } from "./prisma/prisma.module";
import { ProjectChatModule } from "./project-chat/project-chat.module";
import { ProjectsModule } from "./projects/projects.module";
import { AppThrottlerGuard } from "./security/guards/app-throttler.guard";
import { getGlobalThrottlerOptions } from "./security/throttling.config";
import { TasksModule } from "./tasks/tasks.module";
import { TeamsModule } from "./teams/teams.module";
import { UsersModule } from "./users/users.module";

@Module({
imports: [
ThrottlerModule.forRoot(getGlobalThrottlerOptions()),
PrismaModule,
MailModule,
AuthModule,
Expand All @@ -27,6 +31,10 @@ import { UsersModule } from "./users/users.module";
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: AppThrottlerGuard,
},
],
})
export class AppModule {}
3 changes: 3 additions & 0 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { Body, Controller, Headers, Post, UnauthorizedException } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import type { User, VerifyTokenResponse } from "@repo/types";

import { Public } from "./decorators/public.decorator";
import { isAuthBridgeSecretValid } from "./auth.config";
import { VerifyTokenDto } from "./dto/verify-token.dto";
import { AuthService } from "./auth.service";
import { THROTTLE_PRESETS } from "../security/throttling.config";

@Controller("auth")
export class AuthController {
constructor(private readonly authService: AuthService) {}

@Public()
@Throttle(THROTTLE_PRESETS.AUTH_VERIFY)
@Post("verify-token")
async verifyToken(
@Body() dto: VerifyTokenDto,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/src/project-chat/project-chat.controller.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { Body, Controller, Get, Param, Post, UseGuards } from "@nestjs/common";
import { Throttle } from "@nestjs/throttler";
import type { ProjectChatMessage } from "@repo/types";

import { CurrentUser } from "../auth/decorators/current-user.decorator";
import type { AuthUser } from "../auth/interfaces/auth-user.interface";
import { THROTTLE_PRESETS } from "../security/throttling.config";
import { ProjectMemberGuard } from "../tasks/guards/project-member.guard";

import { CreateProjectChatMessageDto } from "./dto/create-project-chat-message.dto";
Expand All @@ -18,6 +20,7 @@ export class ProjectChatController {
return this.projectChatService.listMessages(projectId);
}

@Throttle(THROTTLE_PRESETS.CHAT_WRITE)
@Post()
createMessage(
@Param("projectId") projectId: string,
Expand Down
45 changes: 45 additions & 0 deletions apps/api/src/security/guards/app-throttler.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { AppThrottlerGuard } from "./app-throttler.guard";

function createGuard(): AppThrottlerGuard {
return new AppThrottlerGuard([] as never, {} as never, {} as never);
}

describe("AppThrottlerGuard", () => {
it("prefers authenticated user id as tracker", async () => {
const guard = createGuard();

const tracker = await (guard as any).getTracker({
user: { sub: "user_123" },
});

expect(tracker).toBe("user:user_123");
});

it("falls back to first trusted proxy IP", async () => {
const guard = createGuard();

const tracker = await (guard as any).getTracker({
ips: ["203.0.113.9", "198.51.100.42"],
});

expect(tracker).toBe("ip:203.0.113.9");
});

it("uses req.ip when user and forwarded headers are missing", async () => {
const guard = createGuard();

const tracker = await (guard as any).getTracker({
ip: "127.0.0.1",
});

expect(tracker).toBe("ip:127.0.0.1");
});

it("falls back to unknown tracker when no user or IP is available", async () => {
const guard = createGuard();

const tracker = await (guard as any).getTracker({});

expect(tracker).toBe("ip:unknown");
});
});
29 changes: 29 additions & 0 deletions apps/api/src/security/guards/app-throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Injectable } from "@nestjs/common";
import { ThrottlerGuard } from "@nestjs/throttler";

import type { AuthUser } from "../../auth/interfaces/auth-user.interface";

@Injectable()
export class AppThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: Record<string, unknown>): Promise<string> {
const user = req.user as AuthUser | undefined;
if (user?.sub) {
return `user:${user.sub}`;
}

const requestIps = req.ips;
if (Array.isArray(requestIps) && requestIps.length > 0 && typeof requestIps[0] === "string") {
const firstIp = requestIps[0].trim();
if (firstIp.length > 0) {
return `ip:${firstIp}`;
}
}

const requestIp = req.ip;
if (typeof requestIp === "string" && requestIp.trim().length > 0) {
return `ip:${requestIp.trim()}`;
}

return "ip:unknown";
}
}
71 changes: 71 additions & 0 deletions apps/api/src/security/throttling-metadata.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { AuthController } from "../auth/auth.controller";
import { ProjectChatController } from "../project-chat/project-chat.controller";
import { TasksController } from "../tasks/tasks.controller";
import { TeamsController } from "../teams/teams.controller";

import { THROTTLE_PRESETS } from "./throttling.config";

const LIMIT_METADATA_KEY = "THROTTLER:LIMITdefault";
const TTL_METADATA_KEY = "THROTTLER:TTLdefault";

function expectThrottleMetadata(handler: unknown, limit: number, ttl: number): void {
const actualLimit = Reflect.getMetadata(LIMIT_METADATA_KEY, handler as object) as
| number
| undefined;
const actualTtl = Reflect.getMetadata(TTL_METADATA_KEY, handler as object) as number | undefined;

expect(actualLimit).toBe(limit);
expect(actualTtl).toBe(ttl);
}

describe("throttling metadata", () => {
it("applies auth verify-token throttle override", () => {
expectThrottleMetadata(
AuthController.prototype.verifyToken,
THROTTLE_PRESETS.AUTH_VERIFY.default.limit,
THROTTLE_PRESETS.AUTH_VERIFY.default.ttl,
);
});

it("applies team invite and join throttle overrides", () => {
expectThrottleMetadata(
TeamsController.prototype.inviteMember,
THROTTLE_PRESETS.TEAM_INVITE.default.limit,
THROTTLE_PRESETS.TEAM_INVITE.default.ttl,
);

expectThrottleMetadata(
TeamsController.prototype.joinTeam,
THROTTLE_PRESETS.TEAM_JOIN.default.limit,
THROTTLE_PRESETS.TEAM_JOIN.default.ttl,
);
});

it("applies task write throttle override to create, update, and delete", () => {
expectThrottleMetadata(
TasksController.prototype.createTask,
THROTTLE_PRESETS.TASK_WRITE.default.limit,
THROTTLE_PRESETS.TASK_WRITE.default.ttl,
);

expectThrottleMetadata(
TasksController.prototype.updateTask,
THROTTLE_PRESETS.TASK_WRITE.default.limit,
THROTTLE_PRESETS.TASK_WRITE.default.ttl,
);

expectThrottleMetadata(
TasksController.prototype.deleteTask,
THROTTLE_PRESETS.TASK_WRITE.default.limit,
THROTTLE_PRESETS.TASK_WRITE.default.ttl,
);
});

it("applies project chat write throttle override", () => {
expectThrottleMetadata(
ProjectChatController.prototype.createMessage,
THROTTLE_PRESETS.CHAT_WRITE.default.limit,
THROTTLE_PRESETS.CHAT_WRITE.default.ttl,
);
});
});
91 changes: 91 additions & 0 deletions apps/api/src/security/throttling.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { ThrottlerOptions } from "@nestjs/throttler";

type ThrottleWindow = {
readonly limit: number;
readonly ttl: number;
};

export type ThrottleDecoratorOptions = Record<"default", ThrottleWindow>;

function readPositiveIntegerEnv(name: string, fallback: number): number {
const rawValue = process.env[name]?.trim();

if (!rawValue) {
return fallback;
}

const parsedValue = Number.parseInt(rawValue, 10);
if (!Number.isInteger(parsedValue) || parsedValue <= 0) {
throw new Error(`${name} must be a positive integer.`);
}

return parsedValue;
}

function createThrottleWindow(
limitEnvName: string,
ttlEnvName: string,
fallbackLimit: number,
fallbackTtl: number,
): ThrottleDecoratorOptions {
const limit = readPositiveIntegerEnv(limitEnvName, fallbackLimit);
const ttl = readPositiveIntegerEnv(ttlEnvName, fallbackTtl);

return Object.freeze({
default: Object.freeze({
limit,
ttl,
}),
});
}

export const THROTTLE_PRESETS = Object.freeze({
DEFAULT: createThrottleWindow(
"RATE_LIMIT_DEFAULT_LIMIT",
"RATE_LIMIT_DEFAULT_TTL_MS",
120,
60_000,
),
AUTH_VERIFY: createThrottleWindow(
"RATE_LIMIT_AUTH_VERIFY_LIMIT",
"RATE_LIMIT_AUTH_VERIFY_TTL_MS",
20,
60_000,
),
TEAM_INVITE: createThrottleWindow(
"RATE_LIMIT_TEAM_INVITE_LIMIT",
"RATE_LIMIT_TEAM_INVITE_TTL_MS",
6,
600_000,
),
TEAM_JOIN: createThrottleWindow(
"RATE_LIMIT_TEAM_JOIN_LIMIT",
"RATE_LIMIT_TEAM_JOIN_TTL_MS",
20,
600_000,
),
TASK_WRITE: createThrottleWindow(
"RATE_LIMIT_TASK_WRITE_LIMIT",
"RATE_LIMIT_TASK_WRITE_TTL_MS",
90,
60_000,
),
CHAT_WRITE: createThrottleWindow(
"RATE_LIMIT_CHAT_WRITE_LIMIT",
"RATE_LIMIT_CHAT_WRITE_TTL_MS",
120,
60_000,
),
});

export function getGlobalThrottlerOptions(): ThrottlerOptions[] {
const globalWindow = THROTTLE_PRESETS.DEFAULT.default;

return [
{
name: "default",
limit: globalWindow.limit,
ttl: globalWindow.ttl,
},
];
}
Loading