กลับไปที่บทความ
Security Node.js Next.js Web Development OWASP

OWASP Top 10 ใน Node.js & Next.js App จริง — ก่อน/หลังแก้

พลากร วรมงคล
11 มีนาคม 2569 15 นาที

“OWASP Top 10 ฉบับปี 2021 พร้อม Snippets Node.js และ Next.js ที่มีช่องโหว่จริง และวิธีแก้ที่ผมใช้ Ship จริงใน Production ตัวอย่างที่จับต้องได้ดีกว่าโปสเตอร์รณรงค์”

OWASP Top 10 คือ Security Checklist ที่ถูกอ้างอิงมากที่สุดในวงการ Web Development และก็เป็นรายการที่ถูกนำไปใช้จริงน้อยที่สุดด้วย Paragraph สร้างความตระหนักไม่ได้บอกคุณว่าจริง ๆ แล้ว Bug หน้าตาเป็นอย่างไรใน Route Handler ที่คุณเขียนเมื่อวันอังคารที่แล้ว Post นี้จะไล่ทุก Category พร้อม Snippet หน้าตาเหมือนของจริงใน Node.js / Next.js และ Fix ที่ผมใช้จริง

TL;DR

  • Authorization ต้องอยู่ติดกับ Database Call — Scope ทุก Query ด้วย id ของผู้เรียก ไม่ใช่แค่เช็คว่ามี Authenticated Session
  • เก็บ Session ใน Cookie แบบ HttpOnly; Secure; SameSite และ Hash Password ด้วย Argon2id (หรือ scrypt / bcrypt cost 12+) ห้ามใช้ SHA เด็ดขาด
  • Parameterise ทุก Query — SQL, NoSQL และ Shell — และ Validate Request Body ผ่าน Schema (Zod / Valibot) ก่อนถึง Driver
  • ออกแบบเผื่อการถูกใช้ในทางที่ผิด: Token แบบใช้ครั้งเดียวที่ออกจาก CSPRNG, Rate Limit ที่ Key ทั้ง IP และ Username, MFA สำหรับทุกอย่างที่มีค่า
  • ล็อก Supply Chain: npm audit ใน CI, Renovate สำหรับ Upgrade, SBOM + Signed Artefacts, Merge ที่ปลอดภัยจาก prototype pollution
  • Log Auth Events, 403s และ Admin Actions พร้อม Redaction และ Request-id ห้าม Log Body ดิบหรือ Token เด็ดขาด
  • สำหรับ SSRF ให้รวม Allow-list ระดับ Scheme + Host เข้ากับการตรวจ DNS Resolution และ redirect: "manual"

ทำไมต้องมี Post เกี่ยวกับ OWASP อีก

OWASP Top 10 เป็นหนึ่งในรายการที่ทุกคนเคยอ่านแต่ไม่มีใครซึมซับเข้าไปจริง ๆ Write-up ส่วนใหญ่หยุดอยู่ที่ชื่อ Category พร้อม Paragraph สั้น ๆ ว่า “อย่าเชื่อ Input ของผู้ใช้ Validate ข้อมูล หมุนเวียน Secrets” นั่นคือ Wallpaper มันไม่ได้บอกคุณว่า Bug จริง ๆ แล้วหน้าตาเป็นอย่างไรใน Next.js Route Handler ที่คุณเขียนเมื่อวันอังคารที่แล้ว

Post นี้คือความพยายามทำในทางตรงข้าม สำหรับแต่ละ Category ในรายการปี 2021 ผมจะแสดง สถานการณ์อันตราย — Snippet ของ Node.js หรือ Next.js หน้าตาเหมือนของจริงที่ผมหรือคนใกล้ตัวเคย Ship อย่างน้อยหนึ่งครั้ง — และ วิธีแก้ ที่ผมผลักเข้า Production จริง โฟกัสคือ Web Applications (Next.js App Router, Prisma, NextAuth, MongoDB, Redis) ไม่ใช่ APIs แบบนามธรรม

Categories ของปี 2021 เรียงตามลำดับ:

  • A01 — Broken Access Control
  • A02 — Cryptographic Failures
  • A03 — Injection
  • A04 — Insecure Design
  • A05 — Security Misconfiguration
  • A06 — Vulnerable and Outdated Components
  • A07 — Identification and Authentication Failures
  • A08 — Software and Data Integrity Failures
  • A09 — Security Logging and Monitoring Failures
  • A10 — Server-Side Request Forgery

ทุก Code Sample ด้านล่างเป็น TypeScript บน Node.js เว้นแต่มีการระบุไว้ตรงข้าม ตัวอย่าง Python คู่ขนานหนึ่งตัวใน A03 จะแสดงให้เห็นว่ารูปร่างของ Bug ไม่ขึ้นกับภาษา

A01 — Broken Access Control

อยู่อันดับหนึ่งของรายการด้วยเหตุผลที่ดี มันคือ Bug ที่ Server Authenticate ผู้ใช้ถูกต้อง แล้วลืมเช็คว่าผู้ใช้คนนั้นมีสิทธิ์แตะ Resource ตัวนี้หรือไม่

รูปร่าง Classic คือ IDOR — Insecure Direct Object Reference Route อ่าน id จาก URL แล้วคืน Record โดยไม่เช็ค Ownership

สถานการณ์อันตราย

// app/api/invoices/[id]/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";

export async function GET(_: Request, { params }: { params: { id: string } }) {
  const session = await auth();
  if (!session) return new NextResponse("Unauthorized", { status: 401 });

  // Authenticated — but no authorization check.
  const invoice = await prisma.invoice.findUnique({ where: { id: params.id } });
  return NextResponse.json(invoice);
}

ผู้ใช้ที่ Login แล้วคนใดก็ได้สามารถ Enumerate /api/invoices/abc123 แล้วอ่าน Invoice ทุกใบในระบบได้ Session Check Gate ทางเข้าตึก แต่ไม่ได้ Gate ทางเข้าห้อง

วิธีแก้

Authorization อยู่ติดกับ Query และตัว Query เองก็ Scope ผลลัพธ์ให้กับผู้เรียก

// app/api/invoices/[id]/route.ts
export async function GET(_: Request, { params }: { params: { id: string } }) {
  const session = await auth();
  if (!session?.user?.id) return new NextResponse("Unauthorized", { status: 401 });

  const invoice = await prisma.invoice.findFirst({
    where: { id: params.id, ownerId: session.user.id },
  });
  if (!invoice) return new NextResponse("Not found", { status: 404 });
  return NextResponse.json(invoice);
}

มีสองการเปลี่ยนแปลงที่สำคัญ:

  1. where Clause Pin Record ให้กับผู้เรียก Request ไปดู Invoice ของคนอื่นจะคืน 404 ไม่ใช่ 403 — คุณไม่อยากยืนยันว่า id นั้นมีอยู่จริง
  2. Check นี้ไม่สามารถถูก Bypass ด้วยการเพิ่ม Query Param หรือปรับ Body ได้ เพราะมันเป็น Column บน Row

สำหรับ App ที่ไม่ Trivial เรื่องนี้จะถูก Centralise ผมเก็บ Helper ชื่อ authorize(session, action, resource) ไว้และเรียกจากทุก Handler ที่แตะข้อมูลที่มี Owner Route Middleware เป็น Second Line ที่พอใช้ได้ แต่การบังคับใช้จริงต้องอยู่ติดกับ Database Call — มันเป็นที่สุดท้ายที่ Bug จะซ่อนตัวได้

Server Actions ไม่ได้ฟรี

Next.js Server Actions ดูเหมือน RPC แต่รันบน HTTP Transport ที่ใช้ร่วมกัน Server Action ที่ลืมเช็ค Ownership ก็เป็น Bug แบบเดียวกับ REST Version ด้านบน Treat ทุก Action เหมือน Public Endpoint เพราะมันเป็น Public Endpoint

A02 — Cryptographic Failures

ไม่ได้ใช้ Crypto นั้นหายาก ใช้ผิดต่างหากที่เป็น Case ปกติ

มีสองรสชาติที่ Dominate ใน Web Apps: เก็บ Secret บน Client ที่ขโมยได้ และ Hash Password ด้วย Algorithm ที่ GPU แครกได้สบายในเวลามื้อกลางวัน

สถานการณ์อันตราย — Token ใน localStorage

// A common pattern from the SPA era.
const res = await fetch("/api/login", { method: "POST", body: JSON.stringify(creds) });
const { accessToken } = await res.json();
localStorage.setItem("token", accessToken);

// Later:
fetch("/api/me", {
  headers: { Authorization: `Bearer ${localStorage.getItem("token")}` },
});

localStorage อ่านได้โดย JavaScript ใด ๆ ที่รันบน Origin ของคุณ XSS ตัวเดียวใน Snippet Analytics ของบุคคลที่สาม, npm Package ที่ถูก Compromise ตัวเดียว, dangerouslySetInnerHTML ที่ใครสักคนทำพลาดตัวเดียว และ Token อายุยาวของผู้ใช้ทุกคนก็ออกจากตึกไปแล้ว

// app/api/login/route.ts
import { cookies } from "next/headers";
import { SignJWT } from "jose";

export async function POST(req: Request) {
  const creds = await req.json();
  const user = await verifyCredentials(creds);
  if (!user) return new NextResponse("Invalid", { status: 401 });

  const token = await new SignJWT({ sub: user.id })
    .setProtectedHeader({ alg: "HS256" })
    .setExpirationTime("15m")
    .sign(SECRET);

  cookies().set("session", token, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 15,
  });
  return NextResponse.json({ ok: true });
}

httpOnly หมายถึง JavaScript อ่าน Cookie ไม่ได้ secure หมายถึงมันจะไม่ออกจาก Browser ผ่าน HTTP แบบ Plaintext sameSite: "lax" Block CSRF ส่วนใหญ่ จับคู่กับ Token Lifetime สั้น ๆ และ Refresh-token Rotation Scheme (อยู่ใน HttpOnly Cookie เช่นกัน บน Path คนละตัว) แล้ว Attack Class แบบขโมย Token จะหดตัวลงอย่างมาก

สถานการณ์อันตราย — Password Hashing ที่อ่อน

import { createHash } from "node:crypto";
// Don't.
const hash = createHash("sha256").update(password).digest("hex");
await prisma.user.create({ data: { email, hash } });

SHA-256 เร็ว นั่นคือปัญหา บนฮาร์ดแวร์สมัยใหม่ Attacker ที่มีตาราง users รั่วสามารถลอง Password Candidate ได้หลายร้อยล้านตัวต่อวินาที Salt ก็ไม่ได้ช่วย Attacker ก็แค่ Salt ตามไปด้วย

วิธีแก้ — KDF แบบ Memory-hard

import { hash as argon2Hash, verify as argon2Verify } from "@node-rs/argon2";

// Registration
const hash = await argon2Hash(password, {
  memoryCost: 19456,  // 19 MiB
  timeCost: 2,
  parallelism: 1,
  algorithm: 2,       // Argon2id
});
await prisma.user.create({ data: { email, hash } });

// Login
const ok = await argon2Verify(stored.hash, submitted);

Argon2id (หรือ scrypt หรือ bcrypt ที่ cost 12+) เป็น Memory-hard Attacker ไม่สามารถเอา GPU มาถล่มได้แบบที่ทำกับ SHA Parameter ด้านบนเป็นจุดเริ่มต้น — ปรับ memoryCost และ timeCost บนเครื่อง Production ของคุณให้การ Verify ครั้งเดียวใช้เวลาประมาณ 100–250 ms แล้วก็อยู่กับเลขนั้น

A03 — Injection

ยังอยู่บนรายการมาสามทศวรรษหลังจากที่มันมาถึง เพราะ Pattern — “สร้าง String แล้วยื่นให้ Interpreter” — ปรากฏซ้ำใน Interpreter ใหม่ ๆ ตลอด

มีสามรูปร่างที่พบบ่อยใน Stack Node.js

SQL Injection ผ่าน Raw Prisma

Typed API ของ Prisma ปลอดภัย Escape Hatch ไม่ปลอดภัย

// Pain: user-controlled string in a raw query.
export async function searchUsers(q: string) {
  return prisma.$queryRawUnsafe(
    `SELECT id, email FROM "User" WHERE email LIKE '%${q}%'`
  );
}

คำว่า Unsafe ในชื่อ Method ไม่ใช่ของตกแต่ง Request ที่มี q = "' OR '1'='1" Dump ตารางได้

// Fix: parameterised tagged template.
export async function searchUsers(q: string) {
  return prisma.$queryRaw<
    { id: string; email: string }[]
  >`SELECT id, email FROM "User" WHERE email LIKE ${`%${q}%`}`;
}

$queryRaw กับ Tagged Template ส่ง Parameter แยกจาก SQL Text Driver จะจัดการ Escape เอง ถ้าคุณพบว่าตัวเองกำลังเอื้อมไป $queryRawUnsafe ให้หยุดและถามตัวเองว่า prisma.user.findMany({ where: { email: { contains: q } } }) ใช่สิ่งที่คุณต้องการมาตั้งแต่แรกหรือเปล่า

Bug แบบเดียวกันใน Python

// Node.js — psycopg-style pseudocode, same bug shape.
await client.query(`SELECT * FROM users WHERE email = '${email}'`);
// Fix:
await client.query("SELECT * FROM users WHERE email = $1", [email]);
# Python — the bug is identical at the string-building layer.
cur.execute(f"SELECT * FROM users WHERE email = '{email}'")  # vulnerable
# Fix: parameterise.
cur.execute("SELECT * FROM users WHERE email = %s", (email,))

Version Node และ Python คือ Bug เดียวกันสวมใส่ Syntax คนละแบบ ภาษาใดก็ตามที่ให้คุณ Concatenate String แล้วยื่นให้ SQL Driver จะมี Bug Class นี้ Driver ใดก็ตามที่รับ Parameterised Query จะมี Fix นี้

NoSQL Injection ผ่าน MongoDB Filter

MongoDB รับ Object เป็น Filter ถ้าคุณ Parse JSON Body แล้ว Spread เข้า Query Client สามารถ Inject Operator ได้

// Pain: user body spread into filter.
const body = await req.json();
const user = await db.collection("users").findOne(body);
// Client sends: { "email": "a@b.com", "password": { "$ne": null } }
// Result: login bypass.
// Fix: whitelist with a schema.
import { z } from "zod";
const Login = z.object({ email: z.string().email(), password: z.string().min(1) });
const { email, password } = Login.parse(await req.json());
const user = await db.collection("users").findOne({ email });
if (!user || !(await argon2Verify(user.hash, password))) return reject();

มีสองกฎที่ป้องกัน Bug Class นี้:

  1. ห้ามส่ง JSON ดิบจาก Request เข้า Mongo Filter เด็ดขาด Parse เป็น Typed Object ก่อน
  2. Disable Type ที่เป็นแบบ Object บน String Field ด้วย Schema Validator (Zod, Valibot, io-ts — เลือกสักตัว)

Command Injection ผ่าน child_process

// Pain: shelling out with user input.
import { exec } from "node:child_process";
exec(`convert ${userInput} out.png`, (err, stdout) => { /* ... */ });

exec Invoke Shell ชื่อไฟล์อย่าง ; rm -rf / # จะทำตามที่มันบอก Fix คือ execFile (หรือ spawn) กับ argv Array — ไม่มี Shell มาเกี่ยว

import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);

// Validate the argument first.
if (!/^[\w.-]+\.(png|jpg|webp)$/.test(userInput)) throw new Error("bad filename");

await run("convert", [userInput, "out.png"]);

ถ้า “ไฟล์” ของคุณมาจาก Upload ให้ Hash-and-rename บน Disk ก่อนที่จะส่งชื่อให้ Process อื่น Input ของ convert ก็จะกลายเป็นชื่อที่ Server สร้างขึ้น ที่ไม่มี Attacker คนใดจะมีอิทธิพลกับมันได้

A04 — Insecure Design

Injection คือ Bug ใน Code Insecure Design คือ Bug ในรูปบนกระดานไวท์บอร์ด

สองตัวอย่างที่เกิดซ้ำใน Web Apps

สถานการณ์อันตราย — Password Reset Token ที่เดาได้

// Pain: looks random, isn't.
import { randomBytes } from "node:crypto";
const token = Date.now().toString(36) + Math.random().toString(36).slice(2);
await prisma.passwordReset.create({ data: { userId, token, expiresAt } });

Math.random() ไม่ใช่ CSPRNG Timestamp รั่วว่า Token ถูกสร้างเมื่อไหร่ Attacker ที่รู้คร่าว ๆ ว่าผู้ใช้ขอ Reset เมื่อใดสามารถ Brute-force Token Space ได้ในไม่กี่นาที

วิธีแก้

import { randomBytes, createHash } from "node:crypto";

export async function issueResetToken(userId: string) {
  const raw = randomBytes(32).toString("base64url");          // 256 bits of entropy
  const hash = createHash("sha256").update(raw).digest("hex");
  await prisma.passwordReset.create({
    data: { userId, tokenHash: hash, expiresAt: new Date(Date.now() + 30 * 60_000) },
  });
  return raw; // emailed to the user; never logged, never stored
}

export async function consumeResetToken(raw: string) {
  const hash = createHash("sha256").update(raw).digest("hex");
  const row = await prisma.passwordReset.findFirst({
    where: { tokenHash: hash, usedAt: null, expiresAt: { gt: new Date() } },
  });
  if (!row) throw new Error("invalid or expired");
  await prisma.passwordReset.update({ where: { id: row.id }, data: { usedAt: new Date() } });
  return row.userId;
}

มีสาม Design Property ที่ควรสังเกต:

  1. Token เป็น 256 bits จาก CSPRNG
  2. Database เก็บ Hash ของ Token ไม่ใช่ตัว Token เอง Database รั่วก็ไม่ทำให้ Attacker ได้ชุด Reset Link ที่ใช้งานได้
  3. Token เป็นแบบใช้ครั้งเดียวและหมดอายุภายในเวลาสั้น

สถานการณ์อันตราย — ไม่มี Rate Limit บน Login

Login Endpoint ที่ไม่มี Rate Limit คือสนามเด็กเล่นของ Credential-stuffing Attacker ที่ซื้อ Password Dump ที่รั่วจากที่อื่นมาสามารถทดสอบคู่ (email, password) ได้หลายพันคู่ต่อวินาทีกับ Site ของคุณจนกว่าจะเจอที่ติด

วิธีแก้

// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

export const loginLimiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "15 m"),
  analytics: true,
  prefix: "rl:login",
});

// app/api/login/route.ts
export async function POST(req: Request) {
  const ip = req.headers.get("x-forwarded-for")?.split(",")[0] ?? "unknown";
  const { email } = await req.json();
  const { success } = await loginLimiter.limit(`${ip}:${email.toLowerCase()}`);
  if (!success) return new NextResponse("Too many attempts", { status: 429 });
  // ...verify credentials...
}

Rate-limit Key ด้วย ทั้ง IP และ Username ไม่ใช่อย่างใดอย่างหนึ่ง IP อย่างเดียวเปิดให้ Attacker กระโดดข้าม IP ต่อ User Username อย่างเดียวเปิดให้ Flood Username เยอะ ๆ จาก IP เดียว Key ที่รวมกันทำให้ทั้งสองรูปร่างมีต้นทุนสูง

ซ้อนกับ CAPTCHA หลังจาก N ครั้งที่ล้มเหลว และสำหรับบัญชีที่ Sensitive ให้ Email Alert เมื่อมี Login น่าสงสัย

A05 — Security Misconfiguration

Category ที่จับคุณได้ตอนคุณเหนื่อยและกำลัง Ship ตอนสามทุ่ม

CORS ที่กว้างเกินไป

// Pain: reflecting every origin.
export function middleware(req: NextRequest) {
  const res = NextResponse.next();
  res.headers.set("Access-Control-Allow-Origin", req.headers.get("origin") ?? "*");
  res.headers.set("Access-Control-Allow-Credentials", "true");
  return res;
}

Allow-Origin: * กับ Allow-Credentials: true จริง ๆ แล้วถูก Reject โดย Browser สมัยใหม่ — แต่ Pattern แบบ Reflect Origin แย่กว่า Site ใด ๆ บน Domain ใด ๆ สามารถส่ง Request แบบ Authenticated มาที่ API ของคุณพร้อม Cookie ของผู้ใช้ติดมาด้วย นั่นคือ CSRF Writeup ทุกอันแต่มีขั้นตอนเพิ่ม

// Fix: a small allow-list.
const ALLOWED = new Set(["https://palakorn.com", "https://admin.palakorn.com"]);
export function middleware(req: NextRequest) {
  const origin = req.headers.get("origin");
  const res = NextResponse.next();
  if (origin && ALLOWED.has(origin)) {
    res.headers.set("Access-Control-Allow-Origin", origin);
    res.headers.set("Vary", "Origin");
    res.headers.set("Access-Control-Allow-Credentials", "true");
  }
  return res;
}

Error Page ที่พูดเยอะเกินไป

ค่า Default ของ Next.js ใน Production นั้นโอเค — Stack Trace ไม่รั่ว Bug มักมาจาก Custom Error Handler ที่คืน err.message หรือ err.stack ให้ Client “เพื่อช่วย Debug” มันช่วย Attacker มากกว่า Log Error เต็มที่ฝั่ง Server คืน Message แบบ Redact และ Correlation id ให้ผู้ใช้

export function errorResponse(err: unknown) {
  const id = crypto.randomUUID();
  logger.error({ id, err }, "unhandled");
  return NextResponse.json({ error: "Internal error", id }, { status: 500 });
}

Security Headers

ใน next.config.js headers หรือ Middleware res.headers.set(...):

const securityHeaders = {
  "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
  "X-Content-Type-Options": "nosniff",
  "Referrer-Policy": "strict-origin-when-cross-origin",
  "Permissions-Policy": "camera=(), microphone=(), geolocation=()",
  "Content-Security-Policy":
    "default-src 'self'; img-src 'self' data: https:; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'",
};

CSP ที่เข้มงวดคือการป้องกันที่ใหญ่ที่สุดตัวเดียวที่ทำให้ Stored XSS ไม่กลายเป็นการขโมย Token

.env ที่ออกสู่สาธารณะ

ตัวที่ทำให้ผมนอนไม่หลับ: .env ที่ไป End up ใน Docker Image, Build Artefact หรือ Output ของ next export มีสองนิสัยที่ป้องกันได้:

  1. ห้ามอ่าน .env ตรง ๆ ใน Code — อ่าน process.env แล้ว Fail ดังลั่นถ้า Key ที่ต้องใช้หาย (ไม่มี Default ไม่มี Fallback)
  2. CI Step ที่ Grep Build Artefact หา Pattern Secret ที่รู้จัก (SK_LIVE_, AKIA, -----BEGIN) แล้ว Fail Build

A06 — Vulnerable and Outdated Components

Lockfile ของคุณคือ Code ก้อนที่ใหญ่ที่สุดใน Repo ของคุณ และคุณไม่ได้เขียนมันสักบรรทัด

มีสาม Practice ที่จ่ายคืนตัวเองในทุก Project ที่ผมรัน

npm audit ใน CI พร้อม Threshold ที่ Pin ไว้

# .github/workflows/audit.yml
- name: npm audit
  run: npm audit --audit-level=high

--audit-level=high หมายความว่า Build Fail ที่ Advisory ระดับ High หรือ Critical moderate เสียงดังเกินไปสำหรับทีมส่วนใหญ่ critical หละหลวมเกินไป Review Exception ทุกสัปดาห์

Renovate ไม่ใช่ Update มือ

Renovate Config ที่ Batch Minor และ Patch Update ทุกสัปดาห์และยก Major เป็น PR แยกหมายความว่า Dependency Upgrade เกิดขึ้นเป็นก้อนเล็ก ๆ ที่ Review ได้ แทนที่จะเป็น PR “Bump Everything” ทุกหกเดือนทีเดียว

{
  "extends": ["config:recommended"],
  "schedule": ["before 6am on Monday"],
  "packageRules": [
    { "matchUpdateTypes": ["minor", "patch"], "groupName": "all non-major" },
    { "matchUpdateTypes": ["major"], "labels": ["major-upgrade"] }
  ],
  "vulnerabilityAlerts": { "labels": ["security"], "schedule": ["at any time"] }
}

SBOM และ GHSA Monitoring

Generate SBOM แบบ CycloneDX หรือ SPDX ใน CI (npm sbom --sbom-format cyclonedx หรือ syft) แล้ว Upload ไปกับ Build Dependabot ของ GitHub เฝ้า GHSA อยู่แล้ว ถ้าคุณไม่ได้อยู่บน GitHub ให้ต่อ SBOM เข้า Grype หรือ OSV-Scanner ตามตารางเวลา

- run: npm sbom --sbom-format cyclonedx --sbom-type application > sbom.json
- uses: anchore/scan-action@v4
  with: { sbom: sbom.json, fail-build: true, severity-cutoff: high }

A07 — Identification and Authentication Failures

Login เป็นเรื่องที่ทำให้ถูก 80% ได้ง่ายอย่างน่าประหลาด และผิด 100% ได้ง่ายเช่นกัน

เคยเห็นใน App ที่ Live:

// Pain.
res.setHeader("Set-Cookie", `sid=${sessionId}`);

ไม่มี HttpOnly ไม่มี Secure ไม่มี SameSite ไม่มี Path ไม่มี Expiry XSS แบบฉวยโอกาสอ่านได้ HTTP Intercept แบบฉวยโอกาสขโมยได้ ใช้ cookies() API (หรือ Package cookie) แล้วเซ็ต Attribute ครบเซ็ตแบบที่แสดงใน A02

ไม่มี Account Lockout / ไม่มี MFA

Auth ที่ใช้ Password อย่างเดียวบนอะไรที่มีค่าคือความผิดพลาดของปี 2025 สำหรับ Web App ผมเอื้อมไปหา:

  • TOTP ผ่าน otplib เป็น First Factor นอกเหนือจาก Password พร้อม Backup Codes
  • WebAuthn / Passkeys ตรงที่ผู้ใช้เต็มใจ ผ่าน @simplewebauthn/server

TOTP Enrolment แบบ Minimal:

import { authenticator } from "otplib";

export async function enrollTotp(userId: string) {
  const secret = authenticator.generateSecret();
  await prisma.user.update({ where: { id: userId }, data: { totpSecret: encrypt(secret) } });
  return authenticator.keyuri(userEmail, "palakorn.com", secret);
}

export async function verifyTotp(userId: string, code: string) {
  const user = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
  return authenticator.verify({ token: code, secret: decrypt(user.totpSecret) });
}

Wrapper encrypt/decrypt ใช้ AEAD Key ฝั่ง Server ไม่ใช่ Password ของผู้ใช้ แบบนั้น Database รั่วโดยไม่มี App Key จะไม่ส่งมอบ TOTP Seed ทุกอันออกไป

การป้องกัน Credential Stuffing

A07 ทับซ้อนกับ A04 ตรงนี้ Rate-limit, CAPTCHA หลัง Threshold และ — ชัยชนะที่ถูกที่สุดในบรรดาทั้งหมด — เช็ค Password ที่ส่งมากับ Have I Been Pwned k-anonymity API ตอนสมัครและตอนเปลี่ยน Password ผู้ใช้เกลียด UX แบบนั้น แต่จะเกลียด “ข้อมูลของคุณรั่วไปแล้ว” มากกว่า

A08 — Software and Data Integrity Failures

Category Supply-chain ปี 2021 เพิ่มหลังเหตุการณ์ SolarWinds มัน Earn ที่นั่งของมันในทุก Post-mortem ที่ผมอ่านนับตั้งแต่นั้น

Build Artefact ที่ไม่ได้ Sign

ถ้า Deploy Pipeline ของคุณดึง Tarball จาก URL สาธารณะแล้วรัน URL นั้นคือ Trust Boundary ของคุณ Sign Artefact ด้วย Sigstore / cosign และ Verify บน Production Host ก่อน Unpack

# CI
- run: cosign sign-blob --yes dist.tar.gz --output-signature dist.tar.gz.sig

# Deploy host
- run: cosign verify-blob --signature dist.tar.gz.sig --certificate-identity-regexp '...' dist.tar.gz

หลักการเดียวกันใช้กับ Container Image (cosign verify ก่อน docker pull), Lambda Zip และ Binary ใด ๆ ที่ข้าม Network

Arbitrary JSON ที่ Parse จาก User Input — prototype pollution

// Pain: deep-merging untrusted input.
import merge from "lodash.merge";
const config = merge({}, defaults, req.body);

Body แบบ { "__proto__": { "isAdmin": true } } Pollute Object.prototype ตลอด Process ที่เหลือ ทุก Object ที่สร้างหลังจากนั้น Inherit isAdmin = true จนกว่าจะ Restart

// Fix 1: use a schema and only copy known fields.
const Patch = z.object({ theme: z.enum(["light", "dark"]), locale: z.string() });
const safe = Patch.parse(req.body);
const config = { ...defaults, ...safe };

// Fix 2: if you must deep-merge, use a library that guards __proto__.
//   `defu` and `deepmerge-ts` both reject dunder keys by default.

Node 22+ ก็รองรับ --disable-proto=delete ที่ลบ Object.prototype.__proto__ ออกทั้งหมด คุ้มที่จะเพิ่มลงใน Production Flag

JSON.parse ไม่ใช่ Vector เดียว

yaml.load (ค่า Default ที่ไม่ปลอดภัยของ js-yaml เวอร์ชันเก่า), node-serialize (ตัวที่ฉาวโฉ่) และ Library ใด ๆ ที่เรียก eval กับ Config Data ทั้งหมดอยู่ในถังเดียวกัน กฎเหมือนกับ A03: ห้ามยื่น User Data ให้ Parser ที่สร้าง Object ตามอำเภอใจได้

A09 — Security Logging and Monitoring Failures

Category ที่คุณสังเกตเห็นหลัง Incident ไม่ใช่ก่อน

เป้าหมายไม่ใช่ “Log ทุกอย่าง” แต่คือ Log ให้พอที่หกชั่วโมงเข้าไปใน Incident คุณจะตอบได้ว่า: ใคร Login จากที่ไหน แตะอะไรบ้าง และมีอะไรอื่นใน Stack ทำงานแปลก ๆ ในเวลาเดียวกันหรือไม่

Log อะไรบ้าง

อย่างน้อยที่สุด:

  • Authentication Events: Login Success, Login Failure, Password Change, MFA Enrolment, Password Reset Request + Use, Session Revocation
  • Authorization Denials: ทุก 403 403 ไม่ใช่ Traffic ปกติ ถ้าเห็นเยอะ มีคนกำลัง Probe
  • Administrative Actions: ทุก Write จาก Admin Account พร้อม Resource id และ Old/New Value
  • Server-side Error พร้อม Correlation id ผูกกับ User id เมื่อปลอดภัย

สถานการณ์อันตราย — Log Body ทั้งหมดของ Request

// Pain.
logger.info({ body: await req.json() }, "login attempt");

ตอนนี้ Log Aggregator ของคุณมี Plaintext Password และอะไรก็ตามที่ผู้ใช้ Submit

วิธีแก้ — Redaction ที่ Logger

Option redact ของ Pino เป็นมีดผ่าตัด:

import pino from "pino";

export const logger = pino({
  redact: {
    paths: [
      "password", "*.password",
      "token", "*.token",
      "authorization", "headers.authorization",
      "req.headers.cookie",
      "email",            // PII; hash or drop
    ],
    censor: "[REDACTED]",
  },
});

สำหรับ PII อย่าง Email Address ผม Log Hash ที่เสถียร (sha256(email + pepper)) แทน Plaintext แบบนั้นรักษา Correlation ข้าม Log Line ได้โดยไม่ต้องใส่ Identifier ลงใน SIEM

Trace Correlation

ทุก Log Line ได้ Request id ทุก Response ได้ id เดียวกันใน Header (X-Request-Id) เมื่อผู้ใช้รายงานปัญหาเขาให้ id จาก Error Page คุณหา Request เจอทันที Middleware:

export function middleware(req: NextRequest) {
  const id = req.headers.get("x-request-id") ?? crypto.randomUUID();
  const res = NextResponse.next({ request: { headers: new Headers(req.headers) } });
  res.headers.set("x-request-id", id);
  return res;
}

จับคู่กับ OpenTelemetry ถ้าคุณอยู่บน Distributed Stack — Trace id คือ Request id และ DB Span และ Outbound HTTP Call ทั้งหมดอยู่บน Timeline เดียว

A10 — Server-Side Request Forgery

ตัวที่เปลี่ยน Feature “ดึงรูปนี้ให้ฉันที” ให้กลายเป็นการรั่วไหลของ AWS Metadata Credential

สถานการณ์อันตราย

// app/api/proxy-image/route.ts
export async function GET(req: Request) {
  const url = new URL(req.url).searchParams.get("url")!;
  const res = await fetch(url);
  return new NextResponse(res.body, { headers: { "content-type": res.headers.get("content-type") ?? "" } });
}

Request ไป /api/proxy-image?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ บน AWS คืน IAM Credential ของ Node Request ไป http://localhost:6379/ กับ Redis ที่ไม่มี Auth ให้ Attacker พูด Redis Protocol ผ่าน HTTP Smuggling Request ไป http://internal-admin.svc.cluster.local/ เข้าถึง Service ที่ควรจะเป็น Private

วิธีแก้ — Allow-list ที่เข้มงวด

import { LookupAddress, lookup } from "node:dns";
import { promisify } from "node:util";
import net from "node:net";

const dnsLookup = promisify(lookup);

const ALLOWED_HOSTS = new Set(["images.unsplash.com", "cdn.palakorn.com"]);

function isPrivate(ip: string): boolean {
  if (net.isIPv4(ip)) {
    const [a, b] = ip.split(".").map(Number);
    return (
      a === 10 ||
      a === 127 ||
      (a === 169 && b === 254) ||
      (a === 172 && b >= 16 && b <= 31) ||
      (a === 192 && b === 168)
    );
  }
  // IPv6 loopback, link-local, unique-local, IPv4-mapped.
  return /^(::1|fe80:|fc|fd|::ffff:)/i.test(ip);
}

async function safeFetch(raw: string): Promise<Response> {
  const url = new URL(raw);
  if (url.protocol !== "https:") throw new Error("https only");
  if (!ALLOWED_HOSTS.has(url.hostname)) throw new Error("host not allowed");

  const { address } = await dnsLookup(url.hostname);
  if (isPrivate(address)) throw new Error("private address");

  const res = await fetch(url, { redirect: "manual" });
  if (res.status >= 300 && res.status < 400) throw new Error("no redirects");
  return res;
}

มีสามชั้นป้องกันซ้อนกัน:

  1. Scheme + Host Allow-list 90% ที่ง่าย
  2. DNS Resolution Check เพื่อจับ attacker.com ที่ Resolve เป็น 127.0.0.1 หากไม่มีอันนี้ Hostname Check ก็ Bypass ได้
  3. ไม่มี Redirect อัตโนมัติ ไม่งั้น 302 จาก Host ที่ Allow ไป 169.254.169.254 ก็เอาชนะขั้นที่ 2 ได้

ยังมี TOCTOU Window ระหว่าง DNS Lookup กับ Connect — Attacker ที่ควบคุม DNS Record ได้สามารถ Flip คำตอบได้ Fix แบบ Paranoid คือ Resolve ครั้งเดียว แล้ว Connect ไปที่ IP ที่ Resolve โดยตรงพร้อม Override Host: Header App ส่วนใหญ่ไม่ต้องการขนาดนั้น ถ้าคุณดึงจาก Public CDN ที่คุณไว้ใจก็ไม่ต้องทำ

สำหรับ Isolation ที่แข็งแรงกว่าให้รัน Fetcher ใน Process แยกที่ไม่มี Network Access ไป Internal Range (NetworkPolicy ใน Kubernetes, VPC Subnet ที่ล็อก หรือ bubblewrap บน Bare Host)

สรุปเช็คลิสต์ — เดินสายเข้า CI

Security Posture แข็งแรงได้แค่เท่ากับ Check ที่รันโดยที่คุณไม่ต้องจำ

  • Static Analysis ESLint กับ eslint-plugin-security และ eslint-plugin-n, tsc --noEmit และ semgrep กับ Ruleset javascript.express และ typescript.nextjs
  • Secret Scanning gitleaks ทุก Push และ Pre-commit Hook ใน Local
  • Dependency Audit npm audit --audit-level=high ใน CI, Renovate รายสัปดาห์, SBOM + Grype/OSV ตอน Build
  • Container / Image Scan trivy image บน Deploy Artefact, Fail ที่ High
  • DAST บน Staging ZAP Baseline Scan กับ Staging Deployment ที่กำลังรัน อย่างน้อยสัปดาห์ละครั้ง
  • Security Headers Test ที่ยิงไป / แล้ว Assert Header ครบเซ็ต (HSTS, CSP, XCTO, Referrer-Policy)
  • Rate-limit Test Integration Test ที่ถล่ม /api/login ด้วย Credential ผิดแล้ว Assert 429
  • Logging Test Assertion ว่าไม่มี Log Line จาก Route ที่รู้จักมี Literal Password ที่ Submit ใน Test
  • Auth Matrix Test สำหรับทุก Owned Resource มี Test ที่ User A พยายามอ่าน Record ของ User B และคาดว่าได้ 404

ส่วนใหญ่เป็น YAML แค่บรรทัดเดียว Return on Investment ของบรรทัดนั้นมหาศาล — ความแตกต่างระหว่าง “เรามี Process” กับ “เรามี Posture”

ปิดท้าย

ไม่มีอะไรในนี้เป็น Zero-day ทุก Category ด้านบนอยู่บน Top 10 มาทศวรรษหรือนานกว่า และทุก Fix เป็น Production-grade มาหลายปี เหตุผลที่รายการขึ้นอันดับตัวเองเรื่อย ๆ คือ Security เป็น Property ของทั้งระบบ ไม่ใช่ Commit ใด Commit หนึ่ง Fix แต่ละอันเล็กในตัวเอง เมื่อต่อเข้า CI, Code Review และ Muscle Memory แล้ว นั่นคือสิ่งที่แยก App ที่สบัด Breach Report ทิ้งออกจาก App ที่ขึ้นข่าว

อ่าน OWASP Cheat Sheet เมื่อมีเวลาว่างสักชั่วโมง — เยี่ยมมาก แต่อย่าหยุดที่การอ่าน เปิด Route Handler ตัวสุดท้ายที่คุณเขียน Grep หา findUnique แล้วเช็ค where นั่นคือที่งานอยู่

อ่านเพิ่มเติม

  • OWASP Top 10 (2021) — รายการต้นฉบับ พร้อมคำอธิบาย Category และ Mapping CWE
  • OWASP Cheat Sheet Series — แนวทางเชิงปฏิบัติที่ไม่ขึ้นกับภาษา สำหรับ Authentication, Authorization, Cryptography และอื่น ๆ
  • NIST SP 800-63B — Digital Identity Guidelines — เอกสารอ้างอิงสมัยใหม่สำหรับ Policy เรื่อง Password, MFA และ Session
  • The Tangled Web — Michal Zalewski (2011) ทัวร์ระดับหนังสือที่ชัดเจนที่สุดว่า Browser และ Web Protocol ทำงานอย่างไรเมื่อถูกโจมตี
  • Web Application Hacker’s Handbook, 2nd ed. — Stuttard & Pinto (2011) เก่าแต่ไม่มีใครเทียบ ในฐานะ Catalogue End-to-end ของเทคนิคโจมตี Web

Comments powered by Giscus are not yet configured. Set PUBLIC_GISCUS_REPO_ID and PUBLIC_GISCUS_CATEGORY_ID in apps/web/.env to enable.

PV

เขียนโดย พลากร วรมงคล

Software Engineer Specialist ประสบการณ์กว่า 20 ปี เขียนเกี่ยวกับ Architecture, Performance และการสร้างระบบ Production

เพิ่มเติมเกี่ยวกับผม

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