Tại sao bảo mật Server Actions quan trọng hơn bạn nghĩ
Server Actions là một trong những tính năng mạnh mẽ nhất của Next.js App Router — cho phép bạn viết logic server ngay trong component React mà không cần tạo API route riêng. Tiện lợi? Cực kỳ. Nhưng thành thật mà nói, chính sự tiện lợi đó lại tạo ra một ảo tưởng an toàn khá nguy hiểm.
Mình đã thấy không ít dự án production mắc phải lỗi này. Và sự thật phũ phàng thì vẫn phũ phàng: mỗi Server Action bạn tạo ra đều là một HTTP endpoint công khai. Bất kỳ ai trên internet đều có thể gọi đến nó, bỏ qua hoàn toàn giao diện của bạn.
Cuối năm 2025, cộng đồng bảo mật đã phát hiện một loạt lỗ hổng nghiêm trọng — bao gồm lỗ hổng RCE (Remote Code Execution) với điểm CVSS 10.0, mức cao nhất có thể — ảnh hưởng trực tiếp đến Server Actions và React Server Components. Chuyện không hề nhỏ.
Trong bài này, chúng ta sẽ đi qua mọi khía cạnh bảo mật Server Actions: từ các CVE mà bạn phải vá ngay, đến 5 sai lầm phổ biến nhất của developer, rồi đến hệ thống phòng thủ nhiều lớp với code minh họa thực tế. Nào, bắt đầu thôi.
Server Actions là endpoint HTTP công khai — hiểu đúng bản chất
Khi bạn khai báo 'use server' và export một hàm, Next.js sẽ tự động tạo một HTTP POST endpoint cho hàm đó. Nghe đơn giản, nhưng hệ quả thì không đơn giản chút nào:
- Hàm Server Action có thể bị gọi bởi bất kỳ HTTP client nào — không chỉ từ UI của bạn
- Việc ẩn nút bấm trên giao diện không ngăn được ai đó gọi trực tiếp action
- Mọi Server Action cần được bảo vệ như một API endpoint công khai — không có ngoại lệ
Next.js có một số cơ chế bảo vệ tích hợp sẵn. Framework tạo ra encrypted, non-deterministic Action ID cho mỗi Server Action, được tái tạo giữa các lần build. Chỉ phương thức POST được chấp nhận, và framework so sánh Origin header với Host header để chống CSRF.
Nhưng đây chỉ là lớp bảo vệ cơ bản. Phần còn lại — authentication, authorization, input validation — hoàn toàn là trách nhiệm của bạn.
Các lỗ hổng CVE nghiêm trọng 2025-2026 mà bạn phải biết
React2Shell — CVE-2025-55182 và CVE-2025-66478 (CVSS 10.0)
Đây là lỗ hổng bảo mật nghiêm trọng nhất trong lịch sử React và Next.js, không phóng đại. Được phát hiện bởi đội ngũ nghiên cứu tại Wiz, lỗ hổng này cho phép kẻ tấn công thực thi mã tùy ý trên server mà không cần xác thực — chỉ cần một HTTP request được crafted đặc biệt là đủ.
Nguyên nhân gốc nằm ở cách React deserialize dữ liệu Flight-encoded trong luồng Client → Server (Reply Flow). Logic deserialization sử dụng metadata do client cung cấp để resolve module exports mà không validate property ownership, cho phép attacker can thiệp vào quá trình resolve function phía server.
Và đây là phần đáng lo nhất: ngay cả ứng dụng vừa tạo bằng create-next-app và build cho production cũng bị ảnh hưởng ngay lập tức. Không cần bạn viết thêm dòng code nào. Theo báo cáo, 40% cloud environments chứa instance Next.js hoặc React dính lỗ hổng này.
Phiên bản đã vá:
- Next.js: 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7
- React: 19.0.3+, 19.1.4+, 19.2.3+
# Cập nhật ngay theo phiên bản bạn đang dùng
npm install [email protected] # cho Next.js 15.5.x
npm install [email protected] # cho Next.js 16.0.x
# Hoặc sử dụng công cụ tự động
npx fix-react2shell-next
Rò rỉ mã nguồn — CVE-2025-55183 / CVE-2025-67779
Một HTTP request đặc biệt có thể khiến Server Function trả về compiled source code của các Server Function khác trong ứng dụng. Nghe đã thấy ớn lạnh rồi — business logic lộ hết, và nếu bạn lỡ hard-code secrets trong code (thay vì dùng environment variables), thì coi như xong.
Lưu ý quan trọng: Bản vá đầu tiên cho lỗ hổng này không hoàn chỉnh. Bản vá đầy đủ chỉ có trong CVE-2025-67779. Nếu bạn đã upgrade trước đó, hãy kiểm tra và upgrade lại lần nữa.
Denial-of-Service — CVE-2026-23864 (CVSS 7.5)
Lỗ hổng DoS ảnh hưởng đến React Server Components. Kẻ tấn công gửi mảng có kích thước lớn trong Flight request để làm quá tải server. Đã được vá trong React 19.0.4, 19.1.5 và 19.2.4.
Hành động ngay sau khi vá
Upgrade framework mới chỉ là bước đầu thôi. Sau khi vá và deploy lại, bạn phải rotate tất cả application secrets — API keys, database credentials, encryption keys, và bất kỳ secret nào có thể đã bị lộ. Đừng bỏ qua bước này.
5 sai lầm bảo mật phổ biến khi viết Server Actions
1. Chỉ kiểm tra quyền trên trang, quên kiểm tra trong action
Đây là sai lầm mình gặp nhiều nhất. Developer kiểm tra authentication trong layout hoặc page component, nhưng quên mất rằng Server Action là endpoint riêng biệt — attacker có thể gọi thẳng mà không qua trang nào cả:
// ❌ SAI - chỉ check auth ở page
export default async function AdminPage() {
const session = await auth()
if (!session) redirect('/login')
return <DeleteUserForm /> // Form gọi Server Action
}
// Server Action KHÔNG có auth check
// Attacker có thể gọi trực tiếp mà bypass page check!
export async function deleteUser(formData: FormData) {
const userId = formData.get('userId')
await db.delete(users).where(eq(users.id, userId)) // Nguy hiểm!
}
// ✅ ĐÚNG - luôn check auth bên trong action
'use server'
import { auth } from '@/lib/auth'
export async function deleteUser(formData: FormData) {
const session = await auth()
if (!session || session.user.role !== 'admin') {
throw new Error('Unauthorized')
}
const userId = formData.get('userId')
// Còn phải verify quyền sở hữu nữa...
await db.delete(users).where(eq(users.id, userId))
}
2. Tin tưởng TypeScript types thay vì validate runtime
Cái này cũng rất hay gặp. TypeScript types bị xóa hoàn toàn tại runtime — dù bạn khai báo tham số là string, không có gì đảm bảo client sẽ gửi đúng kiểu dữ liệu đó. Nghĩ mà xem, types chỉ là "lời hứa" lúc compile, chứ runtime thì mạnh ai nấy gửi.
// ❌ SAI - tin tưởng types
'use server'
export async function updateProfile(data: { name: string; age: number }) {
// data.name có thể là bất kỳ thứ gì — SQL injection, XSS payload...
await db.update(users).set({ name: data.name, age: data.age })
}
// ✅ ĐÚNG - validate với Zod
'use server'
import { z } from 'zod'
const UpdateProfileSchema = z.object({
name: z.string().min(1).max(100).trim(),
age: z.number().int().min(0).max(150),
})
export async function updateProfile(formData: FormData) {
const result = UpdateProfileSchema.safeParse({
name: formData.get('name'),
age: Number(formData.get('age')),
})
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
await db.update(users).set(result.data)
}
3. Sử dụng user-provided ID mà không verify quyền sở hữu
Khi nhận ID từ client, bạn phải luôn kiểm tra xem user hiện tại có quyền thao tác trên resource đó không. Đây là lỗ hổng IDOR (Insecure Direct Object Reference) — một trong những lỗi bảo mật web phổ biến nhất và cũng dễ khai thác nhất:
// ❌ SAI - dùng ID từ client mà không verify
export async function deletePost(postId: string) {
await db.delete(posts).where(eq(posts.id, postId))
}
// ✅ ĐÚNG - verify ownership
export async function deletePost(postId: string) {
const session = await auth()
if (!session) throw new Error('Unauthorized')
const post = await db.query.posts.findFirst({
where: and(eq(posts.id, postId), eq(posts.authorId, session.user.id)),
})
if (!post) throw new Error('Post not found or access denied')
await db.delete(posts).where(eq(posts.id, postId))
}
4. Truyền secrets qua closures
Khi bạn định nghĩa Server Action inline trong Server Component, closure có thể vô tình capture biến chứa dữ liệu nhạy cảm. Giải pháp đơn giản nhất: tách Server Actions ra file riêng với directive 'use server' ở đầu file. Thế là xong, không có gì phức tạp cả.
5. Trả về internal error messages cho client
Error messages chi tiết nghe có vẻ "thân thiện với developer", nhưng thực tế lại giúp attacker hiểu rõ cấu trúc hệ thống của bạn:
// ❌ SAI - lộ thông tin internal
export async function createOrder(formData: FormData) {
try {
await db.insert(orders).values(data)
} catch (error) {
// Lộ tên bảng, cấu trúc DB, connection string...
return { error: error.message }
}
}
// ✅ ĐÚNG - log server-side, trả generic message cho client
export async function createOrder(formData: FormData) {
try {
await db.insert(orders).values(data)
} catch (error) {
console.error('Order creation failed:', error)
return { error: 'Không thể tạo đơn hàng. Vui lòng thử lại sau.' }
}
}
Validation input đúng cách với Zod và useActionState
Định nghĩa schema Zod tái sử dụng
Một tip khá hay: tạo schema trong file riêng để dùng chung giữa client (validation nhanh trên form) và server (validation chính thức trong action). Viết một lần, dùng hai nơi — tiết kiệm công sức mà lại đảm bảo nhất quán:
// lib/schemas/contact.ts
import { z } from 'zod'
export const ContactFormSchema = z.object({
name: z
.string()
.min(2, 'Tên phải có ít nhất 2 ký tự')
.max(100, 'Tên không được vượt quá 100 ký tự')
.trim(),
email: z
.string()
.email('Email không hợp lệ'),
message: z
.string()
.min(10, 'Tin nhắn phải có ít nhất 10 ký tự')
.max(2000, 'Tin nhắn không được vượt quá 2000 ký tự')
.trim(),
})
export type ContactFormData = z.infer<typeof ContactFormSchema>
Server Action với useActionState
Kết hợp Zod validation với useActionState để hiển thị lỗi inline ngay trên form. Một điểm cần nhớ: khi dùng useActionState, Server Action nhận thêm tham số prevState ở vị trí đầu tiên (nhiều người quên cái này và debug mãi không ra):
// actions/contact.ts
'use server'
import { ContactFormSchema } from '@/lib/schemas/contact'
export type ContactFormState = {
errors?: {
name?: string[]
email?: string[]
message?: string[]
}
message?: string
success?: boolean
}
export async function submitContact(
prevState: ContactFormState,
formData: FormData
): Promise<ContactFormState> {
const result = ContactFormSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
})
if (!result.success) {
return {
errors: result.error.flatten().fieldErrors,
message: 'Vui lòng kiểm tra lại thông tin.',
}
}
try {
// Xử lý logic (gửi email, lưu DB...)
await saveContact(result.data)
return { success: true, message: 'Gửi thành công!' }
} catch (error) {
console.error('Contact submission failed:', error)
return { message: 'Đã xảy ra lỗi. Vui lòng thử lại.' }
}
}
Client Component hiển thị lỗi
// components/ContactForm.tsx
'use client'
import { useActionState } from 'react'
import { submitContact, type ContactFormState } from '@/actions/contact'
const initialState: ContactFormState = {}
export function ContactForm() {
const [state, formAction, pending] = useActionState(
submitContact,
initialState
)
return (
<form action={formAction}>
<div>
<label htmlFor="name">Họ tên</label>
<input type="text" id="name" name="name" required />
{state.errors?.name && (
<p className="text-red-500 text-sm">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email">Email</label>
<input type="email" id="email" name="email" required />
{state.errors?.email && (
<p className="text-red-500 text-sm">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="message">Tin nhắn</label>
<textarea id="message" name="message" rows={4} required />
{state.errors?.message && (
<p className="text-red-500 text-sm">{state.errors.message[0]}</p>
)}
</div>
<button type="submit" disabled={pending}>
{pending ? 'Đang gửi...' : 'Gửi liên hệ'}
</button>
{state.message && (
<p className={state.success ? 'text-green-600' : 'text-red-600'}>
{state.message}
</p>
)}
</form>
)
}
Xác thực và phân quyền — phòng thủ nhiều lớp
Tại sao chỉ middleware là không đủ
Lỗ hổng CVE-2025-29927 đã chứng minh một điều rõ ràng: không bao giờ nên dựa hoàn toàn vào middleware cho authentication. Middleware có thể bị bypass. Bảo mật Next.js App Router cần chiến lược defense-in-depth — kiểm tra xác thực tại mọi điểm truy cập dữ liệu, không chỉ ở "cửa trước".
Mô hình phòng thủ 3 lớp
Theo kinh nghiệm, kiến trúc bảo mật hiệu quả nhất gồm ba lớp riêng biệt:
- Middleware — Kiểm tra session/token cơ bản, redirect user chưa đăng nhập, đặt security headers
- Server Action — Re-validate authentication, kiểm tra authorization cụ thể cho từng action
- Data Access Layer (DAL) — Kiểm tra quyền sở hữu resource, áp dụng business rules
Nghe có vẻ nhiều lớp, nhưng mỗi lớp đều có vai trò riêng và không thừa. Hãy xem code cụ thể:
// middleware.ts — Lớp 1: Bảo vệ route cơ bản
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('session-token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
}
// lib/dal.ts — Lớp 3: Data Access Layer
import { auth } from '@/lib/auth'
import { cache } from 'react'
// Memoize auth check cho mỗi request
export const getCurrentUser = cache(async () => {
const session = await auth()
if (!session?.user) return null
return session.user
})
export async function getPostForEdit(postId: string) {
const user = await getCurrentUser()
if (!user) throw new Error('Unauthorized')
const post = await db.query.posts.findFirst({
where: and(eq(posts.id, postId), eq(posts.authorId, user.id)),
})
if (!post) throw new Error('Not found or access denied')
return post
}
// actions/post.ts — Lớp 2: Server Action với auth check
'use server'
import { getCurrentUser, getPostForEdit } from '@/lib/dal'
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
const UpdatePostSchema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().min(1).max(50000),
})
export async function updatePost(
prevState: any,
formData: FormData
) {
// Auth check trong action
const user = await getCurrentUser()
if (!user) return { error: 'Vui lòng đăng nhập' }
const postId = formData.get('postId') as string
// Validation
const result = UpdatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!result.success) {
return { errors: result.error.flatten().fieldErrors }
}
// DAL tự verify ownership
const post = await getPostForEdit(postId)
await db.update(posts)
.set({ ...result.data, updatedAt: new Date() })
.where(eq(posts.id, post.id))
revalidatePath('/dashboard/posts')
return { success: true }
}
Sử dụng next-safe-action cho Server Actions type-safe
Nếu dự án của bạn có nhiều Server Actions (và thường thì có), thư viện next-safe-action sẽ giúp cuộc sống dễ thở hơn nhiều. Nó cung cấp end-to-end typesafe validation tích hợp sẵn, với pattern middleware kiểu pipeline — cho phép thêm authentication, rate limiting, và logging vào mọi action một cách nhất quán.
Thiết lập action client
// lib/safe-action.ts
import { createSafeActionClient } from 'next-safe-action'
import { auth } from '@/lib/auth'
// Client cơ bản cho public actions
export const publicAction = createSafeActionClient()
// Client với auth middleware cho protected actions
export const protectedAction = createSafeActionClient({
async middleware() {
const session = await auth()
if (!session?.user) {
throw new Error('Unauthorized')
}
return { user: session.user }
},
})
Định nghĩa safe action
// actions/newsletter.ts
'use server'
import { protectedAction } from '@/lib/safe-action'
import { z } from 'zod'
export const subscribeNewsletter = protectedAction
.schema(z.object({
email: z.string().email(),
preferences: z.array(z.enum(['weekly', 'monthly', 'breaking'])),
}))
.action(async ({ parsedInput, ctx }) => {
// ctx.user đã được xác thực qua middleware
// parsedInput đã được validate qua Zod schema
await db.insert(subscriptions).values({
userId: ctx.user.id,
email: parsedInput.email,
preferences: parsedInput.preferences,
})
return { message: 'Đăng ký thành công!' }
})
Quản lý encryption keys và secrets
Một chi tiết nhiều người không biết: Next.js mã hóa các giá trị được truyền qua closure giữa server và client. Mặc định, một encryption key mới được tạo mỗi lần build. Điều này gây vấn đề khi bạn deploy nhiều instances (vì mỗi instance có key khác nhau). Để đồng bộ:
# .env.production
# Key phải là base64-encoded, decoded length khớp AES key size (16, 24, hoặc 32 bytes)
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-base64-encoded-key-here
Một vài nguyên tắc quản lý secrets mà bạn nên ghi nhớ:
- Không bao giờ hard-code secrets trong Server Actions — luôn dùng environment variables
- Rotate encryption key định kỳ và ngay sau mỗi lần phát hiện sự cố bảo mật
- Sử dụng
process.envtại runtime, đừng để bundler inline giá trị vào compiled output - Kiểm tra rằng các package database và biến môi trường nhạy cảm không được import ngoài Data Access Layer
Giới hạn kích thước request — chống DDoS
Mặc định, Server Actions giới hạn request body ở 1MB. Tùy vào ứng dụng, bạn có thể cần điều chỉnh con số này trong next.config.ts:
// next.config.ts
const nextConfig = {
serverActions: {
bodySizeLimit: '2mb', // Tùy chỉnh theo nhu cầu
// Cấu hình allowed origins để chống CSRF
allowedOrigins: [
'https://your-domain.com',
'https://staging.your-domain.com',
],
},
}
Checklist bảo mật production cho Server Actions
Trước khi deploy ứng dụng Next.js lên production, hãy chạy qua danh sách này. Mình nghiêm túc đấy — bỏ qua bất kỳ mục nào cũng có thể là một lỗ hổng tiềm ẩn:
- Cập nhật framework — Đảm bảo Next.js và React đã vá các CVE mới nhất (tối thiểu Next.js 15.5.7 hoặc 16.0.7)
- Validate mọi input — Dùng Zod hoặc thư viện validation tương đương trong mọi Server Action, không ngoại lệ
- Xác thực trong action — Mỗi Server Action phải tự kiểm tra authentication, đừng dựa vào middleware hay page check
- Kiểm tra authorization — Verify quyền sở hữu resource và quyền thao tác cho mỗi action
- Tách Server Actions ra file riêng — Tránh inline actions để ngăn rò rỉ dữ liệu qua closures
- Triển khai Data Access Layer — Tập trung logic truy cập dữ liệu và phân quyền vào một nơi
- Cấu hình allowedOrigins — Giới hạn domain được phép gọi Server Actions
- Quản lý secrets đúng cách — Dùng environment variables, rotate keys định kỳ
- Log và monitor — Ghi log các action thất bại, theo dõi pattern bất thường
- Trả generic error cho client — Không bao giờ lộ stack trace hay thông tin internal ra ngoài
Câu hỏi thường gặp
Server Actions có an toàn hơn API Routes không?
Không hẳn. Cả hai đều là HTTP endpoints và cần cùng mức bảo mật. Server Actions có lợi thế là Next.js tự xử lý CSRF protection và encrypted action IDs, nhưng bạn vẫn phải tự implement authentication, authorization, và input validation. Đừng nhầm lẫn giữa sự tiện lợi khi viết code với mức độ an toàn thực sự.
Ứng dụng của tôi có bị ảnh hưởng bởi CVE-2025-66478 không?
Nếu bạn đang chạy Next.js 15.x hoặc 16.0.x với App Router và React Server Components — rất có thể là có. Next.js 13.x, 14.x stable, Pages Router, và Edge Runtime không bị ảnh hưởng. Kiểm tra nhanh bằng npx next --version và upgrade ngay nếu cần. Các project trên Vercel được bảo vệ ở tầng platform, nhưng vẫn nên upgrade cho chắc.
Có cần validate input cả ở client và server không?
Có, nhưng mỗi nơi có mục đích khác nhau. Client-side validation (dùng chung Zod schema) giúp UX tốt hơn — người dùng nhận feedback ngay lập tức mà không cần đợi round-trip lên server. Còn server-side validation là bắt buộc cho bảo mật — đây là nơi duy nhất bạn thực sự kiểm soát được. Client-side validation thì ai cũng bypass được.
Khi nào nên dùng next-safe-action?
Nếu ứng dụng có nhiều hơn 5-10 Server Actions, next-safe-action sẽ giúp bạn tránh lặp code boilerplate đáng kể. Middleware pipeline pattern đặc biệt hữu ích khi cần áp dụng cùng logic (authentication, rate limiting, logging) cho nhiều actions cùng lúc. Còn với dự án nhỏ thì viết thủ công với Zod là quá đủ rồi.
Làm sao bảo vệ Server Actions khỏi brute-force?
Kết hợp nhiều biện pháp: cấu hình bodySizeLimit trong next.config.ts, implement rate limiting (dùng next-safe-action middleware hoặc thư viện như rate-limiter-flexible), thiết lập WAF ở tầng infrastructure, và monitor các pattern request bất thường. Không có giải pháp đơn lẻ nào là đủ — cần kết hợp nhiều lớp phòng thủ.