Next.js Server Actions tự động công khai mọi hàm được export trong một tệp use-server dưới dạng một HTTP endpoint công khai, gán cho nó một mã định danh duy nhất mà client sẽ sử dụng để gọi hàm đó. Endpoint này tồn tại bất kể có nút bấm, liên kết hay bất kỳ thành phần UI nào được hiển thị hay không, nghĩa là bất kỳ ai tìm thấy mã định danh này đều có thể gọi hàm trực tiếp.

Thiết kế của framework coi Server Actions như các hàm bổ trợ (helper functions) thông thường, nhưng khi chạy (runtime), chúng trở thành các URL có thể truy cập được. Đối với những nhà phát triển cho rằng UI là lớp bảo vệ duy nhất, điều này tạo ra một bề mặt tấn công (attack surface) vô hình có thể bị khai thác chỉ bằng một yêu cầu được tạo sẵn (crafted request).

Tại sao Server Actions có vẻ an toàn

Server Actions được giới thiệu để cho phép các nhà phát triển viết mã phía server ngay cạnh các component của họ, tránh việc phải viết các API routes riêng biệt rườm rà. Một cách sử dụng điển hình như sau:

<form action={deleteInvoice}>
  <button type="submit">Delete</button>
</form>

Vì form là cách duy nhất có thể nhìn thấy để kích hoạt deleteInvoice, nhiều nhà phát triển thường ẩn nút bấm đối với những người dùng không có quyền, nghĩ rằng các kiểu dữ liệu (signatures) của TypeScript sẽ ngăn chặn dữ liệu sai định dạng, và tin tưởng vào việc hàm nằm trong một module chỉ dành cho server. Không có giả định nào trong số đó mang lại sự bảo vệ thực sự.

Sự lộ lọt tiềm ẩn

Khi một tệp chứa “use server”, Next.js sẽ biên dịch mỗi hàm được export thành một endpoint như:

POST /_next/data/<build-id>/<page>.json?__rsc=<action-id>

<action-id> là một mã hash ổn định được nhúng vào client bundle. Kẻ tấn công có thể lấy được nó bằng cách:

  • Kiểm tra lưu lượng mạng (network traffic) của trang web.
  • Đọc tệp JavaScript đã được đóng gói (bundled JavaScript) (ID này là một chuỗi văn bản thuần túy).
  • Đoán dựa trên các quy ước đặt tên nếu dự án tuân theo các mẫu có thể dự đoán được.

Một khi đã biết ID, một yêu cầu có thể được gửi từ bất kỳ công cụ nào—cURL, Postman, hoặc một script độc hại—nhằm vượt qua mọi kiểm tra ở cấp độ UI.

Ba rủi ro cụ thể

Rủi ro Tại sao nó quan trọng
Các kiểm tra UI không hiệu quả Việc ẩn một nút bấm hoặc liên kết không xóa bỏ endpoint bên dưới. Endpoint đó vẫn có thể truy cập được, giống như một trang quản trị bị ẩn nhưng vẫn tồn tại trên server.
TypeScript không cung cấp sự an toàn khi chạy (runtime) Các kiểu dữ liệu (types) sẽ bị loại bỏ khi mã chạy. Một hàm được khai báo là deleteInvoice(id: number) có thể nhận vào một chuỗi cực lớn, một mảng, hoặc thậm chí là JSON độc hại, dẫn đến lỗi logic hoặc các cuộc tấn công injection.
Không có xác thực hoặc phân quyền mặc định Server Actions trông giống như các hàm bổ trợ cục bộ, vì vậy các nhà phát triển thường quên thêm các bước kiểm tra session, bảo vệ CSRF, hoặc kiểm tra quyền truy cập ở cấp độ dòng (row-level permission) vốn là tiêu chuẩn trong các API routes truyền thống.

Cách bảo mật một Server Action

  1. Xác thực người gọi – Kiểm tra xem một session hoặc token hợp lệ có tồn tại hay không trước khi bất kỳ logic nghiệp vụ nào được thực thi.
  2. Xác thực dữ liệu đầu vào (payload) – Sử dụng một thư viện schema (ví dụ: Zod, Yup) để áp đặt các kiểu dữ liệu và ràng buộc giá trị tại thời điểm runtime.
  3. Phân quyền thao tác – Ngoài việc kiểm tra "người dùng đã đăng nhập chưa?", hãy xác nhận rằng người dùng đó thực sự sở hữu bản ghi cụ thể mà họ đang cố gắng sửa đổi hoặc xóa.

Một ví dụ tối giản:

'use server';
import { getSession } from '@/auth';
import { z } from 'zod';
import { db } from '@/db';

const DeleteInvoiceSchema = z.object({
  id: z.number().int().positive(),
});

export async function deleteInvoice(formData: FormData) {
  const session = await getSession();
  if (!session) throw new Error('Unauthenticated');

  const parsed = DeleteInvoiceSchema.safeParse({
    id: Number(formData.get('id')),
  });
  if (!parsed.success) throw new Error('Invalid input');

  const invoice = await db.invoice.findUnique({ where: { id: parsed.data.id } });
  if (!invoice || invoice.ownerId !== session.userId) {
    throw new Error('Unauthorized');
  }

  await db.invoice.delete({ where: { id: invoice.id } });
}

Mã nguồn kiểm tra rõ ràng việc xác thực, xác thực id gửi đến, và đảm bảo người dùng đã đăng nhập thực sự sở hữu hóa đơn đó trước khi thực hiện lệnh xóa.

Các cạm bẫy tinh vi khác trong Next.js

Vấn đề Triệu chứng Cách khắc phục
Biến môi trường NEXT_PUBLIC_ Bất cứ thứ gì có tiền tố NEXT_PUBLIC_ đều được đóng gói vào client, làm lộ các thông tin bí mật. Giữ các thông tin bí mật trong các biến môi trường thông thường, tuyệt đối không thêm tiền tố NEXT_PUBLIC_.
Chuyển hướng hở (Open redirects) Việc chấp nhận tham số truy vấn redirect và nối chuỗi một cách ngây thơ có thể gửi người dùng đến //evil.com. Xác thực mục tiêu dựa trên một danh sách trắng (whitelist) hoặc thực hiện kiểm tra cùng nguồn gốc (same-origin).
Server-Side Request Forgery (SSRF) Việc lấy một URL do người dùng cung cấp có thể cho phép kẻ tấn công truy cập vào các dịch vụ nội bộ hoặc các endpoint metadata của đám mây. Cho phép các hostname cụ thể (allow-list), chặn các dải IP riêng tư và thiết lập thời gian chờ (timeout).
dangerouslySetInnerHTML Việc hiển thị HTML do người dùng cung cấp mà không qua làm sạch (sanitisation) sẽ mở ra lỗ hổng XSS. Sử dụng một thư viện như DOMPurify hoặc tránh sử dụng HTML thô hoàn toàn.

Tự động quét các mẫu này

Các công cụ phân tích tĩnh (static-analysis) có thể gắn cờ các cấu trúc rủi ro được liệt kê ở trên. Một lựa chọn nhẹ nhàng là Semgrep, công cụ này chạy rất nhanh và có thể được tích hợp vào các pipeline CI:

npx --yes semgrep --config https://raw.githubusercontent.com/catidegla/stacksec/main/rules .

Bộ quy tắc bao gồm các kiểm tra đối với các hàm server được export, việc sử dụng sai NEXT_PUBLIC_, chuyển hướng hở, các mẫu SSRF và việc chèn HTML không an toàn.

Những điều cần theo dõi tiếp theo

  • Cập nhật framework – Hãy theo dõi các bản phát hành của Next.js; đội ngũ phát triển có thể sẽ giới thiệu các hook xác thực tích hợp sẵn hoặc tạo môi trường sandbox cho các endpoint được tạo ra.
  • Công cụ cộng đồng – Các plugin ESLint mới và các quy tắc Semgrep dành riêng cho Next.js đang dần xuất hiện để tự động hóa các biện pháp bảo vệ được mô tả ở đây.
  • Các sự cố thực tế – Khi có nhiều dự án áp dụng Server Actions hơn, hãy chú ý đến các lỗ hổng bị khai thác đã được công bố nhằm minh họa cho rủi ro trong thực tế. Việc phát hiện sớm có thể cung cấp thông tin cho các đợt đánh giá bảo mật nội bộ trước khi một vụ vi phạm xảy ra.

Điểm mấu chốt: Một Next.js Server Action không phải là một hàm bổ trợ riêng tư; nó trở thành một HTTP endpoint công khai ngay khi bạn export nó. Hãy đối xử với nó như bất kỳ API route nào khác—xác thực, kiểm tra tính hợp lệ và cấp quyền—nếu không, sự tiện lợi của việc viết mã server ngay cạnh UI có thể nhanh chóng trở thành một rủi ro bảo mật.