บทความ › ความปลอดภัย

Security headers ห้าบรรทัดที่ทุกเว็บควรมี และกับดัก nginx ที่ทำให้หายเงียบ ๆ

header ห้าตัวที่กันการโจมตีพื้นฐานของเว็บได้ แต่ละตัวทำอะไร ใส่ใน nginx ยังไง และกับดัก add_header ที่ทำให้ header หายไปเฉพาะไฟล์บางประเภท พร้อมวิธีตรวจจากของจริง

เริ่มต้น อ่าน 8 นาที 16 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • header ห้าตัวใช้เวลาใส่ไม่ถึงสิบนาที และปิดการโจมตีพื้นฐานได้หลายแบบ
  • ใน nginx ถ้า location ไหนมี add_header ของตัวเอง header จากชั้นนอกจะไม่ถูกส่งเลย ต้อง include ซ้ำ
  • ตรวจด้วย curl ทั้งหน้าเว็บและไฟล์ static เพราะมักอยู่คนละ location

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

ทดสอบกับ nginx 1.24 บน Ubuntu 24.04 เขียนจากมุมคนดูแลเว็บของตัวเอง

ห้าตัวที่ใช้จริง

เว็บนี้รวม header ไว้ในไฟล์เดียว /etc/nginx/snippets/sec-headers.conf

add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Strict-Transport-Security "max-age=31536000" always;
headerกันอะไร
X-Content-Type-Options: nosniffเบราว์เซอร์เดาชนิดไฟล์เอง เช่น เอาไฟล์ที่อัปโหลดเป็นรูป ไปรันเป็นสคริปต์
Referrer-PolicyURL เต็มของหน้าเรา (ที่อาจมีข้อมูลใน query) รั่วไปเว็บอื่นตอนผู้ใช้กดลิงก์ออก
X-Frame-Options: DENYเว็บอื่นฝังหน้าเราใน iframe ที่มองไม่เห็น หลอกให้ผู้ใช้คลิก (clickjacking)
Permissions-Policyสคริปต์ในหน้า (รวมถึงของบุคคลที่สาม) ขอใช้กล้อง ไมค์ หรือตำแหน่ง
Strict-Transport-Securityผู้ใช้ถูกดักให้ใช้ HTTP ธรรมดาในครั้งถัดไป

คำว่า always ท้ายบรรทัดสำคัญ ถ้าไม่ใส่ nginx จะส่ง header เฉพาะคำตอบที่สำเร็จ หน้า 404 หรือ 500 จะไม่มี header เลย

ขยายความตัวที่คนสงสัยบ่อย

Referrer-Policy ค่า strict-origin-when-cross-origin แปลว่า ลิงก์ภายในเว็บเดียวกันส่ง URL เต็ม แต่ลิงก์ออกไปเว็บอื่นส่งแค่ชื่อโดเมน และถ้าออกจาก HTTPS ไป HTTP ไม่ส่งเลย

HSTS max-age=31536000 คือหนึ่งปี เมื่อเบราว์เซอร์เคยเห็น header นี้ครั้งหนึ่ง ภายในหนึ่งปีจะเปลี่ยน http:// เป็น https:// เองก่อนส่งคำขอ ผู้ใช้ที่พิมพ์ชื่อเว็บโดยไม่มี https จึงไม่โดนดักกลางทาง เรายังไม่ใส่ preload เพราะถอนออกยาก ต้องมั่นใจก่อนว่าทุก subdomain รองรับ HTTPS ถาวร

X-Frame-Options เบราว์เซอร์รุ่นใหม่มีวิธีที่ละเอียดกว่าคือ Content-Security-Policy: frame-ancestors 'none' แต่ X-Frame-Options ยังรองรับกว้าง และใช้ง่าย ใส่คู่กันได้

กับดัก: add_header ไม่สืบทอดลงไป

nginx มีกฎข้อหนึ่งที่คนพลาดกันเยอะมาก

ถ้า location ไหนมีคำสั่ง add_header ของตัวเองแม้แต่บรรทัดเดียว add_header ทั้งหมดจากชั้น server จะ ไม่ถูกส่ง ใน location นั้น

ลองดูตัวอย่างที่ดูเหมือนถูก

server {
    include snippets/sec-headers.conf;   # ใส่ไว้ชั้นนอก คิดว่าครอบทุกอย่าง

    location / {
        try_files $uri $uri/ =404;       # ได้ header ครบ
    }
    location ~* \.(css|js|png|svg)$ {
        add_header Cache-Control "public"; # มี add_header ของตัวเอง
        # → header ความปลอดภัยทั้งห้าตัว หายหมดใน location นี้
    }
}

หน้าแรกได้ header ครบ ตรวจหน้าแรกก็ผ่าน แต่ไฟล์ CSS JS และรูปทั้งหมดไม่มี header ความปลอดภัยเลย ทางแก้คือ include ซ้ำในทุก location ที่มี add_header เอง

location ~* \.(css|js|png|svg)$ {
    include snippets/sec-headers.conf;
    add_header Cache-Control "public";
}

นี่คือเหตุผลที่เราแยก header ไว้ในไฟล์ snippet แทนที่จะเขียนในชั้น server ตรง ๆ จะได้ include ซ้ำได้ด้วยบรรทัดเดียว และแก้ที่เดียวมีผลทุกที่

server { include sec-headers }location /ไม่มี add_header ของตัวเอง✓ ได้ครบlocation ~* \.(css|js)$add_header Cache-Control✗ หายหมดlocation ~* \.(css|js)$include sec-headers + Cache-Control✓ ได้ครบ
location ที่มี add_header ของตัวเอง ไม่ได้รับ header จากชั้นนอกเลย ต้อง include ซ้ำ

กับดักที่สอง: location แบบ regex ชนะ

เว็บนี้ทำด้วย Astro ไฟล์ใน /_astro/ มีชื่อเปลี่ยนทุกครั้งที่เนื้อหาเปลี่ยน จึงให้ cache ได้หนึ่งปี แต่ถ้าเขียนแบบนี้

location /_astro/ { expires 1y; }
location ~* \.(css|js)$ { expires 30d; }

ไฟล์ /_astro/index.css จะได้ 30 วัน ไม่ใช่หนึ่งปี เพราะ nginx เลือก location แบบ regex ก่อน location แบบนำหน้าธรรมดา ต้องใส่ ^~ เพื่อบอกว่าถ้าตรงอันนี้ ไม่ต้องไปดู regex ต่อ

location ^~ /_astro/ {
    include snippets/sec-headers.conf;
    expires 1y;
    add_header Cache-Control "public, immutable";
}

ตรวจจากของจริง

อย่าตรวจแค่หน้าแรก ให้ตรวจไฟล์ static ด้วย เพราะมักอยู่คนละ location

curl -sI https://6amdev.com/ | grep -iE "strict|x-frame|x-content|referrer|permissions"
curl -sI https://6amdev.com/_astro/index.xB_X6hmL.css | grep -iE "x-frame|cache-control"

ตอนเขียนบทความนี้ เราตรวจไฟล์ใน /_astro/ แล้วเจอเรื่องเล็ก ๆ ที่ไม่เคยสังเกต

Cache-Control: max-age=31536000
Cache-Control: public, immutable

Cache-Control มาสองบรรทัด เพราะคำสั่ง expires สร้างบรรทัดหนึ่ง และ add_header เพิ่มอีกบรรทัด เบราว์เซอร์รวมสองบรรทัดเป็นค่าเดียวจึงยังทำงานถูก แต่ถ้าอยากให้เรียบร้อย เขียนบรรทัดเดียวแทนทั้งคู่

add_header Cache-Control "public, max-age=31536000, immutable";

นี่แหละประโยชน์ของการตรวจ header จริง ไม่ใช่อ่านจากไฟล์ตั้งค่า

ขั้นถัดไป: Content-Security-Policy

header ที่ทรงพลังที่สุดคือ CSP บอกเบราว์เซอร์ว่าสคริปต์ สไตล์ และรูปมาจากที่ไหนได้บ้าง ถ้ามีคนฝังสคริปต์เข้ามาได้ (XSS) CSP ที่ดีจะทำให้สคริปต์นั้นไม่ถูกรัน

แต่ CSP ก็ทำให้เว็บพังได้ง่ายที่สุด เพราะต้องรู้ว่าเว็บโหลดอะไรจากไหนบ้างทั้งหมด วิธีที่ปลอดภัยคือเริ่มแบบรายงานอย่างเดียว

add_header Content-Security-Policy-Report-Only "default-src 'self'; img-src 'self' data:; style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com" always;

เปิด console ของเบราว์เซอร์ดูว่ามีอะไรถูกรายงานบ้าง ปรับจนไม่มีรายงาน แล้วค่อยเปลี่ยนเป็น Content-Security-Policy ตัวจริง

ข้อผิดพลาดที่เจอบ่อย

  • ไม่ใส่ always หน้า error ไม่มี header
  • ใส่ header ชั้น server แล้วมี location ที่ add_header เอง header หายเฉพาะไฟล์ static
  • ตรวจแค่หน้าแรก
  • ใส่ HSTS preload ทั้งที่ยังมี subdomain ที่ใช้ HTTP อยู่
  • ทำ CSP ตัวจริงทันทีโดยไม่ผ่านโหมดรายงานก่อน แล้วฟอนต์หรือสคริปต์วิเคราะห์หายทั้งเว็บ
  • หลังแก้ config ไม่รัน sudo nginx -t ก่อน reload ถ้าเครื่องเดียวมีหลายเว็บ config พังตัวเดียวทำทุกเว็บล่ม

คำถามที่พบบ่อย

ใส่ header แล้วเว็บจะพังไหม

ห้าตัวในบทความนี้แทบไม่ทำให้เว็บทั่วไปพัง ยกเว้น X-Frame-Options DENY ถ้าเว็บเราต้องถูกฝังใน iframe ของเว็บอื่น ส่วน Content-Security-Policy มีโอกาสทำให้พังมากกว่า จึงควรเริ่มแบบรายงานอย่างเดียวก่อน

HSTS preload คืออะไร ควรใส่ไหม

preload คือการขอให้เบราว์เซอร์ฝังชื่อเว็บเราไว้ว่าต้องใช้ HTTPS เสมอ ตั้งแต่ครั้งแรก ถอนออกยากและใช้เวลานาน ควรใส่เมื่อมั่นใจว่าทุก subdomain ใช้ HTTPS ได้ถาวรแล้ว

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#Securityกลาง

จำกัดการยิง login และ API ด้วย nginx limit_req: burst, nodelay, 429, fail2ban

ตั้ง rate limit หน้า login และ API ด้วย nginx limit_req ให้ถูกความหมาย burst กับ nodelay คำนวณว่าสคริปต์ยิง 100 ครั้งต่อนาทีผ่านได้กี่ครั้ง และทำไมต้องจำกัดต่อบัญชีด้วย

อ่าน 12 นาที
#Securityกลาง

กัน CSRF โดยไม่ต้องใช้ token ด้วย Sec-Fetch-Site และ Origin (Go 1.25)

เบราว์เซอร์ยุคนี้บอกเซิร์ฟเวอร์เองว่าคำขอมาจากเว็บอื่นหรือเปล่า เช็กหัว Sec-Fetch-Site กับ Origin แทน CSRF token ได้ วิธีทำใน Go 1.25 และข้อแม้ที่ต้องรู้

อ่าน 10 นาที
#Securityกลาง

Post-quantum บน SSH และ HTTPS: ML-KEM คืออะไร และเช็กเซิร์ฟเวอร์ของเรายังไง

OpenSSH 10 ใช้ mlkem768x25519 เป็นค่าเริ่มต้นแล้ว 10.1 เตือนเมื่อไม่ได้ใช้ เบราว์เซอร์ก็ใช้ X25519MLKEM768 วิธีเช็กว่าเซิร์ฟเวอร์ตกลงใช้อะไรจริง และต้องอัปเกรดอะไร

อ่าน 8 นาที