BlogBlog · security

Bảo mật web app Next.js theo OWASP Top 10: access control, XSS, injection, SSRF và quản lý secrets

Đi qua OWASP Top 10:2025 trên một app Next.js 16 cụ thể: authorize trong từng Server Action, chặn IDOR, SQL injection với Prisma, XSS và CSP nonce, CSRF, SSRF, session cookie và cách xử lý khi secret bị commit.

Part of Cybersecurity Alerts series
September 21, 2025
33 min read
By Leo Pham
securitynextjsowaspweb-securityauthentication
Share this post:

Giả sử bạn có một app quản lý hóa đơn viết bằng Next.js 16 App Router, Prisma và PostgreSQL. Mỗi công ty (organization) có nhiều user, mỗi user xem và sửa hóa đơn của công ty mình. Một ngày có người gửi báo cáo: "Tôi đổi inv_123 thành inv_124 trên URL và thấy hóa đơn của một công ty khác." Không có exploit gì phức tạp, chỉ là một câu findUnique({ where: { id } }) thiếu điều kiện organizationId.

Phần lớn lỗ hổng mình gặp khi review code Next.js đều nhàm chán như vậy: thiếu kiểm tra quyền trong một Server Action, một chỗ dangerouslySetInnerHTML render Markdown của user, một biến NEXT_PUBLIC_ chứa secret, một tính năng "nhập URL để preview" gọi được vào 169.254.169.254. Bài này đi qua từng nhóm của OWASP Top 10:2025 và chỉ ra chỗ nó hiện ra trong code Next.js, kèm code sửa chạy được.

Sau bài này bạn sẽ có: một Data Access Layer kiểm tra quyền ở mọi entry point, query raw an toàn với Prisma, pipeline Markdown đã sanitize, CSP có nonce trong proxy.ts, cookie session đúng chuẩn, hàm fetch chống SSRF, quy trình xử lý secret bị lộ, và một bảng đối chiếu OWASP để audit dự án của bạn.

Code trong bài viết cho Next.js 16 (App Router). Ở Next.js 15 trở xuống, file proxy.ts tên là middleware.ts và hàm export tên là middleware, phần còn lại giống nhau.

TL;DR:

  • Proxy (middleware) chỉ là lớp lọc đầu tiên. Mọi Server Action và Route Handler phải tự kiểm tra authentication và authorization, và mọi query phải scope theo tenant.
  • $queryRaw dạng tagged template là an toàn; $queryRawUnsafe với chuỗi nối là SQL injection. Validate kiểu dữ liệu đầu vào cả khi dùng query API thường của Prisma.
  • React escape text, nhưng không bảo vệ dangerouslySetInnerHTML, Markdown render ra HTML, hay JSON nhét vào thẻ script.
  • Server Actions có sẵn origin check, Route Handlers thì không.
  • Secret bị commit: rotate trước, dọn lịch sử git sau.

Broken access control: kiểm tra quyền ở từng entry point

Broken Access Control đứng đầu OWASP Top 10:2025 (A01), và bản 2025 gộp luôn SSRF vào nhóm này. Trong Next.js, nó thường xuất hiện ở ba chỗ: Route Handler (route.ts), Server Action ('use server'), và Server Component đọc dữ liệu rồi truyền xuống Client Component.

Vì sao không thể chỉ dựa vào proxy.ts

Nhiều dự án đặt logic "chưa đăng nhập thì redirect về /login" trong proxy.ts rồi coi như xong. Có ba lý do điều này không đủ:

  1. Proxy chạy theo matcher. Một lần refactor đổi matcher, hoặc chuyển Server Action sang route khác, là một nhóm endpoint mất bảo vệ mà không ai để ý. Tài liệu Next.js nói rõ Server Function được xử lý như POST tới route đang dùng nó, nên matcher loại trừ route đó thì cũng bỏ qua luôn Server Function.
  2. Proxy thường chỉ biết "đã đăng nhập hay chưa", không biết "user này có quyền với hóa đơn inv_124 không". Câu hỏi thứ hai chỉ trả lời được khi có dữ liệu từ database.
  3. Bản thân lớp này có thể có lỗi. CVE-2025-29927 (tháng 3/2025) cho phép bỏ qua hoàn toàn middleware bằng header x-middleware-subrequest trên các bản Next.js tự host chưa vá (đã sửa ở 15.2.3, 14.2.25, 13.5.9, 12.3.5). App nào chỉ kiểm tra quyền trong middleware thì coi như mở cửa.

Một điểm nữa hay bị quên: mỗi Server Action là một endpoint POST công khai. Check quyền ở page (redirect('/login') trong page.tsx) chỉ quyết định UI nào được render, không bảo vệ action được định nghĩa trong page đó. Ai có action ID đều gọi trực tiếp được.

Data Access Layer: một chỗ duy nhất được chạm vào database

Cách mình áp dụng (cũng là cách tài liệu Next.js khuyến nghị cho dự án mới) là gom mọi truy cập dữ liệu vào một Data Access Layer (DAL) chỉ chạy trên server. Mọi hàm trong DAL tự lấy user hiện tại, tự kiểm tra quyền, và chỉ trả về các field cần thiết.

// lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { createHash } from 'node:crypto'
import { prisma } from '@/lib/prisma'

export const SESSION_COOKIE = '__Host-session'

export type CurrentUser = {
  id: string
  organizationId: string
  role: 'MEMBER' | 'ADMIN'
}

export class AuthError extends Error {
  constructor(
    readonly status: 401 | 403 | 404,
    message: string,
  ) {
    super(message)
  }
}

// cache() của React: nhiều component gọi trong cùng một request chỉ query 1 lần
export const getCurrentUser = cache(async (): Promise<CurrentUser | null> => {
  const token = (await cookies()).get(SESSION_COOKIE)?.value
  if (!token) return null

  const tokenHash = createHash('sha256').update(token).digest('hex')
  const session = await prisma.session.findUnique({
    where: { tokenHash },
    select: {
      expiresAt: true,
      user: { select: { id: true, organizationId: true, role: true } },
    },
  })
  if (!session || session.expiresAt < new Date()) return null
  return session.user
})

export async function requireUser(): Promise<CurrentUser> {
  const user = await getCurrentUser()
  if (!user) throw new AuthError(401, 'Unauthenticated')
  return user
}

export async function getInvoice(invoiceId: string) {
  const user = await requireUser()
  const invoice = await prisma.invoice.findFirst({
    // Điều kiện tenant nằm NGAY trong query, không check sau khi đã lấy dữ liệu
    where: { id: invoiceId, organizationId: user.organizationId },
    select: { id: true, number: true, amountCents: true, status: true, dueDate: true },
  })
  // 404 thay vì 403: không xác nhận cho kẻ dò ID rằng hóa đơn đó tồn tại
  if (!invoice) throw new AuthError(404, 'Not found')
  return invoice
}

import 'server-only' làm build fail nếu ai đó lỡ import file này vào Client Component, nên Prisma client và process.env không thể lọt xuống bundle trình duyệt. select tường minh thay vì trả cả record giúp tránh rò rỉ field như internalNote hay stripeCustomerId qua props của Client Component.

Ví dụ IDOR trong Route Handler và cách sửa

IDOR (Insecure Direct Object Reference) là khi server tin vào ID do client gửi lên mà không kiểm tra quyền sở hữu. Đây là phiên bản lỗi mình gặp nhiều nhất:

// app/api/invoices/[id]/route.ts  (SAI)
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/dal'

export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
  await requireUser() // chỉ biết "đã đăng nhập", không biết "của ai"
  const { id } = await ctx.params
  const invoice = await prisma.invoice.findUnique({ where: { id } })
  return Response.json(invoice) // trả về cả record, kể cả field nội bộ
}

Bản sửa đưa authorization vào DAL và để Route Handler chỉ còn làm việc chuyển lỗi thành HTTP status:

// app/api/invoices/[id]/route.ts  (ĐÚNG)
import { z } from 'zod'
import { AuthError, getInvoice } from '@/lib/dal'

const Params = z.object({ id: z.string().min(1).max(64) })

export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
  const parsed = Params.safeParse(await ctx.params)
  if (!parsed.success) return Response.json({ error: 'Invalid id' }, { status: 400 })

  try {
    const invoice = await getInvoice(parsed.data.id)
    return Response.json(invoice)
  } catch (err) {
    if (err instanceof AuthError) {
      return Response.json({ error: err.message }, { status: err.status })
    }
    throw err
  }
}

ID dạng UUID hay cuid khó đoán hơn số tự tăng, nhưng đừng coi đó là cơ chế phân quyền. ID lộ ra khắp nơi: trong URL được share, trong log, trong email thông báo, trong response của API khác.

Server Action: authenticate, authorize, validate, rồi mới ghi

Server Action nhận dữ liệu từ form, mà form thì người dùng sửa được bằng DevTools. Thứ tự mình luôn giữ là: ai đang gọi, người đó có quyền không, dữ liệu có hợp lệ không, rồi mới ghi.

// app/invoices/actions.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/dal'

const UpdateStatusInput = z.object({
  invoiceId: z.string().min(1).max(64),
  status: z.enum(['PAID', 'VOID']), // allowlist, không nhận string tùy ý
})

type ActionResult = { ok: true } | { ok: false; error: string }

export async function updateInvoiceStatus(
  _prev: ActionResult | null,
  formData: FormData,
): Promise<ActionResult> {
  const user = await requireUser()
  if (user.role !== 'ADMIN') return { ok: false, error: 'Bạn không có quyền đổi trạng thái hóa đơn' }

  const parsed = UpdateStatusInput.safeParse({
    invoiceId: formData.get('invoiceId'),
    status: formData.get('status'),
  })
  if (!parsed.success) return { ok: false, error: 'Dữ liệu không hợp lệ' }

  // updateMany với điều kiện tenant: check quyền và ghi trong cùng một câu lệnh
  const { count } = await prisma.invoice.updateMany({
    where: { id: parsed.data.invoiceId, organizationId: user.organizationId },
    data: { status: parsed.data.status },
  })
  if (count === 0) return { ok: false, error: 'Không tìm thấy hóa đơn' }

  revalidatePath('/invoices')
  return { ok: true } // trả về tối thiểu, không trả record từ database
}

Vài chi tiết đáng chú ý. updateMany với organizationId trong where gộp bước kiểm tra quyền và bước ghi thành một câu SQL, nên không có khoảng hở giữa "đọc để check" và "ghi". Từ Prisma 5, update cũng nhận thêm field không unique trong where (miễn có một field unique như id) và ném lỗi P2025 khi không khớp, nếu bạn thích kiểu đó hơn. Kết quả trả về của action được serialize xuống client, nên trả nguyên record từ prisma.invoice.update() là tự tay rò rỉ dữ liệu.

Để tự kiểm tra IDOR, mình đăng nhập hai tài khoản thuộc hai organization khác nhau ở hai trình duyệt, lấy cookie của tài khoản A rồi gọi vào ID tài nguyên của B:

# Cookie của user A (org A), ID hóa đơn thuộc org B: kỳ vọng 404
curl -s -o /dev/null -w "%{http_code}\n" \
  -H "Cookie: __Host-session=$SESSION_A" \
  https://app.example.com/api/invoices/inv_thuoc_org_b
# 404

Nên biến bước này thành integration test chạy trong CI cho từng resource có tenant: mỗi test tạo hai org, gọi hàm DAL bằng session của org này với ID của org kia, và assert nhận lỗi 404.

Injection: Prisma an toàn đến đâu

Query API thường của Prisma (findMany, create, update...) luôn tham số hóa giá trị, nên SQL injection kiểu cổ điển chỉ quay lại khi bạn tự viết SQL. Prisma có hai cặp hàm raw: $queryRaw / $executeRaw (tagged template, an toàn) và $queryRawUnsafe / $executeRawUnsafe (nhận chuỗi, nguy hiểm khi nối chuỗi).

// lib/invoice-search.ts
import 'server-only'
import { Prisma } from '@prisma/client' // hoặc path generated client nếu bạn dùng generator "prisma-client"
import { prisma } from '@/lib/prisma'

// SAI: nối input vào chuỗi SQL
export async function searchInvoicesUnsafe(organizationId: string, q: string) {
  return prisma.$queryRawUnsafe(
    `SELECT id, number FROM "Invoice"
     WHERE "organizationId" = '${organizationId}' AND number ILIKE '%${q}%'`,
  )
  // q = "%' OR '1'='1" → điều kiện tenant bị vô hiệu, trả hóa đơn của mọi công ty
}

// Cột được phép sort: map từ key cố định sang fragment SQL viết sẵn
const SORT_COLUMNS = {
  createdAt: Prisma.sql`"createdAt"`,
  amount: Prisma.sql`"amountCents"`,
} as const

export async function searchInvoices(
  organizationId: string,
  q: string,
  sort: keyof typeof SORT_COLUMNS,
  statuses: string[] = [],
) {
  // Escape ký tự đặc biệt của LIKE để user không tự chèn wildcard
  const pattern = `%${q.replace(/[\\%_]/g, (c) => `\\${c}`)}%`

  return prisma.$queryRaw<{ id: string; number: string }[]>`
    SELECT id, number FROM "Invoice"
    WHERE "organizationId" = ${organizationId}
      AND number ILIKE ${pattern}
      ${statuses.length ? Prisma.sql`AND status IN (${Prisma.join(statuses)})` : Prisma.empty}
    ORDER BY ${SORT_COLUMNS[sort]} DESC
    LIMIT 50`
}

Mỗi ${...} trong tagged template trở thành một placeholder $1, $2 của prepared statement, không bao giờ được ghép vào chuỗi SQL. Prisma.join sinh danh sách placeholder cho IN, và fragment Prisma.sql lồng vào nhau vẫn giữ nguyên tham số hóa.

Giới hạn cần nhớ: placeholder chỉ thay được giá trị, không thay được tên bảng, tên cột hay từ khóa như ASC/DESC. Đó là lý do ORDER BY ở trên dùng map allowlist. Đừng dùng Prisma.raw(userInput) để giải quyết chuyện này; tài liệu Prisma gọi thẳng Prisma.raw là con đường khiến query trở nên có thể bị injection. Nếu bắt buộc dùng $queryRawUnsafe (ví dụ tên bảng động), hãy truyền giá trị qua tham số vị trí: prisma.$queryRawUnsafe('SELECT * FROM "Invoice" WHERE id = $1', id), và chỉ cho tên bảng đi qua allowlist.

Operator injection: kiểu dữ liệu sai cũng là injection

Có một biến thể ít người để ý vì TypeScript che mất: body JSON có thể chứa object ở chỗ bạn nghĩ là string. Xem endpoint đặt lại mật khẩu sau:

// SAI: tin rằng body.token là string vì type nói vậy
const body = (await request.json()) as { email: string; token: string }
const user = await prisma.user.findFirst({
  where: { email: body.email, resetToken: body.token },
})
// Kẻ tấn công gửi: { "email": "ketoan@congty.vn", "token": { "not": "" } }
// → Prisma hiểu là "resetToken khác rỗng", khớp với mọi token đang tồn tại

Type assertion không tồn tại lúc runtime. Cách sửa là validate bằng schema trước khi đưa vào where: z.object({ email: z.string().email(), token: z.string().length(64) }). Cùng nguyên tắc áp dụng cho MongoDB ({ "$ne": null }) và cho mọi filter động bạn dựng từ query string. Validate chặt cả về kiểu lẫn độ dài là lớp phòng thủ rẻ nhất bạn có.

XSS: React escape mặc định, trừ những chỗ bạn tự tắt

JSX escape mọi giá trị text, nên <p>{comment.body}</p> với body = "<img src=x onerror=alert(1)>" chỉ in ra chuỗi ký tự vô hại. XSS trong app React gần như luôn đến từ một trong bốn chỗ sau:

  1. dangerouslySetInnerHTML với HTML không được sanitize, thường là Markdown của user render ra HTML.
  2. URL do user nhập đặt vào href hoặc src. React 19 đã chặn URL javascript: trong href (thay bằng một URL ném lỗi), nhưng bạn vẫn nên tự validate scheme vì không phải chỗ render nào cũng đi qua React DOM, ví dụ HTML email hay PDF.
  3. JSON nhúng vào thẻ <script> (JSON-LD, state khởi tạo). Chuỗi </script> trong dữ liệu sẽ đóng thẻ sớm và mở đường cho script tiếp theo.
  4. Thư viện bên thứ ba thao tác DOM trực tiếp (editor, chart, widget) với dữ liệu từ user.

Render Markdown của user cho đúng

Pipeline unified dưới đây cho phép user viết Markdown, kể cả một ít HTML, nhưng loại bỏ mọi thứ có thể chạy script:

// lib/markdown.ts
import 'server-only'
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
import remarkRehype from 'remark-rehype'
import rehypeRaw from 'rehype-raw'
import rehypeSanitize from 'rehype-sanitize'
import rehypeHighlight from 'rehype-highlight'
import rehypeStringify from 'rehype-stringify'

export async function renderUserMarkdown(markdown: string): Promise<string> {
  const file = await unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype, { allowDangerousHtml: true }) // giữ HTML thô để rehype-raw parse
    .use(rehypeRaw)
    .use(rehypeSanitize) // PHẢI đứng sau rehype-raw: lọc trên cây HTML cuối cùng
    .use(rehypeHighlight) // plugin "tin cậy" chạy SAU sanitize, nếu không class của nó bị xóa
    .use(rehypeStringify)
    .process(markdown)
  return String(file)
}
// app/notes/[id]/page.tsx
import { renderUserMarkdown } from '@/lib/markdown'
import { getNote } from '@/lib/dal'

export default async function NotePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  const note = await getNote(id)
  const html = await renderUserMarkdown(note.body)
  return <article className="prose" dangerouslySetInnerHTML={{ __html: html }} />
}

Schema mặc định của rehype-sanitize theo quy tắc của GitHub: bỏ script, style, iframe, mọi thuộc tính on*, URL javascript:, và thêm tiền tố user-content- vào id/name để chống DOM clobbering. Lỗi hay gặp nhất là thứ tự plugin: đặt rehypeSanitize trước rehypeRaw thì HTML thô được parse sau khi đã lọc, coi như không lọc gì.

Nếu dữ liệu đầu vào đã là HTML (từ rich text editor chẳng hạn), dùng DOMPurify. Trên server, gói isomorphic-dompurify lo phần jsdom cho bạn:

import DOMPurify from 'isomorphic-dompurify'

const clean = DOMPurify.sanitize(editorHtml, { USE_PROFILES: { html: true } })

Hai lưu ý thực tế. Sanitize lúc render (hoặc lúc ghi và lúc render), vì dữ liệu cũ trong database có thể được ghi từ trước khi bạn thêm sanitize. Và JSON-LD dùng đúng cách tài liệu Next.js chỉ: JSON.stringify(jsonLd).replace(/</g, '\\u003c') trước khi đưa vào dangerouslySetInnerHTML.

CSP với nonce trong proxy.ts

Content Security Policy là lớp thứ hai: kể cả khi có một chỗ XSS lọt qua, trình duyệt từ chối chạy script không mang đúng nonce. Next.js tự đọc nonce từ header Content-Security-Policy của request và gắn vào script của framework.

// proxy.ts  (Next.js 15 trở xuống: middleware.ts, export function middleware)
import { NextResponse, type NextRequest } from 'next/server'

export function proxy(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
  const isDev = process.env.NODE_ENV === 'development'

  const csp = `
    default-src 'self';
    script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${isDev ? " 'unsafe-eval'" : ''};
    style-src 'self' 'nonce-${nonce}';
    img-src 'self' blob: data:;
    font-src 'self';
    connect-src 'self';
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  `.replace(/\s{2,}/g, ' ').trim()

  const requestHeaders = new Headers(request.headers)
  requestHeaders.set('x-nonce', nonce)
  requestHeaders.set('Content-Security-Policy', csp) // Next.js đọc nonce từ đây

  const response = NextResponse.next({ request: { headers: requestHeaders } })
  // Giai đoạn đầu: đổi tên header thành Content-Security-Policy-Report-Only
  // để thu thập vi phạm mà không làm hỏng trang
  response.headers.set('Content-Security-Policy', csp)
  return response
}

export const config = {
  matcher: [
    {
      source: '/((?!api|_next/static|_next/image|favicon.ico).*)',
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
}

Script bên thứ ba (analytics, chat widget) cần nonce: đọc bằng (await headers()).get('x-nonce') trong Server Component rồi truyền vào prop nonce của next/script. 'strict-dynamic' cho phép script đã có nonce tải tiếp script khác, nên bạn không phải liệt kê từng domain CDN.

Cái giá phải trả là rendering. Nonce phải mới cho mỗi request, nên mọi trang dùng nonce phải render động: mất static optimization, mất ISR, không cache được ở CDN, và theo tài liệu Next.js thì không dùng chung được với Partial Prerendering. Quy tắc của mình:

  • Dùng nonce khi app có dữ liệu nhạy cảm (dashboard, admin, thanh toán) và trang đằng nào cũng render động vì phụ thuộc session.
  • Trang marketing, blog, docs: set CSP tĩnh trong next.config.ts (không nonce), hoặc thử SRI hash (experimental.sri) để vẫn giữ được static generation.
  • Luôn rollout bằng Content-Security-Policy-Report-Only vài ngày, đọc báo cáo vi phạm, rồi mới bật chặn.

CSRF: SameSite cookie và origin check của Server Actions

CSRF là khi một trang khác khiến trình duyệt của nạn nhân gửi request kèm cookie tới app của bạn. Next.js cho Server Actions hai lớp bảo vệ sẵn: action chỉ gọi được bằng POST, và Next.js so header Origin với Host (hoặc X-Forwarded-Host), không khớp thì hủy request. Lớp thứ ba là cookie SameSite, nhưng đừng trông vào giá trị mặc định: Chrome và Edge coi cookie không khai báo SameSite là Lax, còn Firefox và Safari thì không, nên luôn tự đặt SameSite cho cookie session.

Nếu app chạy sau reverse proxy mà domain public khác host nội bộ, khai báo origin hợp lệ thay vì tắt kiểm tra:

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    serverActions: {
      allowedOrigins: ['app.example.com', '*.app.example.com'],
    },
  },
}

export default nextConfig

Route Handlers thì không có kiểm tra này. Một POST /api/invoices/[id]/void đọc cookie session là mục tiêu CSRF nếu bạn không tự kiểm tra. Mình dùng helper nhỏ dựa trên Fetch Metadata và Origin:

// lib/same-origin.ts
export function isSameOriginRequest(request: Request): boolean {
  // Trình duyệt hiện đại gửi Sec-Fetch-Site; "cross-site" thì chắc chắn từ chối
  const fetchSite = request.headers.get('sec-fetch-site')
  if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false

  const origin = request.headers.get('origin')
  // Chỉ tin X-Forwarded-Host nếu reverse proxy của bạn ghi đè header này
  const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host')
  if (!origin || !host) return false
  return new URL(origin).host === host
}

// app/api/invoices/[id]/void/route.ts
// if (!isSameOriginRequest(request)) return new Response('Forbidden', { status: 403 })

Ba bẫy cần biết. SameSite=Lax vẫn gửi cookie khi người dùng điều hướng top-level bằng GET, nên GET handler không bao giờ được thay đổi dữ liệu (kể cả kiểu /logout?all=1). "Site" trong SameSite là eTLD+1, không phải origin: user-content.app.example.com cùng site với app.example.com, nên một subdomain host nội dung do user upload có thể gửi request "same-site". Và API dùng header Authorization: Bearer thay vì cookie thì không bị CSRF, nhưng đổi lại token phải nằm ở chỗ JavaScript đọc được, tức là XSS sẽ lấy được nó.

Authentication: cookie, hash mật khẩu, rate limit, MFA

OWASP 2025 gọi nhóm này là A07 Authentication Failures. Nếu được, hãy dùng thư viện auth đã được audit nhiều thay vì tự viết từ đầu. Nhưng dù dùng thư viện nào, bạn vẫn phải tự kiểm tra từng điểm dưới đây.

Session cookie

  • HttpOnly: JavaScript không đọc được, XSS không lấy được session.
  • Secure: chỉ gửi qua HTTPS.
  • SameSite=Lax: đủ cho hầu hết app; dùng Strict cho app nội bộ nếu chấp nhận được việc user bấm link từ email vào sẽ chưa thấy mình đăng nhập.
  • Tiền tố __Host-: trình duyệt chỉ nhận cookie này khi có Secure, Path=/ và không có Domain, nên một subdomain bị chiếm không ghi đè được session của bạn.
  • Token là 32 byte ngẫu nhiên; database chỉ lưu SHA-256 của token, để một bản dump database không dùng ngay được để đăng nhập.
  • Tạo session mới sau khi đăng nhập (chống session fixation) và xóa session ở server khi logout, không chỉ xóa cookie.

Login action hoàn chỉnh

// app/login/actions.ts
'use server'

import { createHash, randomBytes } from 'node:crypto'
import { cookies, headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { hash, verify } from '@node-rs/argon2'
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
import { z } from 'zod'
import { prisma } from '@/lib/prisma'
import { SESSION_COOKIE } from '@/lib/dal'

// Cấu hình tối thiểu OWASP khuyến nghị cho argon2id: 19 MiB, 2 vòng, 1 luồng
const ARGON2_OPTIONS = { memoryCost: 19456, timeCost: 2, parallelism: 1 }
const SESSION_TTL_SECONDS = 7 * 24 * 3600

const redis = Redis.fromEnv()
const perAccount = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, '15 m'), prefix: 'rl:login:acct' })
const perIp = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(50, '15 m'), prefix: 'rl:login:ip' })

let dummyHash: Promise<string> | undefined
const getDummyHash = () => (dummyHash ??= hash('placeholder-password-for-timing', ARGON2_OPTIONS))

const LoginInput = z.object({
  email: z.string().trim().toLowerCase().email().max(254),
  password: z.string().min(1).max(256),
})

export async function login(_prev: { error: string } | null, formData: FormData) {
  const parsed = LoginInput.safeParse({
    email: formData.get('email'),
    password: formData.get('password'),
  })
  if (!parsed.success) return { error: 'Email hoặc mật khẩu không đúng' }
  const { email, password } = parsed.data

  // Chỉ tin header IP mà proxy/CDN của bạn ghi đè, nếu không kẻ tấn công tự đặt được
  const ip = (await headers()).get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
  const [acct, byIp] = await Promise.all([perAccount.limit(email), perIp.limit(ip)])
  if (!acct.success || !byIp.success) return { error: 'Bạn thử quá nhiều lần, vui lòng đợi vài phút' }

  const user = await prisma.user.findUnique({
    where: { email },
    select: { id: true, passwordHash: true },
  })
  // Luôn chạy verify, kể cả khi không có user, để thời gian phản hồi không tiết lộ email tồn tại
  const valid = await verify(user?.passwordHash ?? (await getDummyHash()), password)
  if (!user || !valid) return { error: 'Email hoặc mật khẩu không đúng' }

  const token = randomBytes(32).toString('base64url')
  await prisma.session.create({
    data: {
      userId: user.id,
      tokenHash: createHash('sha256').update(token).digest('hex'),
      expiresAt: new Date(Date.now() + SESSION_TTL_SECONDS * 1000),
    },
  })

  ;(await cookies()).set(SESSION_COOKIE, token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    path: '/',
    maxAge: SESSION_TTL_SECONDS,
  })
  redirect('/invoices')
}

Về hash mật khẩu: argon2id là lựa chọn mặc định hiện nay; @node-rs/argon2 dùng argon2id và các tham số trên làm mặc định. Nếu hệ thống cũ đang dùng bcrypt thì giữ cost từ 10 trở lên và nhớ bcrypt chỉ dùng 72 byte đầu của mật khẩu. Muốn chuyển dần sang argon2id, hãy rehash ngay lúc user đăng nhập thành công, vì đó là lúc duy nhất bạn có mật khẩu dạng plaintext.

Rate limit tách hai chiều: theo tài khoản (chặn dò mật khẩu một người) và theo IP (chặn credential stuffing trải trên nhiều tài khoản), với ngưỡng IP cao hơn để văn phòng dùng chung NAT không bị khóa oan. Thông báo lỗi luôn giống nhau cho "sai email" và "sai mật khẩu".

MFA

Với tài khoản admin hoặc có quyền thanh toán, MFA nên là bắt buộc. Thứ tự mình ưu tiên: passkey/WebAuthn (chống phishing vì gắn với domain; phía server có thư viện như @simplewebauthn/server), rồi TOTP qua app authenticator, cuối cùng mới là SMS. Ba chi tiết hay bị làm sai: recovery code phải hash như mật khẩu, bước nhập mã OTP cũng cần rate limit riêng, và các thao tác nhạy cảm (đổi email, tắt MFA, tạo API key) phải yêu cầu xác thực lại.

SSRF: khi server fetch URL do user nhập

SSRF (Server-Side Request Forgery) xảy ra khi tính năng kiểu "dán link để xem preview", "nhập URL webhook", "import ảnh từ URL" cho phép user khiến server của bạn gọi tới địa chỉ nội bộ. Các mục tiêu quen thuộc: http://169.254.169.254/ (metadata service của cloud, nơi có thể chứa credential tạm của máy), http://localhost:6379 (Redis không mật khẩu), dashboard admin chỉ mở trong VPC.

Cách làm ngây thơ là resolve DNS, kiểm tra IP, rồi gọi fetch(url). Nó sai vì fetch resolve DNS thêm một lần nữa. Kẻ tấn công điều khiển DNS có thể trả IP public ở lần thứ nhất và 127.0.0.1 ở lần thứ hai (DNS rebinding). Cách đúng là kiểm tra IP ngay tại bước kết nối, tức "resolve rồi connect" trong cùng một lookup:

// lib/safe-fetch.ts
import 'server-only'
import dns from 'node:dns'
import net, { type LookupFunction } from 'node:net'
import ipaddr from 'ipaddr.js'
import { Agent, fetch } from 'undici'

function isPublicAddress(address: string): boolean {
  if (!ipaddr.isValid(address)) return false
  // process() đổi IPv4-mapped IPv6 (::ffff:10.0.0.1) về IPv4 trước khi xét
  // range() trả 'private', 'loopback', 'linkLocal', 'uniqueLocal'... ; chỉ 'unicast' là public
  return ipaddr.process(address).range() === 'unicast'
}

// Mọi kết nối TCP của agent đều đi qua đây, kể cả kết nối sau redirect
const guardedLookup: LookupFunction = (hostname, options, callback) => {
  dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
    if (err) return callback(err, '')
    const blocked = addresses.length === 0 || addresses.some((a) => !isPublicAddress(a.address))
    if (blocked) {
      const error = Object.assign(new Error(`SSRF blocked: ${hostname}`), { code: 'ESSRFBLOCKED' })
      return callback(error, '')
    }
    if (options.all) return callback(null, addresses)
    callback(null, addresses[0].address, addresses[0].family)
  })
}

const ssrfSafeAgent = new Agent({ connect: { lookup: guardedLookup, timeout: 5_000 } })

function assertAllowedUrl(url: URL) {
  if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Scheme không được phép')
  if (url.port && !['80', '443'].includes(url.port)) throw new Error('Port không được phép')
  // Host là IP literal thì net.connect KHÔNG gọi lookup, phải tự kiểm tra
  const host = url.hostname.replace(/^\[|\]$/g, '')
  if (net.isIP(host) && !isPublicAddress(host)) throw new Error('Địa chỉ nội bộ không được phép')
}

export async function safeFetch(input: string, maxRedirects = 3) {
  let url = new URL(input)
  for (let hop = 0; hop <= maxRedirects; hop++) {
    assertAllowedUrl(url)
    const res = await fetch(url, {
      dispatcher: ssrfSafeAgent,
      redirect: 'manual', // tự xử lý redirect để kiểm tra từng URL đích
      signal: AbortSignal.timeout(10_000),
    })
    const location = res.headers.get('location')
    if (res.status >= 300 && res.status < 400 && location) {
      await res.body?.cancel()
      url = new URL(location, url)
      continue
    }
    return res
  }
  throw new Error('Quá nhiều redirect')
}

Allowlist range() === 'unicast' an toàn hơn blocklist tự viết, vì bạn không phải nhớ hết 10.0.0.0/8, 172.16.0.0/12, 100.64.0.0/10, fc00::/7, fe80::/10, 0.0.0.0 và các dạng IPv6 nhúng IPv4. Phải xử lý redirect thủ công vì một URL public hoàn toàn có thể trả 302 Location: http://169.254.169.254/, và host IP literal thì đi thẳng vào net.connect mà không qua lookup.

Code chưa phải là tất cả. Với tính năng fetch URL thật trong production, mình thêm:

  • Giới hạn kích thước response (đọc stream, dừng khi quá vài MB) và chỉ nhận Content-Type mong đợi.
  • Không trả nguyên body hay thông báo lỗi chi tiết cho user, nếu không bạn vô tình xây một cái proxy để họ dò mạng nội bộ.
  • Ở tầng hạ tầng: bật IMDSv2 bắt buộc trên AWS (với hop limit 1 khi chạy container), chạy tác vụ fetch trong worker riêng có egress firewall chỉ ra internet.

Secrets: NEXT_PUBLIC_, file .env bị commit và secret scanning

NEXT_PUBLIC_ nghĩa là public

Next.js inline mọi biến có tiền tố NEXT_PUBLIC_ vào bundle JavaScript lúc build. NEXT_PUBLIC_STRIPE_SECRET_KEY hay NEXT_PUBLIC_DATABASE_URL là secret đã công khai cho bất kỳ ai mở DevTools. Biến không có tiền tố chỉ đọc được trên server, và module nào đọc chúng nên có import 'server-only'. Kiểm tra nhanh sau mỗi lần build:

npm run build
# Tìm chuỗi trông giống secret trong bundle client
grep -rnE "sk_live_|-----BEGIN [A-Z ]*PRIVATE KEY|postgres(ql)?://" .next/static \
  && echo "CÓ SECRET TRONG BUNDLE" || echo "OK"

.gitignore nên chặn .env* và chỉ cho phép .env.example (chứa tên biến, không có giá trị thật).

Nếu file .env đã bị commit: rotate trước, dọn lịch sử sau

Thứ tự quan trọng hơn bạn nghĩ. Repo public bị bot quét secret liên tục, và một khi đã push thì bạn phải coi secret đó là đã lộ: nó có thể nằm trong bản clone của đồng nghiệp, trong fork, trong cache của CI, trong PR. Viết lại lịch sử git không thu hồi được những bản sao đó. Vì vậy:

  1. Rotate hoặc revoke ngay ở nhà cung cấp: database password, API key, OAuth client secret, NEXT_SERVER_ACTIONS_ENCRYPTION_KEY nếu có. Deploy bản dùng secret mới.
  2. Đọc audit log của nhà cung cấp trong khoảng thời gian secret bị lộ để xem nó đã bị dùng chưa.
  3. Sau đó mới xóa khỏi lịch sử git để tránh lộ thêm lần nữa:
# Làm trên một bản clone mới
git clone git@github.com:acme/invoices.git invoices-clean && cd invoices-clean

# Xóa hẳn file khỏi mọi commit
git filter-repo --invert-paths --path .env --path .env.production

# Hoặc chỉ thay chuỗi secret (mỗi dòng: chuỗi_cũ==>chuỗi_mới)
printf '%s\n' 'sk_live_REPLACE_WITH_LEAKED_VALUE==>REDACTED' > ../replacements.txt
git filter-repo --replace-text ../replacements.txt

# filter-repo gỡ remote origin để tránh push nhầm; thêm lại rồi force push
git remote add origin git@github.com:acme/invoices.git
git push origin --force --all
git push origin --force --tags

Sau khi force push: báo cả team clone lại (đừng git pull vào bản cũ, sẽ merge lịch sử cũ trở lại), và liên hệ GitHub Support nếu cần xóa cached view hoặc ref của pull request vẫn còn chứa commit cũ.

Chặn từ đầu: gitleaks và push protection

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.24.2
    hooks:
      - id: gitleaks

Chạy pre-commit install một lần, và chạy gitleaks git -v để quét toàn bộ lịch sử repo hiện tại. Pre-commit hook dễ bị bỏ qua bằng --no-verify, nên cần thêm một lớp ở server: bật secret scanning với push protection của GitHub để chặn push chứa secret có định dạng đã biết. Tính năng này miễn phí cho repo public; với repo private, nó nằm trong gói GitHub Secret Protection trả phí.

Security headers và rủi ro từ dependency

Header cho mọi response

// next.config.ts
import type { NextConfig } from 'next'

const securityHeaders = [
  // Chỉ thêm preload khi chắc chắn mọi subdomain đều chạy HTTPS; gỡ ra rất mất thời gian
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
  // Cho trình duyệt cũ; trình duyệt mới dùng frame-ancestors trong CSP
  { key: 'X-Frame-Options', value: 'DENY' },
]

const nextConfig: NextConfig = {
  poweredByHeader: false,
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }]
  },
}

export default nextConfig

Kiểm tra trên môi trường thật, vì CDN hoặc reverse proxy có thể ghi đè hoặc nuốt mất header:

curl -sI https://app.example.com/invoices | grep -iE "strict-transport|content-security|x-content-type|x-frame|referrer"

X-Content-Type-Options: nosniff đặc biệt quan trọng nếu bạn phục vụ file người dùng upload: nó ngăn trình duyệt "đoán" một file .txt thành HTML rồi chạy script bên trong. frame-ancestors 'none' trong CSP chống clickjacking tốt hơn X-Frame-Options vì cho phép khai báo nhiều origin khi cần nhúng.

Framework của bạn cũng là một dependency

OWASP 2025 tách hẳn một mục A03 Software Supply Chain Failures. Ví dụ gần nhất với dân Next.js là CVE-2025-55182 (tháng 12/2025): lỗi remote code execution không cần xác thực trong React Server Components, CVSS 10.0, ảnh hưởng Next.js 15.x và 16.x dùng App Router, kể cả app không tự định nghĩa Server Function nào. React vá ở 19.0.1, 19.1.2, 19.2.1; Next.js vá ở các bản như 15.5.7 và 16.0.7. Next.js đóng gói sẵn bản React dùng cho server bên trong package next, nên cách vá là nâng next, không phải chỉ nâng react trong package.json.

Bài học vận hành: theo dõi security advisory của vercel/next.js và facebook/react trên GitHub, bật Dependabot security updates, và có quy trình deploy bản vá trong vài giờ chứ không phải đợi sprint sau. Phần lockfile, install script và các vụ tấn công npm, mình viết riêng trong bài về supply chain attack trên npm.

Bảng đối chiếu OWASP Top 10:2025 cho Next.js

OWASP 2025 Nó hiện ra ở đâu trong Next.js Kiểm tra cụ thể
A01 Broken Access Control (gồm SSRF) Route Handler, Server Action, DAL, fetch URL của user Mọi query có điều kiện tenant; action tự gọi requireUser(); dùng safeFetch cho URL từ user
A02 Security Misconfiguration next.config.ts, header, source map, trang lỗi Có HSTS, nosniff, CSP; poweredByHeader: false; không public source map production
A03 Software Supply Chain Failures package-lock.json, bản next/react, GitHub Actions npm ci, review diff lockfile, vá advisory của Next/React nhanh
A04 Cryptographic Failures Cookie, hash mật khẩu, token reset argon2id, token 32 byte ngẫu nhiên, chỉ lưu hash của token
A05 Injection $queryRawUnsafe, Prisma.raw, filter dựng từ JSON Chỉ $queryRaw tagged template; validate kiểu bằng zod
A06 Insecure Design Luồng reset mật khẩu, mời thành viên, đổi email Token dùng một lần, có hạn; xác thực lại cho thao tác nhạy cảm
A07 Authentication Failures Login action, session, MFA Rate limit hai chiều, cookie __Host- httpOnly, MFA cho admin
A08 Software or Data Integrity Failures Script bên thứ ba, webhook, cache SRI hoặc self-host script; verify chữ ký webhook (Stripe, GitHub)
A09 Security Logging and Alerting Failures Login thất bại, lỗi 403/404 hàng loạt Log có user ID và IP, không log mật khẩu/token; cảnh báo khi tăng đột biến
A10 Mishandling of Exceptional Conditions error.tsx, catch trả err.message Không trả stack trace; lỗi auth thì fail closed (từ chối), không fail open

Dùng bảng này như checklist khi review PR: với mỗi file route.ts hay 'use server' mới, đi qua A01, A05, A07 trước, vì đó là ba nhóm lỗi mình thấy nhiều nhất trong code Next.js.

Những lỗi thường gặp

1. Check quyền ở page, quên trong action.

// SAI: page redirect nếu không phải admin, nhưng action vẫn gọi trực tiếp được
async function deleteAllInvoices() {
  'use server'
  await prisma.invoice.deleteMany()
}

// ĐÚNG: action tự kiểm tra và scope theo tenant
async function deleteDraftInvoices() {
  'use server'
  const user = await requireUser()
  if (user.role !== 'ADMIN') throw new Error('Forbidden')
  await prisma.invoice.deleteMany({ where: { organizationId: user.organizationId, status: 'DRAFT' } })
}

2. Mass assignment từ FormData.

// SAI: user thêm <input name="role" value="ADMIN"> là tự nâng quyền
await prisma.user.update({ where: { id: user.id }, data: Object.fromEntries(formData) })

// ĐÚNG: chỉ nhận đúng field cho phép sửa
const Profile = z.object({ name: z.string().min(1).max(100) })
const data = Profile.parse({ name: formData.get('name') })
await prisma.user.update({ where: { id: user.id }, data })

3. Trả lỗi chi tiết cho client. return { error: String(err) } có thể lộ tên bảng, câu SQL, đường dẫn file. Log chi tiết ở server, trả cho client một thông báo chung kèm request ID để tra log.

4. Secret trong biến public. NEXT_PUBLIC_OPENAI_API_KEY để "gọi thẳng từ client cho nhanh". Đúng ra: gọi qua Route Handler hoặc Server Action có rate limit, key chỉ nằm trên server.

5. Sanitize sai thứ tự. .use(rehypeSanitize).use(rehypeRaw) nghĩa là HTML thô được parse sau khi lọc. Sanitize phải là bước xử lý cây HTML cuối cùng, trước các plugin bạn tin cậy.

6. Coi middleware/proxy là lớp phân quyền duy nhất. Đã có CVE bypass trên thực tế, và matcher thay đổi theo thời gian. Proxy dùng để redirect cho trải nghiệm tốt hơn; phân quyền nằm ở DAL.

7. Fetch URL của user bằng fetch thường. Kể cả khi đã kiểm tra hostname bằng regex, redirect và DNS rebinding vẫn qua được. Dùng agent kiểm tra IP tại bước connect như phần SSRF ở trên.

Checklist

  • Mọi Server Action và Route Handler tự gọi requireUser() và kiểm tra quyền trên đúng resource, không dựa vào proxy hay page.
  • Mọi query đến dữ liệu có tenant đều có organizationId (hoặc tương đương) ngay trong where; có integration test IDOR với hai org.
  • Không còn $queryRawUnsafe hay Prisma.raw nhận input của user; mọi input đi qua zod trước khi vào where.
  • Mọi chỗ dangerouslySetInnerHTML đều nhận HTML đã qua rehype-sanitize hoặc DOMPurify; JSON-LD đã escape <.
  • CSP đã bật (nonce cho trang động, CSP tĩnh hoặc SRI cho trang tĩnh), rollout qua Report-Only trước.
  • Route Handler thay đổi dữ liệu có kiểm tra Origin/Sec-Fetch-Site; không có GET handler nào ghi dữ liệu.
  • Session cookie là __Host-, HttpOnly, Secure, SameSite=Lax; database chỉ lưu hash của token; mật khẩu hash bằng argon2id.
  • Login có rate limit theo tài khoản và theo IP; admin bắt buộc MFA.
  • Mọi tính năng fetch URL do user nhập đi qua hàm chống SSRF có kiểm tra IP lúc connect và xử lý redirect thủ công.
  • grep bundle .next/static không thấy secret; gitleaks chạy ở pre-commit và trong CI; push protection đã bật.

Đọc thêm

Last updated: September 21, 2025

Comments