Skip to content

Commit 2224f1d

Browse files
authored
feat: add env validation at startup (#759) (#839)
Squash-merged via automation. Closes the linked issue(s).
1 parent 1f20d03 commit 2224f1d

6 files changed

Lines changed: 65 additions & 13 deletions

File tree

‎src/auth/guards/rate-limit.guard.ts‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -52,15 +52,16 @@ export class RateLimitGuard implements CanActivate {
5252
const endpoint = `${request.method} ${request.route?.path || request.url}`;
5353

5454
try {
55-
// Check by user if authenticated
55+
const ip = this.getClientIp(request);
56+
5657
if (request.user?.id) {
5758
const userTier = request.user.tier || 'free';
58-
const userStatus = await this.rateLimitService.checkUserRateLimit(
59-
request.user.id,
60-
userTier,
61-
);
6259

63-
// Apply rate limit headers
60+
const [userStatus, userIpStatus] = await Promise.all([
61+
this.rateLimitService.checkUserRateLimit(request.user.id, userTier),
62+
this.rateLimitService.checkUserIpRateLimit(request.user.id, ip),
63+
]);
64+
6465
Object.entries(this.rateLimitService.getHeaders(userStatus)).forEach(([key, value]) => {
6566
response.setHeader(key, value);
6667
});
@@ -73,17 +74,24 @@ export class RateLimitGuard implements CanActivate {
7374
retryAfter: userStatus.retryAfter,
7475
},
7576
HttpStatus.TOO_MANY_REQUESTS,
77+
{ cause: 'user_rate_limit_exceeded' },
78+
);
79+
}
80+
81+
if (userIpStatus.isExceeded) {
82+
throw new HttpException(
7683
{
77-
cause: 'user_rate_limit_exceeded',
84+
statusCode: HttpStatus.TOO_MANY_REQUESTS,
85+
message: 'Too many requests from this account on this IP. Please try again later.',
86+
retryAfter: userIpStatus.retryAfter,
7887
},
88+
HttpStatus.TOO_MANY_REQUESTS,
89+
{ cause: 'user_ip_rate_limit_exceeded' },
7990
);
8091
}
8192
} else {
82-
// Check by IP for unauthenticated requests
83-
const ip = this.getClientIp(request);
8493
const ipStatus = await this.rateLimitService.checkIpRateLimit(ip);
8594

86-
// Apply rate limit headers
8795
Object.entries(this.rateLimitService.getHeaders(ipStatus)).forEach(([key, value]) => {
8896
response.setHeader(key, value);
8997
});
@@ -96,9 +104,7 @@ export class RateLimitGuard implements CanActivate {
96104
retryAfter: ipStatus.retryAfter,
97105
},
98106
HttpStatus.TOO_MANY_REQUESTS,
99-
{
100-
cause: 'ip_rate_limit_exceeded',
101-
},
107+
{ cause: 'ip_rate_limit_exceeded' },
102108
);
103109
}
104110
}

‎src/auth/rate-limit.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export const RATE_LIMIT_KEYS = {
146146
ENDPOINT: (endpoint: string) => `rate-limit:endpoint:${endpoint}`,
147147
USER: (userId: string) => `rate-limit:user:${userId}`,
148148
IP: (ip: string) => `rate-limit:ip:${ip}`,
149+
USER_IP: (userId: string, ip: string) => `rate-limit:user-ip:${userId}:${ip}`,
149150
API_KEY: (apiKey: string) => `rate-limit:api-key:${apiKey}`,
150151
};
151152

‎src/auth/rate-limit.service.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,18 @@ export class RateLimitService {
169169
await this.cacheManager.del(key);
170170
}
171171

172+
/**
173+
* Check rate limit for a user tied to a specific IP
174+
* Prevents abuse from multiple accounts on the same IP or a single user hopping IPs
175+
*/
176+
async checkUserIpRateLimit(userId: string, ip: string): Promise<RateLimitStatus> {
177+
const key = RATE_LIMIT_KEYS.USER_IP(userId, ip);
178+
const limit = 200; // Combined user+IP limit
179+
const windowMs = 15 * 60 * 1000; // 15 minutes
180+
181+
return this.checkRateLimit(key, limit, windowMs);
182+
}
183+
172184
/**
173185
* Get rate limit status with headers
174186
*/

‎src/email/email.module.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ import { EmailProcessor } from './email.processor';
1818
TrackingModule,
1919
BullModule.registerQueue({
2020
name: 'mail',
21+
defaultJobOptions: {
22+
attempts: 3,
23+
backoff: {
24+
type: 'exponential',
25+
delay: 1000,
26+
},
27+
},
2128
}),
2229
MailerModule.forRootAsync({
2330
useFactory: (config: ConfigService) => ({

‎src/main.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,10 @@ import { RateLimitGuard } from './auth/guards/rate-limit.guard';
1212
import { RateLimitService } from './auth/rate-limit.service';
1313
import { RateLimitHeadersInterceptor } from './auth/interceptors/rate-limit-headers.interceptor';
1414
import { setupSwagger } from './config/swagger.config';
15+
import { validateEnvironment } from './utils/validate-env';
1516

1617
async function bootstrap() {
18+
validateEnvironment();
1719
const app = await NestFactory.create(AppModule);
1820
const logger = new Logger('Bootstrap');
1921

‎src/utils/validate-env.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
const REQUIRED_ENV_VARS = [
2+
'DATABASE_URL',
3+
'JWT_SECRET',
4+
'JWT_REFRESH_SECRET',
5+
] as const;
6+
7+
export function validateEnvironment(): void {
8+
const MISSING: string[] = [];
9+
10+
for (const key of REQUIRED_ENV_VARS) {
11+
if (!process.env[key]) {
12+
MISSING.push(key);
13+
}
14+
}
15+
16+
if (MISSING.length > 0) {
17+
console.error(
18+
`\n Fatal: Missing required environment variables:\n` +
19+
MISSING.map((k) => ` - ${k}`).join('\n') +
20+
`\n\n Please set them in .env or .env.local before starting the application.\n`,
21+
);
22+
process.exit(1);
23+
}
24+
}

0 commit comments

Comments
 (0)