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

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

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

กลาง อ่าน 10 นาที 23 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • เบราว์เซอร์ที่ออกตั้งแต่ปี 2023 ส่งหัว Sec-Fetch-Site มากับทุกคำขอไปเว็บ HTTPS เซิร์ฟเวอร์ใช้ค่านี้ตัดสินได้เลยว่าคำขอข้ามมาจากเว็บอื่นหรือไม่
  • กติกาสั้น ๆ: GET/HEAD/OPTIONS ผ่าน, Sec-Fetch-Site เป็น same-origin หรือ none ผ่าน, ไม่มีหัวนี้ให้เทียบ Origin กับ Host, ไม่มีทั้งคู่ถือว่าไม่ใช่เบราว์เซอร์
  • Go 1.25 มี http.CrossOriginProtection ทำตามกติกานี้ให้ ภาษาอื่นเขียนเองได้ในสิบกว่าบรรทัด
  • วิธีนี้ใช้ได้ต่อเมื่อ GET ไม่เปลี่ยนข้อมูลเลย เว็บเป็น HTTPS และยังคง SameSite cookie ไว้เป็นชั้นเสริม

CSRF (Cross-Site Request Forgery) คือการที่เว็บอื่นหลอกให้เบราว์เซอร์ของผู้ใช้ส่งคำขอ POST มาที่เว็บเรา พร้อม cookie login ที่เบราว์เซอร์แนบให้เอง วิธีกันแบบเดิมคือฝัง token ลับในทุกฟอร์ม ซึ่งได้ผลแต่ต้องไล่ใส่ทุกฟอร์มทุก fetch ตั้งแต่ Go 1.25 (สิงหาคม 2025) ไลบรารีมาตรฐานของ Go มีตัวกันที่ไม่ใช้ token เลย อาศัยหัวที่เบราว์เซอร์ส่งมาเองว่าคำขอมาจากไหน และแนวคิดเดียวกันใช้ได้กับทุกภาษา

ทดสอบบน: ตรวจตรรกะกับซอร์ส src/net/http/csrf.go และเอกสาร net/http บน pkg.go.dev ณ ตุลาคม 2026 (โค้ด Go ในบทความยังไม่ได้รันบนเครื่องเรา เพราะเครื่องทดสอบมี Go 1.23) ส่วนโค้ด Python รันจริงบน Python 3.12

เบราว์เซอร์บอกเราแล้วว่าคำขอมาจากไหน

ทุกคำขอที่เบราว์เซอร์รุ่นใหม่ส่งไปเว็บ HTTPS จะมีหัว Sec-Fetch-Site ค่าเป็นหนึ่งในสี่แบบ

ค่าความหมายตัวอย่าง
same-originมาจาก origin เดียวกันเป๊ะ (scheme + host + port)ฟอร์มบน https://example.com POST ไป https://example.com/save
same-siteคนละ origin แต่โดเมนหลักเดียวกันhttps://blog.example.com ไป https://app.example.com
cross-siteมาจากเว็บอื่นเลยหน้าเว็บของคนอื่นแอบส่งฟอร์มมาหาเรา
noneผู้ใช้เป็นคนเริ่มเองพิมพ์ URL เอง กด bookmark

จุดสำคัญคือหัวที่ขึ้นต้นด้วย Sec- เป็น forbidden header JavaScript บนหน้าเว็บตั้งค่าเองไม่ได้ เบราว์เซอร์เป็นคนใส่ให้ หน้าเว็บของคนอื่นจึงปลอมว่าตัวเองเป็น same-origin ไม่ได้

Filippo Valsorda ผู้ออกแบบ middleware ตัวนี้ เขียนผลวิจัยไว้ สรุปว่าทุกเบราว์เซอร์หลักส่ง Sec-Fetch-Site ตั้งแต่ปี 2023 และส่งเฉพาะเมื่อปลายทางเป็น trustworthy origin คือ HTTPS หรือ localhost

GET / HEAD / OPTIONSผ่าน (ต้องไม่แก้ข้อมูล)Origin อยู่ในรายการที่ไว้ใจผ่านSec-Fetch-Site: same-origin หรือ noneผ่านไม่มีทั้งสองหัวผ่าน (ไม่ใช่เบราว์เซอร์)Origin ตรงกับ Hostผ่าน ที่เหลือได้ 403
ตรวจจากบนลงล่าง ข้อแรกที่ตรงคือคำตอบ ที่เหลือ 403

กติกาห้าข้อ

ตรรกะที่ Go ใช้ (และใช้ได้กับทุกภาษา) เรียงตามนี้

  1. GET, HEAD, OPTIONS ผ่านเสมอ ถือว่าเป็น method ที่ไม่เปลี่ยนข้อมูล
  2. Origin อยู่ในรายการที่เราไว้ใจ ผ่าน
  3. มี Sec-Fetch-Site ผ่านเฉพาะ same-origin หรือ none ค่าอื่นปฏิเสธ
  4. ไม่มีทั้ง Sec-Fetch-Site และ Origin ผ่าน ถือว่าไม่ใช่เบราว์เซอร์ (curl, แอปมือถือ, server-to-server)
  5. มีแต่ Origin (เบราว์เซอร์เก่า) ผ่านเมื่อ host:port ใน Origin ตรงกับหัว Host

ข้อ 4 อาจดูหลวม แต่ CSRF เกิดได้เพราะเบราว์เซอร์แนบ cookie ให้อัตโนมัติ โปรแกรมที่ไม่ใช่เบราว์เซอร์ไม่มี cookie ของเหยื่อให้ขโมยใช้

ใช้ใน Go 1.25

ห่อ handler ด้วย middleware บรรทัดเดียว คำขอที่ไม่ผ่านจะได้ 403 Forbidden

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /save", save)

	cop := http.NewCrossOriginProtection()
	// อนุญาต origin อื่นเป็นราย ๆ รูปแบบต้องเป็น scheme://host[:port]
	if err := cop.AddTrustedOrigin("https://admin.example.com"); err != nil {
		log.Fatal(err)
	}

	log.Fatal(http.ListenAndServe(":8080", cop.Handler(mux)))
}

func save(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("saved"))
}

เมธอดอื่นที่ควรรู้ ตามเอกสาร net/http

  • SetDenyHandler(h) เปลี่ยนจาก 403 เป็น handler ของเราเอง เช่น ตอบหน้า error สวย ๆ หรือ log ไว้ดู
  • AddInsecureBypassPattern(pattern) ยกเว้นบาง path ใช้รูปแบบเดียวกับ ServeMux เหมาะกับ callback จาก SSO ที่ต้องรับ POST ข้ามเว็บ ใช้ให้แคบที่สุด
  • Check(r) คืน error ถ้าคำขอควรถูกปฏิเสธ ใช้เมื่ออยากเช็กเองโดยไม่ห่อ handler

ภาษาอื่น: เขียนเองสิบกว่าบรรทัด

ไม่ได้ใช้ Go ก็ทำตามกติกาเดียวกันได้ ตัวอย่าง Python ล้วน ไม่พึ่ง framework เอาไปเรียกใน middleware ของ Flask, Django หรือ FastAPI ได้

from urllib.parse import urlsplit

SAFE = {"GET", "HEAD", "OPTIONS"}
TRUSTED = {"https://admin.example.com"}

def is_cross_origin_ok(method, headers, host):
    if method in SAFE:
        return True
    origin = headers.get("Origin", "")
    if origin in TRUSTED:
        return True
    site = headers.get("Sec-Fetch-Site", "")
    if site:
        return site in ("same-origin", "none")
    if not origin:
        return True  # ไม่มีทั้งสองหัว = ไม่ใช่เบราว์เซอร์
    return urlsplit(origin).netloc == host

ผลที่ได้เมื่อรันกับคำขอตัวอย่าง (host = example.com)

คำขอผล
POST, Sec-Fetch-Site: same-originผ่าน
POST, cross-site, Origin https://evil.example403
POST, same-site, Origin https://blog.example.com403
POST, ไม่มี Sec-Fetch-Site, Origin https://example.comผ่าน
POST, ไม่มี Sec-Fetch-Site, Origin https://evil.example403
POST, ไม่มีหัวทั้งคู่ผ่าน (ไม่ใช่เบราว์เซอร์)
GET, cross-siteผ่าน (method ปลอดภัย)

Origin: null ก็ตกไปข้อสุดท้ายและถูกปฏิเสธ ซึ่งถูกต้อง OWASP เตือนว่าห้ามใส่ค่า null ในรายการที่ไว้ใจ

ตัวอย่างคำนวณ: ผู้ใช้กี่คนที่ไม่ได้รับการป้องกันจาก Sec-Fetch-Site

ตอน Simon Willison เขียนถึงเรื่องนี้ (ตุลาคม 2025) Can I Use รายงานว่าเบราว์เซอร์ที่รองรับ Sec-Fetch-Site มีราว 94% ของทั่วโลก ลองคิดกับเว็บที่มีผู้ใช้ 10,000 คน

  1. ส่วนที่ไม่รองรับ = 100% − 94% = 6%
  2. 10,000 × 0.06 = 600 คน ที่เบราว์เซอร์ไม่ส่งหัวนี้
  3. ในกลุ่มนี้ส่วนใหญ่ยังส่ง Origin กับ POST อยู่ จึงตกไปกติกาข้อ 5 และยังได้รับการป้องกัน
  4. กลุ่มที่หลุดจริงคือเบราว์เซอร์เก่ามากที่ไม่ส่งทั้งสองหัว เช่น Firefox 60–69 ที่ Alex Edwards ยกตัวอย่างว่าไม่ส่ง Origin กับ POST

ตัวเลขนี้คือเหตุผลที่ต้องมีชั้นเสริม ไม่ใช่พึ่งหัวเดียว

Sec-Fetch-Site เป็นชั้นหลัก Origin เป็นตัวสำรอง SameSite cookie เป็นตาข่ายรองอีกชั้น ถอดชั้นไหนออกก็มีคนกลุ่มหนึ่งหลุด

ข้อแม้ที่ต้องรู้ก่อนใช้

GET ต้องไม่เปลี่ยนข้อมูล กติกาปล่อย GET ผ่านทุกครั้ง ถ้ามี GET /logout หรือ GET /delete?id=5 อยู่ วิธีนี้ไม่ช่วยอะไร ไล่ดู route ทั้งหมดก่อน อะไรที่เขียนฐานข้อมูลต้องเป็น POST/PUT/PATCH/DELETE

ต้องเป็น HTTPS เบราว์เซอร์ส่ง Sec-Fetch-Site ให้เฉพาะปลายทาง HTTPS หรือ localhost เว็บ HTTP ล้วนจะตกไปพึ่ง Origin อย่างเดียว

Origin กับ Host ไม่มี scheme การเทียบในข้อ 5 ดูแค่ host:port จึงแยกคำขอจาก http://example.com ไป https://example.com ไม่ออก ทั้ง Filippo และ Alex Edwards แนะนำให้เปิด HSTS ซึ่งเราตั้งไว้แล้วใน security headers บน nginx

ยังตั้ง SameSite ไว้ cookie session ควรเป็น SameSite=Lax หรือ Strict พร้อม Secure และ HttpOnly OWASP ถือ SameSite เป็น defense in depth ไม่ใช่ตัวกันหลัก เพราะ Lax ยังแนบ cookie ไปกับ GET แบบ top-level navigation

Reverse proxy ที่เปลี่ยน Host ถ้า nginx ส่งต่อด้วย Host คนละค่ากับที่ผู้ใช้เห็น ข้อ 5 จะตีกลับคำขอที่ถูกต้อง ตั้ง proxy_set_header Host $host; หรือเพิ่ม origin นั้นใน trusted list

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

  • มี GET ที่เปลี่ยนข้อมูล เช่น ลิงก์ยืนยันอีเมลที่ลบของ หรือปุ่ม logout แบบลิงก์ ปิดช่องนี้ก่อนเรื่องอื่น
  • บล็อกทุกคำขอที่ไม่มี Origin แล้ว webhook, curl, แอปมือถือพังหมด ถ้าอยากเข้มกว่านี้ ให้แยก API ที่ใช้ token ใน header ออกจาก route ที่ใช้ cookie
  • ใส่ AddInsecureBypassPattern("/") เพื่อแก้ปัญหาเฉพาะหน้า เท่ากับปิดการป้องกันทั้งเว็บ ยกเว้นเฉพาะ path ที่ต้องรับ POST ข้ามเว็บจริง
  • คิดว่า same-site ปลอดภัย subdomain ที่ให้คนอื่นอัปโหลด HTML ได้ (เช่น หน้า preview) ส่งคำขอแบบ same-site มาได้ กติกานี้บล็อกให้ อย่าไปเปิดเพิ่มเอง
  • ทดสอบบน HTTP ด้วย IP เครื่อง แล้วงงว่าทำไมไม่มี Sec-Fetch-Site ใช้ localhost หรือ HTTPS ตอนทดสอบ

ถ้าเว็บใช้ passkey สำหรับ login อยู่แล้ว ดู passkeys ประกอบ ส่วน CSRF ยังต้องกันอยู่ดีเพราะหลัง login ทุกอย่างวิ่งบน cookie เหมือนเดิม

แหล่งอ้างอิง

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

เลิกใช้ CSRF token ได้เลยไหม

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

ทำไม subdomain ของเราเองถึงโดนบล็อก

blog.example.com กับ app.example.com เป็น same-site แต่ไม่ใช่ same-origin กติกานี้ยอมแค่ same-origin เพราะ subdomain คนละตัวอาจมีระดับความไว้ใจต่างกัน ถ้าต้องการให้ข้ามได้จริง ใส่ origin นั้นใน AddTrustedOrigin ทีละตัว

API ที่แอปมือถือหรือ curl เรียกจะโดนบล็อกไหม

ไม่ โปรแกรมที่ไม่ใช่เบราว์เซอร์ส่วนใหญ่ไม่ส่งทั้ง Sec-Fetch-Site และ Origin กติกานี้ปล่อยผ่าน เพราะ CSRF เป็นปัญหาของเบราว์เซอร์ที่แนบ cookie ให้อัตโนมัติ API แบบนั้นต้องยืนยันตัวด้วย token ใน header อยู่แล้ว

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

อ่านต่อ

#Securityกลาง

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

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

อ่าน 8 นาที
#Securityเริ่มต้น

ใบรับรอง SSL/TLS อายุสั้นลงเหลือ 47 วัน: ไทม์ไลน์ 2026-2029 และต้องทำอะไร

CA/B Forum ลดอายุใบรับรอง TLS จาก 398 วันเหลือ 47 วันภายในปี 2029 ส่วน Let's Encrypt มีใบ 6 วันและใบสำหรับ IP แล้ว สรุปไทม์ไลน์ และวิธีตั้งต่ออายุอัตโนมัติ

อ่าน 9 นาที
#Securityเริ่มต้น

เผลอ push API key ขึ้น Git ทำอะไรก่อน: เปลี่ยนกุญแจ ล้างประวัติ กันซ้ำ

API token หลุดขึ้น GitHub ลบ commit ไม่ช่วย ต้องเปลี่ยนกุญแจก่อน แล้วค่อยตรวจ log ล้างประวัติด้วย git filter-repo และตั้ง gitleaks กับ push protection กันครั้งหน้า

อ่าน 10 นาที