สองกลไกที่ไม่ทับกัน CORS ปิดช่องที่ CSRF ปล่อยผ่าน และ SameSite ที่ไม่มีค่าตั้งต้น
เจ็ดบทก่อนหน้าวัดสองกลไกนี้ทีละตัว บทนี้วางมันซ้อนกันแล้วถามข้อเดียว คือสองกลไกนี้ทับกัน หรือปิดคนละรู
คำตอบที่วัดได้มีสองครึ่ง ครึ่งแรกคือมันแยกกันจริง ครึ่งที่สองคือรูที่กลไกหนึ่งเปิดทิ้งไว้ ถูกปิดด้วยอีกกลไกหนึ่ง และไม่ใช่ทิศที่สัญชาตญาณบอก
ทรงที่บทนี้เทียบมีอยู่แล้วใน probe ทั้งหมด สิ่งที่บทนี้ทำคืออ่านสองทรงพร้อมกัน ทีละช่อง แล้วดูว่าช่องไหนขยับ
ตัวเลขทุกตัวรันเองได้ สั่ง scripts/clinic-cross-origin/up.sh
แล้วตามด้วย python3 scripts/clinic-cross-origin/probe.py
ทรงคู่ที่บทนี้อ่าน ต่างกันที่ bean ไม่ใช่ที่กฎการอนุญาต
หัวข้อที่มีชื่อว่า “ทรงคู่ที่บทนี้อ่าน ต่างกันที่ bean ไม่ใช่ที่กฎการอนุญาต”
csrf-on-corsคือทรงcsrf-onที่เติม bean ของ CORS เข้ามา และเติมหนึ่งบรรทัดในSecConfig.kt
CSRFCross-Site Request Forgery (CSRF)การที่หน้าเว็บของผู้อื่นสั่งเบราว์เซอร์ของผู้ใช้ให้ยิงคำขอไปยังแอปที่ผู้ใช้ยัง login ค้างอยู่ · กลไกกันของ Spring Security คือค่าที่ผู้เรียกต้องแนบมาเอง ซึ่งหน้าเว็บของผู้อื่นอ่านไม่ได้ คือกลไกที่ถามว่าคำขอใบนี้มาจากหน้าเว็บของแอปเองหรือไม่ ส่วน CORSCross-Origin Resource Sharing (CORS)ชุดกฎที่บอกเบราว์เซอร์ว่าหน้าเว็บจากต้นทางหนึ่ง อ่านคำตอบจากอีกต้นทางได้หรือไม่ · บังคับที่เบราว์เซอร์เป็นหลัก จึงกันการอ่าน ไม่ได้กันการเขียนโดยตัวมันเอง คือชุดกฎที่บอกเบราว์เซอร์ว่าหน้าเว็บจากต้นทางหนึ่ง อ่านคำตอบจากอีกต้นทางได้ไหม
สองทรงที่บทนี้เทียบคือ csrf-on กับ csrf-on-cors ความต่างทั้งหมดของมันมีสองอย่าง
csrf-on-corsมีCorsConfig.ktเพิ่มเข้ามาอีกหนึ่งตัว ซึ่งอนุญาตต้นทางhttps://allowed.exampleตัวเดียวSecConfig.ktของมันมีบรรทัดhttp.cors { }เพิ่มมาอีกหนึ่งบรรทัด
กฎใน authorizeHttpRequests เหมือนกันทั้งสองทรง formLogin เหมือนกัน ผู้ใช้คนเดียวกัน
และปลายทางทุกตัวเหมือนกันทุก byte ช่องที่ขยับจึงขยับเพราะสองอย่างนั้น
คำถามข้อแรก สองกลไกนี้ทับกันไหม
หัวข้อที่มีชื่อว่า “คำถามข้อแรก สองกลไกนี้ทับกันไหม”คำขอที่กลไกกันปลอมตัดสิน ได้คำตัดสินเดิมทั้งทรงที่ไม่มี CORS และทรงที่มี
ทุกคำขอในตารางนี้พก cookie ของ session ที่ login สำเร็จแล้ว และไม่ประกาศ Origin
ช่องซ้ายคือสิ่งที่บทที่ 1 วัดไว้ ช่องขวาคือสิ่งที่บทนี้เติมเข้ามา
POST /visits | ทรง csrf-on | ทรง csrf-on-cors |
|---|---|---|
| ไม่มี CSRF token | 403 · +0 แถว | 403 · +0 แถว |
| ส่ง CSRF token มาทาง header | 200 · +1 แถว | 200 · +1 แถว |
สองแถวนี้ไม่มีช่องไหนต่างกันเลย การเติม bean ของ CORS เข้ามา ไม่ได้ขยับคำตัดสิน ของกลไกกันปลอมสักช่องเดียว
นี่คือความหมายของคำว่าแยกกัน กลไกหนึ่งถามว่า CSRF token มาถึงหรือไม่ อีกกลไกถามว่าต้นทางที่ประกาศมาอยู่ในรายชื่อที่อนุญาตหรือไม่ และไม่มีตัวไหนตอบแทนกันได้
probe ยังยิงอีกใบเพื่อปิดข้อสงสัยที่เหลือ คือ POST /visits ที่ประกาศต้นทาง
https://allowed.example และแนบ CSRF token มาครบ ยิงใส่ทรง csrf-on-cors
ผลคือ 200 และแถวเพิ่ม 1 แถว ผ่านทั้งสองกลไกพร้อมกัน
ช่องที่กลไกกันปลอมไม่ได้ปิด และตัวที่ปิดมันจริง
หัวข้อที่มีชื่อว่า “ช่องที่กลไกกันปลอมไม่ได้ปิด และตัวที่ปิดมันจริง”
GET /visits/touchเขียนฐานข้อมูล และมันเดินผ่านกลไกกันปลอมไปได้ทั้งดุ้น
บทที่ 4 วัดปลายทางตัวนี้ไว้แล้ว มันเป็น safe methodsafe methodmethod ที่สเปกจัดว่าไม่เปลี่ยนสถานะ เช่น GET · กลไกกันปลอมจึงปล่อยผ่าน วัดแล้วว่าทรง csrf-on ให้ GET ที่เขียนฐานข้อมูลจริงได้ 200 และแถวเพิ่มหนึ่งแถว ทั้งที่ยิงมาจากต้นทางของผู้โจมตี ในสายตาของสเปก จึงไม่ถูกตรวจ CSRF token
ทั้งที่ตัวมันเขียนหนึ่งแถวลงตาราง csrf_visit จริง
คำขอที่ยิงคือใบเดียวกันทั้งสองช่อง คือ GET /visits/touch ที่ประกาศ
Origin: https://evil.example และ Referer: https://evil.example/attack.html
GET /visits/touch จากต้นทางของผู้โจมตี | รหัส | แถวใน csrf_visit |
|---|---|---|
ทรง csrf-on — สิ่งที่บทที่ 4 วัดไว้ | 200 | +1 |
ทรง csrf-on-cors | 403 | +0 |
body ของ 403 ใบนั้นไม่ใช่ body ของกลไกกันปลอม มันเป็นข้อความปฏิเสธของ CORS
ซึ่งบทที่ 7 อ่านตัวอักษรของมันไว้แล้ว ผู้ปฏิเสธจึงเป็นคนละตัวกับที่บทที่ 1 วัด
POST /visits ที่แนบ CSRF token ครบ แต่ประกาศต้นทางของผู้โจมตี ให้ผลรูปเดียวกัน
คือทรง csrf-on แถวเพิ่ม 1 แถว ส่วนทรง csrf-on-cors ตอบ 403 และไม่มีแถวลง
สัญชาตญาณบอกว่ากลไกกันปลอมดูแลการเขียน และ CORS เป็นเรื่องของการอ่าน ที่วัดได้คือกลับกันในช่องนี้ การเขียนที่หลุดออกไป ถูกปิดด้วยการตั้งค่า CORS
สองกลไก มองเป็นสองคำถามที่ถามคนละอย่าง
หัวข้อที่มีชื่อว่า “สองกลไก มองเป็นสองคำถามที่ถามคนละอย่าง”คำขอใบเดียวถูกถามสองคำถาม และคำถามที่ปฏิเสธมันขึ้นกับว่าใบนั้นมีรูปแบบไหน
flowchart TB
A["คำขอหนึ่งใบ · พก cookie ของ session ที่ login แล้ว"] --> Q1{"เป็น POST ที่ไม่มี CSRF token ไหม"}
Q1 -->|"ใช่"| R1["กลไกกันปลอมปฏิเสธ · แถวไม่ลง · ผลเท่ากันทั้งทรง csrf-on และ csrf-on-cors"]
Q1 -->|"ไม่ใช่ · เป็น GET ที่เขียน หรือ POST ที่แนบ CSRF token ครบ"| Q2{"ทรงนี้มี bean ของ CORS ไหม"}
Q2 -->|"csrf-on · ไม่มี"| R2["ผ่าน · แถวลง"]
Q2 -->|"csrf-on-cors · มี และประกาศต้นทางที่ไม่อยู่ในรายชื่อ"| R3["403 · แถวไม่ลง"]
Q2 -->|"csrf-on-cors · มี และประกาศต้นทางที่อยู่ในรายชื่อ หรือไม่ประกาศต้นทางเลย"| R4["200 · แถวลง"]
คำบรรยายภาพ: คำถามแรกเป็นของกลไกกันปลอม และคำตอบของมันไม่ขึ้นกับ CORS เลย คำถามที่สองเป็นของ CORS และมันได้ตัดสินก็ต่อเมื่อคำถามแรกปล่อยผ่านมาแล้ว
การตั้งค่าที่สตาร์ตได้ แล้วพังตอนมีต้นทางเข้ามา
หัวข้อที่มีชื่อว่า “การตั้งค่าที่สตาร์ตได้ แล้วพังตอนมีต้นทางเข้ามา”
allowedOrigins("*")คู่กับallowCredentials = trueเป็นคู่ที่ผิดกฎ และแอปก็ยังสตาร์ตขึ้นมา
package clinic.csrf
import org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.web.cors.CorsConfigurationimport org.springframework.web.cors.CorsConfigurationSourceimport org.springframework.web.cors.UrlBasedCorsConfigurationSource
@Configurationclass CorsConfig { @Bean fun corsConfigurationSource(): CorsConfigurationSource { val c = CorsConfiguration() c.allowedOrigins = listOf("*") c.allowedMethods = listOf("GET", "POST", "OPTIONS") c.allowedHeaders = listOf("*") c.allowCredentials = true val source = UrlBasedCorsConfigurationSource() source.registerCorsConfiguration("/**", c) return source }}ทรงนี้ปิดกลไกกันปลอมไว้ ไม่เหมือนสองทรงในหัวข้อก่อนหน้า POST /visits ที่ไม่มี CSRF token
จึงไม่มีใครตรวจมันตั้งแต่ต้น ทรงที่เทียบได้ตรงตัวจึงเป็น cors-configured ซึ่งปิดกลไกกันปลอม
เหมือนกันและอนุญาตต้นทางเดียว สองทรงนั้นต่างกันตรงบรรทัด c.allowedOrigins บรรทัดเดียว
ที่เหลือเท่ากันทุก byte
| ช่องที่วัด | ผลของทรง cors-star-credentials |
|---|---|
| แอปสตาร์ตขึ้นไหม | สตาร์ตได้ |
POST /visits ที่ไม่ประกาศ Origin | 200 · +1 แถว |
POST /visits ที่ประกาศ Origin ของผู้โจมตี | 500 · +0 แถว |
Access-Control-Allow-* ในคำตอบใบที่พัง | 0 ตัว |
| exception ที่โผล่ใน log | java.lang.IllegalArgumentException |
นี่คือการตั้งค่าที่มองไม่เห็นได้ง่ายที่สุดในคอร์สนี้ ชุดเทสต์ที่ยิงจากในเครื่องเดียวกัน
ไม่ประกาศ Origin มาด้วย มันจึงตกลงในแถวที่สองของตาราง และเขียวสนิท
รหัสที่ออกมาไม่ใช่ 403 ที่แปลว่าปฏิเสธ มันเป็น 500 ที่แปลว่าแอปโยน exception ออกมา
ผู้เรียกที่เห็นรหัสนี้จึงอ่านไม่ออกว่าตัวเองถูกปฏิเสธด้วยกฎ หรือแอปพัง
ผลทั้งบทวัดบน Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25 รุ่นอื่นเป็นคนละการวัด
โดยเฉพาะข้อที่ว่าแอป สตาร์ตได้ ทั้งที่คู่ค่านี้ผิดกฎ ข้อนี้เป็นผลของทรงที่ probe สร้างขึ้นบนรุ่นนั้น ไม่ใช่กฎของ Spring ที่อ่านได้จากที่อื่น
SameSite ตัวอักษรที่ server คายออกมา
หัวข้อที่มีชื่อว่า “SameSite ตัวอักษรที่ server คายออกมา”วัดได้ว่า Boot 4 ไม่ใส่ค่าตั้งต้นมาให้ และ property หนึ่งบรรทัดเขียนค่านั้นลงไปตรงตัว
SameSiteSameSiteคุณสมบัติของ cookie ที่บอกเบราว์เซอร์ว่าจะแนบ cookie ไปกับคำขอข้ามไซต์หรือไม่ · คอร์สนี้อ้างได้แค่ literal ที่ server คายออกมา ซึ่งวัดแล้วว่า Boot 4 ไม่ใส่ค่าตั้งต้นมาให้ และค่านั้นไม่เปลี่ยนจำนวนแถวสักแถว คือคุณสมบัติของ cookie ที่บอกเบราว์เซอร์ว่าจะแนบ cookie ไปกับคำขอข้ามไซต์หรือไม่
สิ่งที่บทนี้วัดคือตัวอักษรใน Set-Cookie ที่ server ส่งออกมา
ทรง samesite-lax กับ samesite-none คือทรง csrf-off บวก property หนึ่งบรรทัด
server.servlet.session.cookie.same-site=Laxทรง samesite-none คือบรรทัดเดียวกันนี้ ที่มีค่าเป็น None แทน Lax ไม่มี byte อื่นต่างกัน
| ทรง | Set-Cookie ที่คำตอบของ /login ส่งกลับมา |
|---|---|
csrf-off | JSESSIONID=<id>; Path=/; HttpOnly |
samesite-lax | JSESSIONID=<id>; Path=/; HttpOnly; SameSite=Lax |
samesite-none | JSESSIONID=<id>; Path=/; HttpOnly; SameSite=None |
<id> คือค่าที่ probe แทนที่ไว้เอง เพราะมันเปลี่ยนทุกรอบ ส่วนตัวอักษรที่เหลือเป็นของจริง
แถวแรกจึงเป็นข้ออ้างข้อหนึ่ง คือ ไม่มี SameSite โผล่ออกมาเลยเมื่อไม่ได้ตั้งค่า
ทั้งสองทรงที่ตั้งค่าไว้ ยังรับ POST /visits แล้วเขียน 1 แถว เท่ากัน
property ตัวนี้จึงเขียนตัวอักษรลงใน header และไม่ได้ห้ามอะไรที่ฝั่ง server เลย
cookie ใบที่ตารางนี้อ่าน คือใบที่ /login คายออกมา และในทรงที่กลไกกันปลอมปิดอยู่
ยังไม่มี session อยู่ก่อนหน้านั้นเลย ส่วนทรง csrf-on มี session อยู่ก่อน login แล้ว
ซึ่งเป็นข้อที่บทที่ 2 วัดไว้ในตารางของทรงนั้น
พฤติกรรมข้ามไซต์ของ SameSite ยังไม่ได้วัด และคอร์สนี้จะไม่อ้างมัน
ชุดวัดฝั่งเบราว์เซอร์ไม่เคยตั้ง cookie ให้ต้นทางของ API เลย ช่อง cookieSeen
จึงเป็น false ทุกทรง และ SameSite=None ต้องมาคู่กับ Secure ซึ่ง http ธรรมดาให้ไม่ได้
ที่คอร์สนี้อ้างได้คือตัวอักษรที่ server คายออกมา และจำนวนแถวที่ไม่ขยับ ไม่ใช่สิ่งที่เบราว์เซอร์ ตัดสินใจ ทำกับตัวอักษรนั้น
แปดบทตอบอะไรไป และไม่ตอบอะไร
หัวข้อที่มีชื่อว่า “แปดบทตอบอะไรไป และไม่ตอบอะไร”คำถามตั้งต้นได้คำตอบเป็นจำนวนแถว และรายการที่ไม่ได้ตอบก็ยาวพอกัน
ก่อนคอร์สนี้ นับเมื่อ 2026-09-05 ทั้งไซต์เอ่ยถึงสองเรื่องนี้รวมกัน 2 บรรทัด หนึ่งในนั้นคือบรรทัดที่คอร์สที่ 35 ปิดกลไกกันปลอมทิ้ง และอีกบรรทัดคือประโยคที่บอกว่า เรื่องนี้ใหญ่เกินกว่าแปดบทของคอร์สนั้นจะรับไหว
สิ่งที่แปดบทนี้ตอบไปแล้ว
- บรรทัดที่ถูกปิด ปิดคำถามข้อที่สองไป คือคำขอใบนี้มาจากหน้าเว็บของแอปเองหรือไม่
- CSRF token ผูกกับ session หนึ่งใบ และมันไม่ใช่ค่าที่ใช้ได้ครั้งเดียว
- คำขอรูปเดียวกันได้รหัสคนละตัว เพราะลำดับของ filter ไม่ใช่เพราะค่าใน config
GETที่เขียนฐานข้อมูล เดินผ่านกลไกกันปลอมไปได้- เบราว์เซอร์บอกว่าบล็อก แต่แถวลงไปแล้ว เพราะการปฏิเสธเกิดที่ขั้นอ่านคำตอบ
- มีทรงที่การเขียนไม่เคยเกิดขึ้นเลย และเส้นแบ่งอยู่ที่รูปของคำขอ
- ตั้งค่า CORS แล้ว server ปฏิเสธเอง และตัวสวิตช์คือ bean ไม่ใช่บรรทัดที่ดูเหมือนสวิตช์
- สองกลไกนี้แยกกัน และตัวที่ปิดรูของ
GETที่เขียน คือ CORS
สิ่งที่คอร์สนี้ไม่ได้ตอบ และไม่ควรถูกอ่านว่าตอบแล้ว
- พฤติกรรมข้ามไซต์ของ
SameSiteยังไม่ได้วัด ด้วยเหตุผลในกล่องข้างบน - สิ่งที่หน้าเว็บเห็นเมื่อเจอ
500ของทรง wildcard คู่กับ credentials ยังไม่ได้วัด - อายุที่ preflight ถูกเก็บไว้ใช้ซ้ำ ข้อนั้นมีนาฬิกาอยู่ข้างใน จึงตกนอกหน่วยของคอร์สนี้
- OAuth2 · OIDC และหน้า login คอร์สที่ 35 ตัดออกด้วยเหตุผลเดียวกัน
หน่วยของหลักฐานที่ probe ใช้ทั้งชุดมีสี่หน่วย คือจำนวนแถวใน csrf_visit รหัส HTTP
จำนวน header และตัวอักษรของ Set-Cookie ส่วนชุดวัดฝั่งเบราว์เซอร์นับจำนวนคำขอ
ที่ปลายทางได้รับ และไม่มีข้ออ้างข้อไหนในคอร์สนี้อ่านนาฬิกา
-
Spring Security — Cross Site Request Forgery (CSRF) (เข้าถึง 2026-09-05) — ที่มาของความหมายของกลไกกันปลอม และของ
safe methodที่มันไม่ตรวจ -
Spring Framework — CORS configuration (เข้าถึง 2026-09-05) — ที่มาของ
CorsConfigurationและของกฎที่ห้ามallowedOrigins("*")คู่กับallowCredentials -
Fetch Standard — CORS protocol (เข้าถึง 2026-09-05) — ที่มาของ CORS protocol · หัวข้อนี้เขียนไว้เองว่ามันอธิบาย protocol ในมุมของผู้พัฒนาฝั่ง server ส่วนข้อกำหนดของ user agent อยู่ในขั้นตอน fetch · บทนี้อ้างมันเป็นสเปกเท่านั้น ไม่ได้วัดมัน
-
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/clinic-cross-origin/probe.pyซึ่งสร้างโปรเจกต์ ขึ้นใหม่ทีละทรงแล้วเทียบผลทีละช่อง · ต้องสั่งscripts/clinic-cross-origin/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55437
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3ยิง `POST /visits` ที่ไม่มี CSRF token ใส่ทรง `csrf-on` และทรง `csrf-on-cors` ผลต่างกันตรงไหน