การตรวจสอบความปลอดภัยแบบหลายขั้นตอน: การเสริมความแข็งแกร่งให้แอปพลิเคชัน Next.js
การตรวจสอบความปลอดภัยของเว็บแอปพลิเคชันมักพบปัญหากลุ่มเดิม ไม่ว่าจะใช้เทคโนโลยีชุดใด ได้แก่ ช่องโหว่ในการกำหนดสิทธิ์การเข้าถึง การตรวจสอบข้อมูลนำเข้าที่ไม่ครบถ้วน ส่วนหัวความปลอดภัย HTTP ที่ไม่เพียงพอ และช่องโหว่จากเวลาตอบสนองในกระบวนการยืนยันตัวตน รายละเอียดต่างกันตามเฟรมเวิร์ก แต่รูปแบบปัญหาพื้นฐานคล้ายกัน สิ่งที่ต่างคือการจัดระบบตรวจสอบ การทบทวนที่ไม่มีโครงสร้างมักพบปัญหาชัดเจนแต่พลาดปัญหาที่สังเกตยาก
เราตรวจสอบแอปพลิเคชัน Next.js ที่ใช้งานจริงและใช้ Supabase เป็น backend แบบหลายขั้นตอน แอปจัดการการยืนยันตัวตน เก็บระเบียนที่เชื่อมกับผู้ใช้ และมีเส้นทาง API หลายเส้นทางสำหรับ frontend กับ webhook ภายนอก ไม่มีข้อค้นพบใดร้ายแรงถึงขั้นหายนะ แต่หลายข้ออาจกลายเป็นเหตุการณ์ร้ายแรงหากผู้ไม่หวังดีพบเข้า ต่อไปนี้คือขอบเขต สิ่งที่พบ และการแก้ไข
ขั้นตอนที่หนึ่ง: การยืนยันตัวตนและการกำหนดสิทธิ์การเข้าถึง
ขั้นแรกตรวจชั้นการยืนยันตัวตนและนโยบายความปลอดภัยระดับแถว (Row Level Security หรือ RLS) ที่ควบคุมการเข้าถึงข้อมูล นี่คือมาตรการสำคัญที่สุดของแอป Supabase หากการยืนยันตัวตนเสียหรือ RLS ตั้งค่าผิด การตรวจข้อมูลนำเข้าหรือจำกัดอัตราการเรียกใช้ก็ชดเชยไม่ได้
เริ่มจากทำทะเบียนตาราง Supabase ทั้งหมดพร้อมสถานะ RLS ทุกตารางในสคีมาที่เปิดให้เข้าถึงควรเปิด RLS ด้วยสิทธิ์เริ่มต้นของ Supabase ตารางใด ๆ ในสคีมาที่เปิดให้เข้าถึง เช่น public ซึ่งไม่ได้เปิด RLS จะอ่านและเขียนได้โดยทุกคนที่มี anon key ซึ่งตั้งใจให้เป็นสาธารณะและฝังในชุดไฟล์ฝั่งไคลเอนต์ แอปนี้มีตารางอ้างอิงหนึ่งตารางปิด RLS ตอนสร้างมีเพียงค่าคงที่ ต่อมามีแถวที่บรรจุข้อมูลเมตาเชื่อมกับผู้ใช้ซึ่งควรป้องกัน การแก้คือเปิด RLS และเพิ่มนโยบาย แต่ข้อบกพร่องของกระบวนการคือควรเปิด RLS ให้ตารางใหม่ตั้งแต่ต้น ไม่ใช่ทำย้อนหลัง
จากนั้นตรวจความถูกต้องของนโยบายในตารางที่ป้องกันแล้ว ข้อผิดพลาดทั่วไปคือเช็กรหัสผู้ใช้กับคอลัมน์ผิด เช่น นโยบายบน documents ที่ตรวจ auth.uid() = created_by จำกัดเฉพาะผู้สร้างได้ แต่ถ้ารองรับเอกสารร่วมกัน แถวผู้ร่วมงานในตารางเชื่อม document_collaborators ต้องมีนโยบายของตัวเอง การตรวจตารางหลักไม่ส่งผลต่อเนื่องไปยังข้อมูลที่เชื่อม
นโยบายหนึ่งมีตรรกะผิดที่หลุดจากการทบทวนโค้ด: ใช้เพียง USING ทั้งที่ควรใช้ทั้ง USING และ WITH CHECK USING กำหนดว่าแถวที่มีอยู่ใดบ้างที่คำสั่งสามารถดู อัปเดต หรือลบได้ ส่วน WITH CHECK กำหนดว่าแถวใหม่หรือแถวที่แก้ไขแล้วใดบ้างที่เขียนได้ เมื่อนโยบายที่ครอบคลุมการเขียน (FOR ALL หรือ FOR UPDATE) ละ WITH CHECK ไว้ PostgreSQL จะใช้นิพจน์ USING ซ้ำสำหรับการเขียนด้วย ดังนั้น USING ที่เขียนเป็นกฎสำหรับการอ่านจึงเปิดให้ผู้ใช้ที่ยืนยันตัวตนแล้วทุกคนเขียนแถวที่ตนอ่านได้ ซึ่งไม่ใช่พฤติกรรมที่ต้องการ รายละเอียดของ PostgreSQL RLS นี้มองข้ามได้ง่าย
ขั้นตอนที่สอง: การตรวจสอบข้อมูลนำเข้า
ขั้นที่สองตรวจทุกจุดที่ข้อมูลจากผู้ใช้เข้าสู่ระบบ ทั้งตัวจัดการเส้นทาง API ใน app/api/, server actions และแบบฟอร์ม เพื่อยืนยันว่าข้อมูลทั้งหมดผ่านการตรวจสอบก่อนใช้ และเมื่อไม่ผ่านจะคืนข้อผิดพลาดที่อธิบายได้ แทนข้อยกเว้นที่ไม่ได้จัดการ
แอปใช้ Zod บางส่วนแต่ไม่สม่ำเสมอ บางเส้นทางมีสคีมาครบและเรียก schema.parse() ตอนต้นตัวจัดการ บางเส้นทางอ่านฟิลด์ req.body โดยตรงโดยไม่ตรวจ แล้วอาศัยการระบุชนิดข้อมูลของ TypeScript ซึ่งไม่ให้ความปลอดภัยขณะรัน:
// ไม่ปลอดภัย: TypeScript type assertion, ไม่มีการตรวจสอบ runtime
const { userId, documentId } = req.body as { userId: string; documentId: string };
// ปลอดภัย: Zod parse, throw หาก input ไม่ถูกต้อง
const schema = z.object({
userId: z.string().uuid(),
documentId: z.string().uuid(),
});
const { userId, documentId } = schema.parse(req.body);
เราเพิ่มสคีมา Zod ให้ทุกเส้นทางที่ยังไม่ตรวจ และสร้างมิดเดิลแวร์ตรวจข้อมูลร่วมที่ทุกเส้นทางต้องผ่าน วิธีนี้ช่วยไม่ให้เส้นทางโค้ดที่เพิ่มภายหลังโดยไม่รู้ข้อกำหนดข้ามชั้นตรวจสอบได้
เส้นทางหนึ่งรับ redirectUrl ไปใช้ใน res.redirect() ทำให้เกิดช่องโหว่เปลี่ยนเส้นทางไปปลายทางใดก็ได้ ผู้โจมตีสร้างลิงก์ที่ดูเป็นของแอปแล้วพาผู้ใช้ไปเว็บไซต์ฟิชชิงหลังโต้ตอบอย่างดูน่าเชื่อถือได้ วิธีแก้คือตรวจปลายทางกับรายการพาธที่อนุญาต:
const ALLOWED_REDIRECT_PATHS = ['/dashboard', '/settings', '/profile'];
const redirectPath = schema.parse(req.query).redirectUrl;
if (!ALLOWED_REDIRECT_PATHS.includes(redirectPath)) {
return res.redirect('/dashboard');
}
return res.redirect(redirectPath);
ขั้นตอนที่สาม: ส่วนหัวความปลอดภัย HTTP และ CSP
แอปไม่มีส่วนหัว Content Security Policy (CSP) เลย หากไม่มี CSP การแทรกโค้ด XSS ที่สำเร็จผ่านเนื้อหาผู้ใช้หรือสคริปต์ภายนอกที่ถูกเจาะสามารถรัน JavaScript ใด ๆ ในบริบทแอป และเข้าถึงโทเคนยืนยันตัวตนกับข้อมูลผู้ใช้ได้
การเพิ่ม CSP ให้ Next.js ต้องระวัง เพราะมีสคริปต์ฝังในหน้าโดยค่าเริ่มต้น วิธีที่แนะนำใช้ nonce ซึ่งเป็นค่าสุ่มที่ปลอดภัยทางการเข้ารหัส สร้างใหม่ต่อคำขอ ใส่ในส่วนหัว CSP และกำกับสคริปต์ฝังในหน้าแต่ละตัว Next.js ใช้ nonce กับสคริปต์ของตัวเองโดยอ่านนโยบายจากส่วนหัวของคำขอ ดังนั้นมิดเดิลแวร์ในระบบใช้งานจริงต้องส่ง CSP (และโดยทั่วไปส่วนหัว x-nonce ด้วย) ผ่าน NextResponse.next({ request: { headers } }) ตามที่คู่มือ CSP ของ Next.js แสดง ตัวอย่างด้านล่างตั้งค่าเฉพาะส่วนหัวของการตอบกลับ และยังนำเข้าโมดูล crypto ของ Node ซึ่งไม่มีใน Edge runtime ในสภาพแวดล้อมนั้นให้ใช้ Web Crypto API ที่มีให้ใช้แบบโกลบอล (เช่น crypto.randomUUID()):
// middleware.ts
import { NextResponse } from 'next/server';
import crypto from 'crypto';
export function middleware(request: Request) {
const nonce = crypto.randomBytes(16).toString('base64');
const cspHeader = [
`default-src 'self'`,
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
].join('; ');
const response = NextResponse.next();
response.headers.set('Content-Security-Policy', cspHeader);
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}
เริ่มนำ CSP ขึ้นใช้ในโหมด Content-Security-Policy-Report-Only เพื่อรายงานการละเมิดโดยไม่ปิดกั้น วิธีนี้เผยสคริปต์ฝังในหน้าและแหล่งที่มาภายนอกที่ใช้งานถูกต้องและต้องเพิ่มในรายการอนุญาตก่อนบังคับใช้ การใช้โหมดรายงานอย่างเดียวหนึ่งสัปดาห์ก่อนบังคับใช้เป็นแนวทางที่ใช้กันทั่วไป หากข้ามขั้นนี้มักทำให้ส่วนที่ไม่ปรากฏในการทดสอบเสีย
ยังพบว่า load balancer ตั้งส่วนหัว Strict-Transport-Security แต่แอปไม่ได้ตั้ง ยอมรับได้หากเป็นจุดรับข้อมูลเพียงจุดเดียว แต่ต้องยืนยันให้ชัด การป้องกันหลายชั้นหมายถึงแอปควรตั้งส่วนหัวความปลอดภัยเองด้วยแม้โครงสร้างพื้นฐานจะจัดให้
ขั้นตอนที่สี่: ขีดจำกัดอัตราการเรียกใช้
ปลายทางยืนยันตัวตนสำหรับเข้าสู่ระบบ สมัคร และรีเซ็ตรหัสผ่านไม่มีขีดจำกัดอัตราการเรียกใช้ จึงรับคำขอไม่จำกัด เอื้อให้เดารหัสผ่านแบบไล่ลองและค้นหาอีเมลที่มีบัญชีจากพฤติกรรมตอบกลับที่ต่างกัน
การจำกัดอัตราบนเส้นทาง API ของ Next.js ทำได้ด้วยตัวนับที่ใช้ Redis ตัวอย่างด้านล่างใช้ช่วงเวลาคงที่ ซึ่งเรียบง่ายกว่าช่วงเวลาเลื่อน เนื่องจากเรียก INCR และ EXPIRE แยกกัน หากเกิดข้อผิดพลาดระหว่างสองคำสั่ง อาจเหลือคีย์ที่ไม่มีวันหมดอายุ โค้ดในระบบใช้งานจริงจึงควรทำทั้งสองคำสั่งแบบอะตอมมิก (เช่น ในธุรกรรมหรือสคริปต์ Lua) โดยมักใช้ที่อยู่ IP เป็นคีย์ หรือใช้รหัสผู้ใช้เมื่อยืนยันตัวตนแล้ว ปลายทางเข้าสู่ระบบเหมาะกับการจำกัดตาม IP เพราะยังไม่ทราบตัวตนผู้ใช้:
// lib/rateLimit.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({ url: process.env.UPSTASH_URL!, token: process.env.UPSTASH_TOKEN! });
export async function checkRateLimit(identifier: string, limit: number, windowSeconds: number) {
const key = `rate_limit:${identifier}`;
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, windowSeconds);
}
return { allowed: count <= limit, remaining: Math.max(0, limit - count) };
}
กำหนดขีดจำกัดอย่างระมัดระวัง: จำนวนครั้งเข้าสู่ระบบต่อ IP ในช่วงสิบห้านาที และคำขอรีเซ็ตรหัสผ่านต่ออีเมลต่อชั่วโมง ขีดจำกัดผ่อนปรนพอที่ผู้ใช้ที่ใช้งานอย่างถูกต้องตามปกติจะติดข้อจำกัดน้อยครั้ง และเข้มพอที่จะทำให้การโจมตีแบบเดารหัสผ่านทุกความเป็นไปได้โดยอัตโนมัติไม่คุ้มที่จะทำในทางปฏิบัติ
ขั้นตอนที่ห้า: การป้องกันการโจมตีผ่านเวลาตอบสนอง
การโจมตีผ่านเวลาตอบสนองทำให้อนุมานได้ว่าอีเมลลงทะเบียนหรือไม่ โดยเทียบเวลา “ไม่พบอีเมล” กับ “พบอีเมล แต่รหัสผ่านผิด” การค้นพบผู้ใช้ใช้เวลานานกว่าเล็กน้อย เพราะเปรียบเทียบรหัสผ่านด้วย bcrypt หรือ Argon2 เฉพาะเมื่อพบผู้ใช้ ความต่างโดยทั่วไป 50–200ms วัดได้เมื่อส่งคำขอมากพอ
วิธีลดความเสี่ยงมาตรฐานคือเปรียบเทียบแฮชรหัสผ่านทุกครั้ง ใช้แฮชจำลองเมื่อไม่มีผู้ใช้:
const DUMMY_HASH = '$2b$12$dummyhashvaluethatisvalidbutnevermatchesanypassword';
export async function verifyLogin(email: string, password: string) {
const user = await db.user.findUnique({ where: { email } });
// รัน bcrypt comparison เสมอเพื่อทำให้เวลาตอบสนองสม่ำเสมอ
const hashToCompare = user?.passwordHash ?? DUMMY_HASH;
const isValid = await bcrypt.compare(password, hashToCompare);
if (!user || !isValid) {
return null; // Code path เดียวกันสำหรับทั้งสองกรณีความล้มเหลว
}
return user;
}
DUMMY_HASH ด้านบนเป็นเพียงค่าตัวอย่าง ไม่ใช่แฮช bcrypt ที่ถูกต้องซึ่งมีความยาว 60 อักขระ ไลบรารีอย่าง bcryptjs จะคืนค่า false ทันทีเมื่อแฮชมีรูปแบบไม่ถูกต้อง ทำให้การปรับเวลาตอบสนองให้เท่ากันไม่ได้ผล ในการใช้งานจริงให้สร้างแฮชที่ถูกต้องหนึ่งครั้งเมื่อเริ่มระบบ (เช่น ใช้ bcrypt.hash() กับสตริงสุ่ม) โดยใช้ค่า cost factor เดียวกับแฮชที่จัดเก็บไว้ ปัญหานี้ไม่ได้เกี่ยวกับปลายทางเข้าสู่ระบบในตัวของ Supabase แต่เกิดขึ้นในเส้นทางยืนยันตัวตนที่แอปเพิ่มเองเพื่อเชื่อมระบบเก่า ตรรกะที่ไม่ใช้ฟังก์ชันยืนยันตัวตน Supabase ต้องทำให้เวลาตอบสนองสม่ำเสมอเอง
สิ่งที่เปลี่ยนแปลงและสิ่งที่ไม่เปลี่ยน
แก้ข้อค้นพบทั้งห้าขั้นครบก่อนถือว่าการตรวจเสร็จ การตั้งค่า RLS ผิดกับการเปลี่ยนเส้นทางไปปลายทางใดก็ได้เป็นเรื่องวิกฤตและแก้ทันที CSP กับขีดจำกัดอัตรามีความสำคัญสูงและขึ้นใช้งานในสปรินต์เดียวกัน การปรับเวลาตอบสนองเส้นทางระบบเก่ามีความสำคัญปานกลางและส่งในสปรินต์ถัดไป
ปรับกระบวนการด้วย: ตารางใหม่ต้องผ่านการทบทวนนโยบาย RLS ก่อนรวม pull request เส้นทาง API ใช้ตัวครอบตรวจ Zod ร่วมที่อยู่ในเทมเพลตเส้นทาง และส่วนหัว CSP สร้างจากการตั้งค่ากลางแทนข้อความที่ฝังแยกกัน
ผลสำคัญที่สุดคือทะเบียนข้อมูล ไม่ใช่การแก้ข้อใดข้อหนึ่ง การรู้ว่าแต่ละตารางมีข้อมูลอะไร นโยบายใดควบคุม และเส้นทาง API ใดใช้งาน เป็นฐานตัดสินใจด้านความปลอดภัยเมื่อแอปเปลี่ยน หากไม่มีทะเบียนก็แก้ตามปัญหาที่พบ หากมี ก็พิจารณาความปลอดภัยอย่างเป็นระบบได้
บทความที่เกี่ยวข้อง
- การกู้คืนจากภัยพิบัติสำหรับบริการที่ติดตั้งและดูแลเอง – ชั้นการสำรองข้อมูลและการกู้คืนที่จับคู่กับการเสริมความแข็งแกร่งด้านความปลอดภัย สิ่งที่เกิดขึ้นเมื่อการควบคุมล้มเหลวแม้จะพยายามอย่างเต็มที่
- การสร้างแอปเตรียมสอบที่มีคำถามหลายพันข้อ – นโยบาย RLS ของ Supabase ในแอปพลิเคชันที่ใช้งานจริง รวมถึงรูปแบบการเข้าถึงที่รองรับผู้เช่าหลายราย