Next.js Server Actions จะเปิดเผยทุกฟังก์ชันที่ถูก export ในไฟล์ use-server ให้เป็น public HTTP endpoint โดยอัตโนมัติ พร้อมกำหนด unique identifier ให้เพื่อให้ client ใช้เรียกใช้งานฟังก์ชันนั้นๆ endpoint นี้จะคงอยู่เสมอไม่ว่าจะมีปุ่ม ลิงก์ หรือองค์ประกอบ UI อื่นๆ แสดงผลอยู่หรือไม่ ซึ่งหมายความว่าใครก็ตามที่ค้นพบ identifier นี้จะสามารถเรียกใช้งานฟังก์ชันได้โดยตรง

การออกแบบของ framework นี้ปฏิบัติกับ Server Actions เหมือนเป็นฟังก์ชัน helper ทั่วไป แต่ในขณะ runtime พวกมันจะกลายเป็น URL ที่สามารถเข้าถึงได้ สำหรับนักพัฒนาที่เข้าใจว่า UI เป็นด่านป้องกันเพียงอย่างเดียว สิ่งนี้จะสร้างช่องโหว่ (attack surface) ที่มองไม่เห็น ซึ่งสามารถถูกโจมตีได้ด้วยการส่ง request ที่สร้างขึ้นมาเพียงครั้งเดียว

ทำไม Server Actions ถึงดูเหมือนจะปลอดภัย

Server Actions ถูกนำเข้ามาเพื่อให้เหล่านักพัฒนาสามารถเขียนโค้ดฝั่ง server ไว้ข้างๆ component ได้เลย ช่วยหลีกเลี่ยงความยุ่งยาก (boilerplate) ของการสร้าง API routes แยกต่างหาก ตัวอย่างการใช้งานทั่วไปคือ:

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

เนื่องจากฟอร์มเป็นวิธีเดียวที่มองเห็นได้ในการเรียกใช้งาน deleteInvoice นักพัฒนาจำนวนมากจึงเลือกที่จะซ่อนปุ่มสำหรับผู้ใช้ที่ไม่ได้รับอนุญาต, คิดว่า TypeScript signatures จะช่วยป้องกันข้อมูลที่ผิดรูปแบบได้ และเชื่อมั่นว่าฟังก์ชันนั้นอยู่ใน server-only module ซึ่งสมมติฐานเหล่านี้ไม่มีสิ่งใดที่ให้การป้องกันที่แท้จริงได้เลย

ช่องโหว่ที่ถูกซ่อนไว้

เมื่อไฟล์มี “use server” Next.js จะคอมไพล์แต่ละฟังก์ชันที่ถูก export ให้กลายเป็น endpoint เช่น:

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

<action-id> คือ hash ที่คงที่ซึ่งถูกฝังอยู่ใน client bundle ผู้โจมตีสามารถหาค่านี้ได้โดย:

  • การตรวจสอบ network traffic ของหน้าเว็บ
  • การอ่าน JavaScript ที่ถูก bundle มา (ID จะเป็น string ธรรมดา)
  • การคาดเดาจากรูปแบบการตั้งชื่อ (naming conventions) หากโปรเจกต์ใช้รูปแบบที่คาดเดาได้

เมื่อทราบ ID แล้ว ก็สามารถส่ง request จากเครื่องมือใดก็ได้ ไม่ว่าจะเป็น cURL, Postman หรือสคริปต์ที่เป็นอันตราย เพื่อข้ามผ่านการตรวจสอบในระดับ UI ทั้งหมด

ความเสี่ยงที่เป็นรูปธรรม 3 ประการ

ความเสี่ยง ทำไมถึงสำคัญ
การตรวจสอบที่ UI นั้นไม่ได้ผล การซ่อนปุ่มหรือลิงก์ไม่ได้เป็นการลบ endpoint ที่อยู่เบื้องหลังออกไป endpoint นั้นยังคงเข้าถึงได้ เหมือนกับหน้า admin ที่ถูกซ่อนไว้แต่ยังมีตัวตนอยู่บน server
TypeScript ไม่ได้ให้ความปลอดภัยในขณะ runtime Type จะถูกถอดออกเมื่อโค้ดทำงาน ฟังก์ชันที่ประกาศเป็น deleteInvoice(id: number) อาจได้รับ string ขนาดใหญ่, array หรือแม้แต่ JSON ที่เป็นอันตราย ซึ่งนำไปสู่ข้อผิดพลาดทาง logic หรือการโจมตีแบบ injection
ไม่มีการตรวจสอบสิทธิ์หรือการอนุญาตโดยค่าเริ่มต้น Server Actions ดูเหมือน helper ท้องถิ่น ทำให้นักพัฒนามักลืมเพิ่มการตรวจสอบ session, การป้องกัน CSRF หรือการตรวจสอบสิทธิ์ในระดับแถวข้อมูล (row-level permission) ซึ่งเป็นมาตรฐานใน API routes แบบดั้งเดิม

วิธีการรักษาความปลอดภัยให้กับ Server Action

  1. Authenticate the caller – ตรวจสอบว่ามี session หรือ token ที่ถูกต้องก่อนที่ business logic ใดๆ จะเริ่มทำงาน
  2. Validate the payload – ใช้ schema library (เช่น Zod, Yup) เพื่อบังคับใช้ประเภทข้อมูลและข้อจำกัดของค่าต่างๆ ในขณะ runtime
  3. Authorize the operation – นอกเหนือจากคำถามที่ว่า “ผู้ใช้ล็อกอินอยู่หรือไม่?” ต้องยืนยันด้วยว่าผู้ใช้เป็นเจ้าของข้อมูลชุดนั้นๆ ที่พวกเขากำลังพยายามแก้ไขหรือลบจริงๆ

ตัวอย่างแบบพื้นฐาน:

'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 } });
}

โค้ดนี้มีการตรวจสอบการยืนยันตัวตนอย่างชัดเจน, ตรวจสอบความถูกต้องของ id ที่ส่งเข้ามา และตรวจสอบให้แน่ใจว่าผู้ใช้ที่ล็อกอินอยู่เป็นเจ้าของ invoice นั้นจริงๆ ก่อนที่จะดำเนินการลบ

หลุมพรางอื่นๆ ใน Next.js ที่อาจมองข้ามได้

ปัญหา อาการ วิธีแก้ไข
ตัวแปรสภาพแวดล้อม NEXT_PUBLIC_ อะไรก็ตามที่ขึ้นต้นด้วย NEXT_PUBLIC_ จะถูก bundle เข้าไปใน client ซึ่งเป็นการเปิดเผยข้อมูลความลับ เก็บความลับไว้ใน env vars ปกติ และห้ามใช้ prefix NEXT_PUBLIC_ โดยเด็ดขาด
Open redirects การรับ query parameter redirect แล้วนำมาต่อกันตรงๆ อาจส่งผู้ใช้ไปยัง //evil.com ได้ ตรวจสอบเป้าหมายเทียบกับ whitelist หรือบังคับใช้การตรวจสอบ same-origin
Server-Side Request Forgery (SSRF) การดึงข้อมูลจาก URL ที่ผู้ใช้ระบุอาจทำให้ผู้โจมตีเข้าถึงบริการภายในหรือ cloud metadata endpoints ได้ ทำ allow-list สำหรับ hostname, บล็อกช่วง IP ส่วนตัว และตั้งค่า timeout
dangerouslySetInnerHTML การเรนเดอร์ HTML ที่ผู้ใช้ส่งมาโดยไม่มีการทำ sanitisation จะทำให้เกิดช่องโหว่ XSS ใช้ library อย่าง DOMPurify หรือหลีกเลี่ยงการใช้ raw HTML ไปเลย

การสแกนหารูปแบบเหล่านี้โดยอัตโนมัติ

เครื่องมือวิเคราะห์แบบ Static (Static-analysis tools) สามารถตรวจจับโครงสร้างที่มีความเสี่ยงตามที่ระบุไว้ข้างต้นได้ ตัวเลือกที่ใช้งานง่ายและรวดเร็วคือ Semgrep ซึ่งสามารถรวมเข้ากับ CI pipelines ได้:

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

ชุดกฎ (rule set) นี้รวมถึงการตรวจสอบฟังก์ชัน server ที่ถูก export, การใช้ NEXT_PUBLIC_ อย่างผิดวิธี, การทำ open redirects, รูปแบบ SSRF และการแทรก HTML ที่ไม่ปลอดภัย

สิ่งที่ควรติดตามต่อไป

  • การอัปเดตเฟรมเวิร์ก – คอยติดตามการปล่อยเวอร์ชันใหม่ๆ ของ Next.js เนื่องจากทีมงานอาจมีการนำ authentication hooks แบบ built-in มาใช้ หรือทำ sandbox ให้กับ endpoint ที่ถูกสร้างขึ้น
  • เครื่องมือจากชุมชน – มีการพัฒนา ESLint plugins ใหม่ๆ และกฎ Semgrep เฉพาะสำหรับ Next.js ออกมา เพื่อช่วยจัดการระบบป้องกันที่กล่าวถึงในที่นี้แบบอัตโนมัติ
  • เหตุการณ์ที่เกิดขึ้นจริง – เมื่อมีโปรเจกต์ต่างๆ หันมาใช้ Server Actions มากขึ้น ให้คอยสังเกตช่องโหว่ (exploits) ที่มีการเปิดเผยออกมา ซึ่งจะช่วยแสดงให้เห็นถึงความเสี่ยงในการใช้งานจริง การตรวจพบตั้งแต่เนิ่นๆ จะช่วยให้สามารถนำข้อมูลไปใช้ในการตรวจสอบความปลอดภัยภายในก่อนที่จะเกิดการรั่วไหล

บทสรุป: Next.js Server Action ไม่ใช่ private helper แต่เป็น public HTTP endpoint ทันทีที่คุณทำการ export ออกมา ให้ปฏิบัติกับมันเหมือนกับ API route อื่นๆ นั่นคือต้องมีการ authenticate, validate และ authorize มิฉะนั้น ความสะดวกสบายในการเขียนโค้ดฝั่งเซิร์ฟเวอร์ไว้ข้างๆ UI อาจกลายเป็นความเสี่ยงด้านความปลอดภัยได้อย่างรวดเร็ว