Security และ Patterns
ไอเดียในหนึ่งประโยค
หัวข้อที่มีชื่อว่า “ไอเดียในหนึ่งประโยค”ทุก Server Action คือ public HTTP endpoint เราจึงต้อง validate input และ authorize ผู้เรียกภายใน action เอง อย่าเชื่อว่าถูกเรียกจาก UI ของเราเท่านั้น
Validate and authorize inside the action
หัวข้อที่มีชื่อว่า “Validate and authorize inside the action”ใครที่รู้ action ID ก็เรียก action ด้วย payload อะไรก็ได้ ดังนั้น action ต้องเช็คซ้ำว่าผู้ใช้คือใคร และ parse input ใหม่ทุกครั้งที่ถูกเรียก authorize ก่อน แล้ว validate แล้วค่อย mutate UI ที่ render form ไม่ใช่ security boundary
// app/actions.ts — authorize, then validate, then mutate'use server'
import { auth } from '@/lib/auth'import { z } from 'zod'
const schema = z.object({ id: z.string().uuid(), title: z.string().min(1) })
export async function updatePost(formData: FormData) { const session = await auth() if (!session?.user) throw new Error('Unauthorized')
const parsed = schema.safeParse({ id: formData.get('id'), title: formData.get('title'), }) if (!parsed.success) return { error: 'Invalid input' }
await db.post.update({ where: { id: parsed.data.id, authorId: session.user.id }, data: { title: parsed.data.title }, })}The built-in protections are not authorization
หัวข้อที่มีชื่อว่า “The built-in protections are not authorization”Next.js เข้ารหัสตัวแปรที่เรา close over ใน action สร้าง action ID ที่เดาไม่ได้ และ dead-code-eliminate action ที่เราไม่เคยใช้ ทำให้ action พวกนั้นไม่กลายเป็น public endpoint สิ่งเหล่านี้ลด attack surface แต่ไม่มีตัวไหนเช็คว่าผู้ใช้คนนี้ทำ action นี้ได้ไหม authorization และ validation จึงยังเป็นหน้าที่ของเรา
// app/actions.ts — unused actions are stripped at build; used ones get an ID'use server'
// Used in the app: Next.js creates an encrypted, unguessable ID for it.export async function updateUser(formData: FormData) {}
// Never imported anywhere: dead-code-eliminated, no public endpoint exists.export async function deleteUser(formData: FormData) {}A reusable action wrapper with typed results
หัวข้อที่มีชื่อว่า “A reusable action wrapper with typed results”เพื่อเลี่ยงการเขียน auth และ validation ซ้ำ ให้ห่อ action ด้วย helper ที่ authorize, parse ด้วย schema และ return ผลลัพธ์แบบ typed ทุก action จะ return รูปแบบ success หรือ error เดียวกัน ซึ่งเข้ากับ useActionState ได้อย่างสะอาด
// app/lib/action.ts — one wrapper: authorize, validate, typed resultimport { auth } from '@/lib/auth'import { z } from 'zod'
type Result<T> = { ok: true; data: T } | { ok: false; error: string }
export function authedAction<S extends z.ZodType, T>( schema: S, run: (input: z.infer<S>, userId: string) => Promise<T>,) { return async (formData: FormData): Promise<Result<T>> => { const session = await auth() if (!session?.user) return { ok: false, error: 'Unauthorized' }
const parsed = schema.safeParse(Object.fromEntries(formData)) if (!parsed.success) return { ok: false, error: 'Invalid input' }
const data = await run(parsed.data, session.user.id) return { ok: true, data } }}Keep DB access in a data-access layer
หัวข้อที่มีชื่อว่า “Keep DB access in a data-access layer”อย่ากระจาย query ดิบไปทั่ว action ให้ทุก read และ write ผ่าน data-access layer ที่แต่ละ function บังคับ ownership check ของตัวเอง action เรียก layer นั้น authorization จึงอยู่ติดกับข้อมูลและลืมยาก
// app/data/posts.ts — the only place that touches the posts tableimport 'server-only'
export async function updatePostForUser(id: string, userId: string, title: string) { return db.post.update({ where: { id, authorId: userId }, data: { title }, })}
graph TD
A["Public request hits action ID"] --> B{"Authorized user?"}
B -->|"No"| C["Reject: Unauthorized"]
B -->|"Yes"| D{"Input valid by schema?"}
D -->|"No"| E["Reject: Invalid input"]
D -->|"Yes"| F["Data-access layer with ownership check"]
F --> G["Mutate the database"]