tva
← Insights

การตรวจสอบความปลอดภัยแบบหลายขั้นตอน: การเสริมความแข็งแกร่งให้แอปพลิเคชัน Next.js

การตรวจสอบความปลอดภัยบนเว็บแอปพลิเคชันมักพบปัญหาในหมวดหมู่เดิมๆ โดยไม่คำนึงถึง stack ที่ใช้ ได้แก่ ช่องโหว่ด้านการอนุญาต ความไม่สมบูรณ์ในการตรวจสอบ input ส่วนหัวความปลอดภัย HTTP ที่ไม่เพียงพอ และความเปราะบางด้านระยะเวลาในกระบวนการยืนยันตัวตน รูปแบบที่เป็นรูปธรรมอาจต่างกันตาม framework แต่รูปแบบพื้นฐานมีความสอดคล้องกัน สิ่งที่แตกต่างคือความเป็นระบบของการตรวจสอบ การทบทวนที่ไม่มีโครงสร้างมักจะพบปัญหาที่ชัดเจนแต่พลาดปัญหาที่ละเอียดอ่อนกว่า

เราดำเนินการตรวจสอบแบบหลายขั้นตอนบนแอปพลิเคชัน Next.js ในระดับ production โดยใช้ Supabase เป็น backend แอปพลิเคชันนี้จัดการการยืนยันตัวตนของผู้ใช้ จัดเก็บข้อมูลที่เชื่อมโยงกับผู้ใช้ และเปิดเผย API routes หลายเส้นทางที่ใช้โดยทั้งฝั่ง front end และ webhook ภายนอก ไม่มีสิ่งที่พบเจอใดที่ถึงขั้นวิกฤต แต่หลายรายการเป็นประเภทที่อาจกลายเป็นเหตุการณ์ร้ายแรงหากถูกค้นพบโดยผู้ที่มีเจตนาไม่ดี นี่คือสิ่งที่การตรวจสอบครอบคลุม สิ่งที่พบ และสิ่งที่เปลี่ยนแปลงเป็นผล

ขั้นตอนที่หนึ่ง: การยืนยันตัวตนและการอนุญาต

ขั้นตอนแรกมุ่งเน้นที่ชั้นการยืนยันตัวตนและนโยบาย Row Level Security ที่ควบคุมการเข้าถึงข้อมูล นี่คือการควบคุมที่สำคัญที่สุดในแอปพลิเคชัน Supabase ทุกตัว หาก authentication ใช้งานไม่ได้หรือ RLS ถูกตั้งค่าผิด ไม่มีการตรวจสอบ input หรือการจำกัดอัตราการเรียกใดๆ ที่จะชดเชยได้

การตรวจสอบเริ่มต้นด้วยการสำรวจตาราง Supabase ทั้งหมดและสถานะ RLS ของตาราง ทุกตารางควรเปิดใช้งาน RLS ตารางใดที่ไม่ได้เปิดใช้ RLS จะสามารถอ่านได้โดยทุกคนที่มี anon key ซึ่งเป็นสาธารณะโดยการออกแบบและฝังอยู่ใน bundle ฝั่ง client ในแอปพลิเคชันนี้ มีหนึ่งตารางที่ปิดใช้งาน RLS เป็นตาราง lookup ที่ถูกจัดการเป็นข้อมูลอ้างอิง และในช่วงเวลาที่สร้าง มีแต่ค่าสถิต แต่เมื่อเวลาผ่านไป มีแถวที่ถูกเพิ่มซึ่งมี metadata ที่เชื่อมโยงกับผู้ใช้ที่ควรได้รับการป้องกัน การแก้ไขตรงไปตรงมา แต่ความล้มเหลวในกระบวนการพื้นฐานนั้นน่าสังเกต: ตารางใหม่ควรเปิดใช้งาน RLS โดยค่าเริ่มต้น ไม่ใช่เป็นขั้นตอนย้อนหลัง

จากนั้นนโยบาย RLS บนตารางที่ได้รับการป้องกันถูกตรวจสอบเพื่อความถูกต้อง ข้อผิดพลาดทั่วไปคือนโยบายที่ตรวจสอบ user ID ได้อย่างถูกต้องแต่ทำกับคอลัมน์ที่ผิด ตัวอย่างเช่น นโยบายบนตาราง documents ที่ตรวจสอบ auth.uid() = created_by จำกัดการเข้าถึงเฉพาะผู้สร้างได้อย่างถูกต้อง แต่หากแอปพลิเคชันยังรองรับเอกสารที่แชร์ แถวผู้ร่วมงานในตาราง join document_collaborators จำเป็นต้องมีนโยบายของตัวเอง การตรวจสอบบนตารางหลักไม่ส่งผ่านไปยังข้อมูลที่ join

นโยบายหนึ่งมีข้อผิดพลาดทางตรรกะที่รอดพ้นการ code review: ใช้ USING ในที่ที่ควรใช้ทั้ง USING และ WITH CHECK คำสั่ง USING ควบคุมการเข้าถึงการอ่าน ส่วน WITH CHECK ควบคุมการเข้าถึงการเขียน นโยบายที่มีเฉพาะ USING อนุญาตให้ผู้ใช้ที่ยืนยันตัวตนแล้วทุกคนเขียนลงในตารางได้ตราบใดที่พวกเขาสามารถอ่านจากตารางได้ ซึ่งไม่ใช่พฤติกรรมที่ตั้งใจไว้ นี่คือความละเอียดอ่อนของ PostgreSQL RLS ที่มองข้ามได้ง่าย

ขั้นตอนที่สอง: การตรวจสอบ Input

ขั้นตอนที่สองตรวจสอบทุกจุดรับข้อมูลที่ผู้ใช้ป้อน: API route handlers ใน app/api/, server actions และการส่ง form เป้าหมายคือยืนยันว่า input ทั้งหมดได้รับการตรวจสอบก่อนใช้งาน และความล้มเหลวในการตรวจสอบสร้างข้อผิดพลาดที่ให้ข้อมูลแทนที่จะเป็น exception ที่ไม่ได้จัดการ

แอปพลิเคชันใช้ Zod สำหรับการตรวจสอบบางส่วน แต่ไม่สม่ำเสมอ บาง routes มี schema ครบถ้วนพร้อม z.parse() ที่ด้านบนของ handler ส่วนอื่นๆ อ่านฟิลด์ req.body โดยตรงโดยไม่ตรวจสอบ พึ่งพาการ assert ประเภทใน TypeScript ที่ไม่มีความปลอดภัยในขณะ runtime:

// ไม่ปลอดภัย: 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 schemas ให้กับ API route ทุกตัวที่ไม่มีการตรวจสอบ และสร้าง validation middleware ที่ใช้ร่วมกันซึ่ง routes ทั้งหมดผ่าน รูปแบบ middleware มีความสำคัญ: รับประกันว่าชั้นการตรวจสอบไม่สามารถข้ามได้โดย code path ที่ถูกเพิ่มโดยไม่ทราบถึงข้อกำหนดการตรวจสอบ

Route หนึ่งรับพารามิเตอร์ redirectUrl ที่ถูกใช้ใน res.redirect() นี่คือช่องโหว่ open redirect ผู้โจมตีสามารถสร้างลิงก์ไปยังแอปพลิเคชันที่เปลี่ยนเส้นทางผู้ใช้ไปยังไซต์ฟิชชิ่งหลังจากการโต้ตอบที่ดูถูกกฎหมาย การแก้ไขคือตรวจสอบเป้าหมาย redirect กับ allowlist ของพาธที่ยอมรับ:

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 การฉีด XSS ที่สำเร็จ ไม่ว่าจะผ่านเนื้อหาที่ผู้ใช้สร้างหรือสคริปต์บุคคลที่สามที่ถูกโจมตี สามารถรัน JavaScript ตามอำเภอใจในบริบทของแอปพลิเคชัน โดยมีสิทธิ์เข้าถึง token การยืนยันตัวตนและข้อมูลผู้ใช้

การเพิ่ม CSP ให้กับแอปพลิเคชัน Next.js ต้องระมัดระวัง เนื่องจาก Next.js ฝังสคริปต์บางส่วนโดยค่าเริ่มต้น แนวทางที่แนะนำใช้ nonces ซึ่งเป็นค่าสุ่มทางการเข้ารหัสที่สร้างต่อ request และถูกเพิ่มใน CSP header และนำไปใช้กับสคริปต์ inline แต่ละตัว:

// 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 ซึ่งรายงานการละเมิดโดยไม่บล็อก สิ่งนี้เปิดเผยสคริปต์ inline ที่ถูกต้องและ origin บุคคลที่สามหลายรายที่ต้องอยู่ใน allowlist ก่อนเปลี่ยนเป็นโหมดบังคับใช้ การรันในโหมด report-only เป็นเวลาหนึ่งสัปดาห์ก่อนบังคับใช้เป็นแนวทางมาตรฐาน การบังคับใช้โดยไม่มีขั้นตอนนี้มักทำให้ฟังก์ชันการทำงานที่ไม่เห็นชัดในการทดสอบหยุดทำงาน

การตรวจสอบยังพบว่าส่วนหัว Strict-Transport-Security ถูกตั้งโดย load balancer แต่ไม่ได้ตั้งโดยแอปพลิเคชัน ซึ่งยอมรับได้หาก load balancer เป็นจุดรับเดียว แต่ควรยืนยันให้ชัดเจน Defense in depth หมายความว่าแอปพลิเคชันควรตั้งส่วนหัวความปลอดภัยของตัวเองแม้เมื่อ infrastructure จัดการให้

ขั้นตอนที่สี่: การจำกัดอัตราการเรียก

endpoints การยืนยันตัวตน ได้แก่ sign in, sign up, password reset ไม่มีการจำกัดอัตราการเรียก หากไม่มี rate limiting endpoints เหล่านี้รับ request ได้ไม่จำกัด ซึ่งเปิดใช้งานการโจมตี brute-force บนรหัสผ่านและการระบุที่อยู่อีเมลที่ถูกต้องผ่านความแตกต่างในพฤติกรรมการตอบสนอง

Rate limiting บน Next.js API routes สามารถทำได้ด้วย sliding window counter ที่ใช้ Redis ค่าหลักมักเป็น IP address หรือสำหรับ endpoints ที่ยืนยันตัวตนแล้ว ใช้ user ID สำหรับ endpoints การยืนยันตัวตน การจำกัดตาม 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) };
}

ขีดจำกัดที่ใช้ค่อนข้างอนุรักษ์นิยม: ความพยายาม sign-in ถูกจำกัดต่อ IP ต่อช่วงเวลาสิบห้านาที คำขอ reset รหัสผ่านถูกจำกัดต่อที่อยู่อีเมลต่อชั่วโมง ขีดจำกัดเหล่านี้เพียงพอที่ผู้ใช้ทั่วไปจะไม่พบภายใต้การใช้งานปกติ และจำกัดเพียงพอที่การโจมตีอัตโนมัติไม่สามารถทำได้

ขั้นตอนที่ห้า: การป้องกันการโจมตีด้วยระยะเวลา

การโจมตีด้วยระยะเวลาบน endpoints การยืนยันตัวตนอนุญาตให้ผู้โจมตีอนุมานว่าที่อยู่อีเมลลงทะเบียนแล้วหรือไม่โดยวัดความแตกต่างในเวลาตอบสนองระหว่าง “ไม่พบอีเมล” และ “พบอีเมล รหัสผ่านไม่ถูกต้อง” การ lookup ที่พบผู้ใช้ใช้เวลาเล็กน้อยนานกว่าที่ไม่พบ เพราะการเปรียบเทียบรหัสผ่าน (ซึ่งใช้ bcrypt หรือ Argon2) รันเฉพาะเมื่อพบผู้ใช้ ความแตกต่างนี้ซึ่งมักอยู่ในช่วง 50–200ms วัดได้ด้วย request เพียงพอ

การบรรเทามาตรฐานคือรันการเปรียบเทียบ hash รหัสผ่านโดยไม่มีเงื่อนไข โดยใช้ dummy hash เมื่อผู้ใช้ไม่มีอยู่:

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

การยืนยันตัวตนในตัวของ Supabase จัดการสิ่งนี้อย่างถูกต้องสำหรับ sign-in endpoint ของตัวเอง ปัญหาปรากฏใน custom authentication route ที่แอปพลิเคชันเพิ่มเพื่อจัดการ integration รุ่นเก่า logic การยืนยันตัวตน custom ใดๆ ที่ไม่ใช้ฟังก์ชัน auth ของ Supabase จำเป็นต้องใช้งาน timing normalization อย่างชัดเจน

สิ่งที่เปลี่ยนแปลงและสิ่งที่ไม่เปลี่ยน

จากสิ่งที่พบในทุกห้าขั้นตอน ทุกรายการได้รับการจัดการก่อนที่การตรวจสอบจะถือว่าสมบูรณ์ การจัดลำดับความสำคัญชัดเจน: ข้อผิดพลาดการตั้งค่า RLS และ open redirect ถือเป็นวิกฤตและแก้ไขทันที การปรับใช้ CSP และ rate limiting ถือเป็นลำดับความสำคัญสูงและปรับใช้ภายใน sprint เดียวกัน การทำให้เวลาตอบสนองสม่ำเสมอสำหรับ legacy route ถือเป็นลำดับความสำคัญปานกลางและส่งออกใน sprint ถัดไป

การตรวจสอบยังสร้างการเปลี่ยนแปลงกระบวนการ: ตารางใหม่ต้องมีการตรวจสอบนโยบาย RLS ก่อนที่ pull request จะถูก merge, API routes มี wrapper การตรวจสอบ Zod ที่ใช้ร่วมกันซึ่งเป็นส่วนหนึ่งของ route template, และส่วนหัว CSP ถูกสร้างจาก configuration ที่เป็นศูนย์กลางแทนที่จะเป็น string inline

ผลลัพธ์ที่โดดเด่นที่สุดไม่ใช่การแก้ไขใดๆ เป็นรายบุคคล แต่เป็นการสำรวจ การเข้าใจว่าข้อมูลแต่ละตารางมีอะไร นโยบาย RLS ใดควบคุม และ API routes ใดที่สัมผัสกับตาราง เป็นรากฐานสำหรับการตัดสินใจด้านความปลอดภัยที่ถูกต้องเมื่อแอปพลิเคชันพัฒนา หากไม่มีการสำรวจนั้น ความปลอดภัยเป็นแบบตอบสนอง คุณแก้ไขสิ่งที่ค้นพบ ด้วยการสำรวจนั้น ความปลอดภัยกลายเป็นคุณสมบัติที่คุณสามารถตั้งใจใช้เหตุผลได้

ข้อมูลเชิงลึกที่เกี่ยวข้อง

บทความที่เกี่ยวข้อง