Security headers ห้าบรรทัดที่ทุกเว็บควรมี และกับดัก nginx ที่ทำให้หายเงียบ ๆ
header ห้าตัวที่กันการโจมตีพื้นฐานของเว็บได้ แต่ละตัวทำอะไร ใส่ใน nginx ยังไง และกับดัก add_header ที่ทำให้ header หายไปเฉพาะไฟล์บางประเภท พร้อมวิธีตรวจจากของจริง
- 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-Policy | URL เต็มของหน้าเรา (ที่อาจมีข้อมูลใน 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 ซ้ำได้ด้วยบรรทัดเดียว และแก้ที่เดียวมีผลทุกที่
กับดักที่สอง: 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 ได้ถาวรแล้ว
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ