Anti-spam requests with Nginx, NextJS and NestJS
Introduction
In the previous article, I provided instructions on using NextJS Proxy to check API rate limits simply. However, that application method has the following scalability flaws:
- Using lru-cache only stores data in memory, so when scaling to multiple pods, the rate limit check will be incorrect because pods do not share data with each other.
- In actual deployment, you rarely let the NextJS server receive requests directly like that, but instead use additional CDNs (Cloudfront, Nginx) to take advantage of edge locations and their data caching capabilities.
Therefore, in this article, I will provide a more comprehensive implementation from CDN, NextJS and NestJS servers to handle request spamming, including:
- Blacklist: automatically block IPs marked as attacking the system.
- Whitelist: add static IPs and only allow these IPs to use important services, such as allowing partner IPs to use services or deploying internal services accessible only via company VPN.
- Rate Limit: limit the number of requests within a period of time.
- Payload size: limit the size of the request payload.
- Using Redis as a centralized data store, so no matter how you scale, IP checking and blocking will be performed effectively.
We will set up the roles of the components as follows:
- Nginx: blocks payload size and rate limits for most common requests, applies IP whitelisting for important services and acts as an intermediate layer to forward requests.
- NextJS server: acts as a BFF (Backend for frontend), an intermediate layer to enhance security (to avoid CORS, check cookie and token, used as an aggregator to reduce the actual number of requests to the core service).
- NestJS server: the core service. Once a request reaches here, it has passed through most of the above layers. If it violates your business rules (such as data retrieval frequency or too many incorrect password entries), it can be blocked right here.
Prerequisites
In this article, I will reuse content from previous articles regarding proxies, rewrites, Redis and JWT, so I will not mention them specifically here. If there is any part where you do not see the corresponding source code, please review the previous articles.
Detail
NestJS project
Create the file assets/ip-whitelist.json to define whitelisted IPs:
{
"whitelist": [
"1.2.3.4",
"5.6.7.8"
]
}
Create the file service/common.service.ts:
import {Injectable} from '@nestjs/common'
import * as express from 'express'
@Injectable()
export class CommonService {
getIp(req: express.Request): string {
const unknown = 'unknown'
try {
let ip = (req.headers['x-forwarded-for'] ||
req.headers['x-real-ip'] ||
req.ip ||
'') as string
ip = ip.split[','](0).trim()
ip = ip.replace('::ffff:', '')
return ip || 'unknown'
} catch {
return unknown
}
}
}
- x-forwarded-for: a list of IPs received if the request has been forwarded through many places. Take the first value, which can be considered the closest value before entering Nginx.
- x-real-ip
- replace('::ffff:', ''): used to convert IPv6 to IPv4 for synchronized processing.
Create the file guard/ip-white-list.guard.ts. This guard is only used to check if the request IP belongs to the whitelist to use internal services:
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common'
import SECURITY from 'src/assets/ip-whitelist.json'
import {CommonService} from 'src/service/common.service'
@Injectable()
export class IpWhitelistGuard implements CanActivate {
constructor(private readonly commonService: CommonService) {}
private readonly authorizedIps = SECURITY.whitelist
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest()
const clientIp = this.commonService.getIp(request)
if (!this.authorizedIps.includes(clientIp)) {
throw new ForbiddenException(
`Access denied. IP address ${clientIp} is not authorized for this operation.`
)
}
return true
}
}
Create the file controller/system.controller.ts to use IpWhitelistGuard:
import {Controller, Post, UseGuards} from '@nestjs/common'
import {IpWhitelistGuard} from 'src/guard/ip-white-list.guard'
@Controller('system')
export class SystemController {
@Post('maintenance-mode')
@UseGuards(IpWhitelistGuard)
toggleMaintenance() {
return {message: 'System maintenance mode has been toggled successfully.'}
}
}
Create the file service/auth.service.ts:
import {
ForbiddenException,
Inject,
Injectable,
UnauthorizedException,
} from '@nestjs/common'
import {JwtService} from '@nestjs/jwt'
import * as express from 'express'
import Redis from 'ioredis'
import {LoginDto} from 'src/dto/auth.dto'
import {CommonService} from './common.service'
import {EnvironmentService} from './environment.service'
const MAX_RETRIES = 5
@Injectable()
export class AuthService {
constructor(
private readonly jwtService: JwtService,
private readonly envService: EnvironmentService,
private readonly commonService: CommonService,
@Inject('VALKEY_CLIENT') private readonly valkey: Redis
) {}
async login(info: LoginDto, req: express.Request): Promise<any> {
const clientIp = this.commonService.getIp(req)
const loginFailKey = `login_fail_count:${clientIp}`
const isLocked = await this.valkey.get(`blacklist:${clientIp}`)
if (isLocked) {
throw new ForbiddenException(
'Your IP address is temporarily blocked due to multiple failed login attempts. Please try again in 15 minutes.'
)
}
const user = USERS.find(
u => u.username === info.username && u.password === info.password
)
if (!user) {
const currentFails = await this.valkey.incr(loginFailKey)
await this.valkey.expire(loginFailKey, 300, 'NX')
if (currentFails >= MAX_RETRIES) {
await this.valkey.set(
`blacklist:${clientIp}`,
'BANNED_BRUTE_FORCE',
'EX',
900
)
await this.valkey.del(loginFailKey)
throw new ForbiddenException(
'Too many failed attempts. Your IP address has been blocked for 15 minutes.'
)
}
throw new UnauthorizedException(
`Invalid credentials. You have ${MAX_RETRIES - currentFails} attempts remaining.`
)
}
await this.valkey.del(loginFailKey)
return this.getTokens({username: user.username})
}
}
This code is only used to check if a user enters the wrong password more than 5 times, then block this IP by storing it in Redis. You can change it to the corresponding logic according to your needs.
Create the file controller/auth.controller.ts:
import {
Body,
Controller,
Get,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from '@nestjs/common'
import express from 'express'
import {LoginDto} from 'src/dto/auth.dto'
import {JwtRefreshAuthGuard} from 'src/guard/jwt-refresh.guard'
import {JwtAuthGuard} from 'src/guard/jwt.guard'
import {AuthService} from 'src/service/auth.service'
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
async login(
@Body() body: LoginDto,
@Req() request: express.Request,
@Res({passthrough: true}) response: express.Response
) {
const token = await this.authService.login(body, request)
response.cookie('token', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
})
return token
}
}
Update the file app.module.ts to import controllers and services. Note that ValkeyModule is the module to connect to Redis:
import {Module} from '@nestjs/common'
import {ConfigModule} from '@nestjs/config'
import {AuthController} from './controller/auth.controller'
import {SystemController} from './controller/system.controller'
import {TestController} from './controller/test.controller'
import {IpWhitelistGuard} from './guard/ip-white-list.guard'
import {ValkeyModule} from './module/valkey.module'
import {AuthService} from './service/auth.service'
import {CommonService} from './service/common.service'
import {EnvironmentService} from './service/environment.service'
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
ValkeyModule,
],
controllers: [
TestController,
AuthController,
SystemController,
],
providers: [
IpWhitelistGuard,
EnvironmentService,
CommonService,
AuthService,
],
})
export class AppModule {}
NextJS project
Create a .env file containing Redis information:
# Redis
REDIS_HOST = <REDIS_HOST>
REDIS_PORT = <REDIS_PORT>
Create the file lib/common.ts similar to NestJS above to get the IP value:
export function getIp(headers: Headers): string {
const unknown = 'unknown'
try {
let ip = (headers.get('x-forwarded-for') ||
headers.get('x-real-ip') ||
'') as string
ip = ip.split[','](0).trim()
ip = ip.replace('::ffff:', '')
return ip || unknown
} catch {
return unknown
}
}
Create the file lib/redis.ts to connect to Redis:
import Redis from 'ioredis'
const globalForRedis = global as unknown as {redis: Redis}
export const redisClient =
globalForRedis.redis ||
new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
connectTimeout: 10000,
maxRetriesPerRequest: 3,
enableOfflineQueue: true,
})
if (process.env.NODE_ENV !== 'production') globalForRedis.redis = redisClient
redisClient.on('error', err => {
console.error('❌ Redis Connection Error:', err)
})
redisClient.on('connect', () => {
console.log('✅ Connected to Redis')
})
Update the file next.config.ts:
import type {NextConfig} from 'next'
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: process.env.SERVER_HOST + '/:path*',
},
]
},
}
export default nextConfig
Create the file proxy.ts:
import type {NextRequest} from 'next/server'
import {NextResponse} from 'next/server'
import {getIp} from './lib/common'
import {redisClient} from './lib/redis'
const MAX_PAYLOAD_SIZE = 2 * 1024 * 1024
export async function proxy(request: NextRequest) {
const ip = getIp(request.headers)
try {
const isBlacklisted = await redisClient.get(`blacklist:${ip}`)
if (isBlacklisted) {
return NextResponse.json(
{
message:
'Access denied. Your IP address has been blocked due to policy violations.',
},
{status: 403}
)
}
const contentLength = request.headers.get('content-length')
if (contentLength && parseInt(contentLength, 10) > MAX_PAYLOAD_SIZE) {
return NextResponse.json(
{
message: 'Request payload too large! Maximum allowed size is 2MB.',
},
{status: 413}
)
}
return NextResponse.next()
} catch (error) {
console.error('Proxy Security Error:', error)
return NextResponse.next()
}
}
export const config = {
matcher: ['/api/:path*'],
}
- Uses Redis to immediately block IPs in the blacklist.
- Blocks payload sizes larger than 2MB.
Create the file app/page/hook.ts:
import {useMutation} from '@tanstack/react-query'
interface ApiResponse {
message: string
}
interface CreatePayload {
name: string
}
export interface LoginPayload {
username?: string
password?: string
}
const requestPostApi = async (
url: string,
payload: any
): Promise<ApiResponse> => {
const isFormData = payload instanceof FormData
const response = await fetch(url, {
method: 'POST',
headers: isFormData ? {} : {'Content-Type': 'application/json'},
body: isFormData ? payload : JSON.stringify(payload),
})
return response.json()
}
export function usePostMutation<TVariables = any>(url: string) {
return useMutation({
mutationFn: (payload: TVariables) => requestPostApi(url, payload),
})
}
export function useLogin() {
return usePostMutation<LoginPayload>('/api/auth/login')
}
export function useCreate() {
return usePostMutation<CreatePayload>('/api/test/create')
}
export function useMaintenance() {
return usePostMutation<void>('/api/system/maintenance-mode')
}
export function useUpload() {
return usePostMutation<FormData>('/api/test/upload')
}
Create the file app/page/page.tsx:
'use client'
import {
MessageOutlined,
PlusOutlined,
SettingOutlined,
UploadOutlined,
} from '@ant-design/icons'
import {Button, Card, Upload} from 'antd'
import {useCreate, useMaintenance, useUpload} from './hook'
export default function WelcomePage() {
const createMutation = useCreate()
const maintenanceMutation = useMaintenance()
const uploadMutation = useUpload()
const handleCreateData = () => {
createMutation.mutate({name: 'Test Item'})
}
const handleToggleMaintenance = () => {
maintenanceMutation.mutate()
}
const handleUploadFile = (file: File) => {
const formData = new FormData()
formData.append('file', file)
uploadMutation.mutate(formData)
return false
}
const displayMessage =
createMutation.data?.message ||
maintenanceMutation.data?.message ||
uploadMutation.data?.message
return (
<div className="flex flex-col gap-4 items-center justify-center min-h-[400px] p-6 bg-gray-50 rounded-xl border border-gray-200 max-w-md mx-auto my-10 shadow-sm">
<h2 className="text-2xl font-bold text-gray-800 mb-2 text-center">
Welcome Back!
</h2>
<p className="text-sm text-gray-500 text-center mb-6">
You have successfully logged in.
</p>
<Button
type="primary"
size="large"
icon={<PlusOutlined />}
onClick={handleCreateData}
className="bg-green-600 hover:bg-green-500 font-medium rounded-lg px-8 py-5 flex items-center justify-center w-full shadow-md border-none"
>
Test Create API
</Button>
<Button
type="default"
size="large"
icon={<SettingOutlined />}
onClick={handleToggleMaintenance}
className="font-medium rounded-lg px-8 py-5 flex items-center justify-center w-full shadow-md"
>
Toggle Maintenance Mode
</Button>
<Upload
beforeUpload={handleUploadFile}
showUploadList={false}
className="w-full"
style={{width: '100%'}}
>
<Button
size="large"
icon={<UploadOutlined />}
className="font-medium rounded-lg px-8 py-5 flex items-center justify-center w-full shadow-md"
>
Upload File
</Button>
</Upload>
<div className="w-full mt-2">
{displayMessage && (
<Card
title={
<div className="flex items-center gap-2 text-gray-700">
<MessageOutlined />
<span>Response Data</span>
</div>
}
className="w-full border border-gray-200 shadow-sm rounded-lg animate-in fade-in duration-300"
styles={{header: {backgroundColor: '#f9fafb'}}}
>
<p className="text-gray-700 font-mono bg-gray-50 p-3 rounded border border-gray-100 break-words text-sm">
{displayMessage}
</p>
</Card>
)}
</div>
</div>
)
}
Nginx
Create a whitelist.conf file containing whitelisted IPs:
allow 1.2.3.4;
allow 5.6.7.8;
Create the default.conf file:
# Define Rate Limit zone (DDoS protection support)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=50r/s;
server {
listen 80;
client_max_body_size 1M;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location /api/payments/webhook {
include /etc/nginx/whitelist.conf;
deny all;
proxy_pass http://host.docker.internal:4000;
}
location / {
proxy_pass http://host.docker.internal:3000;
proxy_set_header X-Real-IP $remote_addr;
limit_req zone=api_limit burst=100 nodelay;
}
}
- client_max_body_size 1M: limits the maximum payload to 1MB.
- location /api/payments/webhook: configures the whitelist for APIs requiring high security.
- location /: to forward requests to the NextJS server.
- proxy_set_header X-Real-IP $remote_addr: Nginx takes the IP of the device that just connected to it (stored in the $remote_addr variable) and attaches it to the X-Real-IP header. Thanks to this config, you can access this value in the NextJS and NestJS code above.
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=50r/s
limit_req zone=api_limit burst=100 nodelay;
- creates an api_limit zone, which is a memory area with a size of 10MB, using the IP as a key and storing it in binary form, so with 10MB we can store up to hundreds of thousands of IPs.
- limits allowing 50 requests per second. If this level is exceeded, Nginx will block or queue them.
- burst is the queue that will contain 100 requests. If this level is exceeded, Nginx will block that IP.
- nodelay: will immediately process requests in the burst queue.
Usage example as follows:
- If sending 50 requests/s, it still goes through normally.
- If sending 120 requests/s, 50 will be processed immediately, the remaining 70 will fall into the burst queue. Because nodelay is configured, Nginx will also process requests in this queue immediately. But if it exceeds 100 in this queue, Nginx will return a 503 Service Unavailable error.
Create the docker-compose.yml file:
services:
serviceName:
image: nginx:alpine
container_name: nginx
ports:
- 8080:80
volumes:
- ./default.conf:/etc/nginx/conf.d/default.conf:ro
- ./whitelist.conf:/etc/nginx/whitelist.conf:ro
Then start NextJS, NextJS project (you should build these projects before starting them to avoid socket connection errors in the development environment) and Docker in order:
docker compose up -d
Then, access http://localhost:8080 or whichever port you have configured for Nginx. Nginx will connect to NextJS as a proxy and will connect to NestJS as the core service when calling the API. Check the results as follows.
You can check when reaching the limit as follows:
When reaching the payload size limit, it will be blocked immediately at Nginx.
If the IP is not in the whitelist, internal services cannot be used.
Happy coding!
Comments
Post a Comment