บรรทัดเดียวที่คอร์สก่อนหน้าปิดไว้ POST ที่ไม่มี token ได้ 200 และแถวลงหนึ่งแถว
คอร์สคัดกรองผู้เรียกของคลินิก วางกฎขั้นต่ำไว้ก้อนหนึ่ง
แล้วตั้งชื่อก้อนนั้นเองว่า SecConfig.kt — กฎขั้นต่ำที่ probe ใช้วัดทุกทรงหลังจากนี้
ก้อนนี้พิมพ์ออกมาในบทเดียว แต่ชื่อของมันพูดถึง ทรง ไม่ใช่บท คอร์สนั้นวัด 12 ทรง
และ 10 ทรง ในนั้นตั้งอยู่บนกฎก้อนนี้ อีก 2 ทรงไม่มี SecConfig.kt เลย เพราะเป็น
สองทรงที่คอร์สนั้นวัดไว้ก่อนจะเขียนกฎก้อนนี้ขึ้นมา
ในก้อนนั้นมีบรรทัด http.csrf { it.disable() } อยู่หนึ่งบรรทัด และไม่มีบทไหนของคอร์สนั้น
บอกว่ามันปิดอะไรไป บทนี้เอาบรรทัดนั้นออก แล้ววัดว่าช่องไหนเปลี่ยน
คอร์สนี้ตอบบรรทัดที่ clinic-security ปิดไว้ แต่ไม่ได้ยกโปรเจกต์
ใบเดิมมาต่อ Visit ของที่นี่มีแค่ id กับ note ตารางชื่อ csrf_visit
และ container เป็นคนละใบกับของคอร์สนั้น
ตัวเลขทุกตัวในบทนี้มาจาก probe ที่คุณรันเองได้ สั่ง scripts/clinic-cross-origin/up.sh
แล้วตามด้วย python3 scripts/clinic-cross-origin/probe.py
บรรทัดที่แปดบทวางอยู่บนมัน และไม่มีบทไหนอธิบาย
หัวข้อที่มีชื่อว่า “บรรทัดที่แปดบทวางอยู่บนมัน และไม่มีบทไหนอธิบาย”บรรทัดนั้นมีที่อยู่ที่ระบุได้ และคอร์สนั้นไม่เคยอธิบายมัน
บรรทัดที่พูดถึงอยู่ที่ clinic-security/01-one-line-that-broke-the-contract.mdx
บรรทัดที่ 83 ซึ่งเป็นบรรทัดแรกในตัวงานของ fun chain
ก่อนคอร์สนี้ ทั้งไซต์เอ่ยถึง CSRF กับ CORS รวมกัน 2 บรรทัด นับเมื่อ 2026-09-05
โดยไม่นับไดเรกทอรีของคอร์สนี้เอง บรรทัดนั้นคือหนึ่งในสองบรรทัด
อีกบรรทัดอยู่ในหัวข้อสิ่งที่คอร์ส 35 จงใจไม่แตะ
CSRFCross-Site Request Forgery (CSRF)การที่หน้าเว็บของผู้อื่นสั่งเบราว์เซอร์ของผู้ใช้ให้ยิงคำขอไปยังแอปที่ผู้ใช้ยัง login ค้างอยู่ · กลไกกันของ Spring Security คือค่าที่ผู้เรียกต้องแนบมาเอง ซึ่งหน้าเว็บของผู้อื่นอ่านไม่ได้ คือการที่หน้าเว็บของผู้อื่นสั่งเบราว์เซอร์ของผู้ใช้ให้ยิงคำขอไปยังแอปที่ผู้ใช้ ยัง login ค้างอยู่ กลไกกันของ Spring Security คือค่าที่ผู้เรียกต้องแนบมาเอง
it.disable() ปิดกลไกนั้นทั้งตัว คำถามของบทนี้จึงเป็นคำถามเดียว คือปิดแล้วช่องไหนเปลี่ยน
และช่องนั้นวัดเป็นอะไรได้
ทรงสองทรง ที่ต่างกันหนึ่งบรรทัด
หัวข้อที่มีชื่อว่า “ทรงสองทรง ที่ต่างกันหนึ่งบรรทัด”probe สร้างสองทรงจากรูปเดียวกัน ทรงหนึ่งมีบรรทัดนั้น อีกทรงไม่มี และไม่มี byte อื่นต่างกัน
package clinic.csrf
import org.springframework.context.annotation.Beanimport org.springframework.context.annotation.Configurationimport org.springframework.security.config.annotation.web.builders.HttpSecurityimport org.springframework.security.core.userdetails.Userimport org.springframework.security.core.userdetails.UserDetailsServiceimport org.springframework.security.provisioning.InMemoryUserDetailsManagerimport org.springframework.security.web.SecurityFilterChain
@Configurationclass SecConfig { @Bean fun chain(http: HttpSecurity): SecurityFilterChain { http.csrf { it.disable() } http.authorizeHttpRequests { it.requestMatchers("/ping", "/csrf", "/login").permitAll() it.anyRequest().authenticated() } http.formLogin { } return http.build() }
@Bean fun users(): UserDetailsService = InMemoryUserDetailsManager( User.withUsername("kaen-a").password("{noop}pw-for-probe-only").roles("USER").build(), )}chain คืน bean ชนิด SecurityFilterChainSecurityFilterChainbean ที่ประกาศว่าคำขอเดินผ่าน filter ตัวไหนบ้าง ตามลำดับใด · สิ่งที่มันประกาศไม่ได้บอกครบว่าสายนั้นมี filter ตัวใดบ้าง เพราะ bean ตัวอื่นเติม filter เข้าสายเดียวกันได้ด้วย ซึ่งประกาศว่าคำขอเดินผ่าน filter ตัวไหนบ้าง
ทรง csrf-on คือรูปเดียวกันนี้ ลบบรรทัด http.csrf { it.disable() } ทิ้งทั้งบรรทัด
probe ตรวจข้อนี้เป็นข้ออ้างของตัวเอง มันเทียบสองรูปทีละบรรทัด แล้วรายงานว่าบรรทัดที่มี
เฉพาะใน csrf-off คือบรรทัดนั้นบรรทัดเดียว และบรรทัดที่มีเฉพาะใน csrf-on มี 0 บรรทัด
ปลายทางที่ทั้งสองทรงใช้ร่วมกัน
หัวข้อที่มีชื่อว่า “ปลายทางที่ทั้งสองทรงใช้ร่วมกัน”
POST /visitsเขียนหนึ่งแถว ส่วน/csrfคืนค่าที่ Spring เตรียมไว้ให้คำขอนั้น
@GetMapping("/csrf") fun csrf(token: CsrfToken?): Map<String, Any> = if (token == null) mapOf("present" to false) else mapOf( "present" to true, "headerName" to token.headerName, "parameterName" to token.parameterName, "token" to token.token, )
/** เขียนหนึ่งแถว · รูปปกติของการเขียน */ @PostMapping("/visits") fun create(): Map<String, Any> = mapOf("id" to svc.add("post"), "who" to who())argument ชนิด CsrfTokenCsrfTokenค่าที่คำขอซึ่งเปลี่ยนข้อมูลต้องแนบมาด้วย · Spring คายมันออกมาให้อ่านก่อน แล้วรับกลับได้สองทาง คือ header X-CSRF-TOKEN และ parameter _csrf · รหัสที่ได้เมื่อไม่แนบค่านี้ ขึ้นกับทรงที่วัด ไม่ใช่ค่าเดียวตายตัว ตัวนั้นเป็น nullable โดยตั้งใจ ค่า null คือค่าที่วัดได้
ไม่ใช่ข้อผิดพลาด และเป็นวิธีที่บทนี้ถามว่า Spring ฉีดค่านั้นเข้ามาหรือไม่
หน่วยของหลักฐานทั้งบทมีสองหน่วย คือรหัส HTTP ที่ผู้เรียกได้รับกลับมา
และจำนวนแถวที่เพิ่มขึ้นในตาราง csrf_visit นับก่อนยิงหนึ่งครั้ง นับหลังยิงอีกหนึ่งครั้ง
ปิดไว้ · ไม่มี token ให้ส่ง และแถวก็ลง
หัวข้อที่มีชื่อว่า “ปิดไว้ · ไม่มี token ให้ส่ง และแถวก็ลง”ทรง
csrf-offรับคำขอทั้งที่ไม่มี CSRF token มาด้วย และไม่มีค่านั้นให้ส่งตั้งแต่ต้น
ทุกคำขอในตารางนี้พก cookie ของ session ที่ login สำเร็จแล้ว ต่างกันแค่ header ที่ประกาศเพิ่มเข้ามา
คำขอที่ยิงใส่ทรง csrf-off | รหัส | แถวใน csrf_visit |
|---|---|---|
POST /visits ไม่มี CSRF token | 200 | +1 |
POST /visits ไม่มี CSRF token · ประกาศ Origin และ Referer ของผู้โจมตี | 200 | +1 |
แถวที่สองยิงด้วย Origin: https://evil.example และ
Referer: https://evil.example/attack.html ผลที่ได้เท่ากับแถวแรกทุกช่อง
ก่อนหน้านั้น probe ยิง GET /csrf ด้วย session ใบเดียวกัน คำตอบที่ได้คือ
present เป็น false แปลว่า Spring ไม่ได้ฉีด CsrfToken เข้ามาให้คำขอนี้เลย
ไม่มีค่าให้ส่ง จึงไม่มีอะไรให้ตรวจ ผู้เรียกที่ถือ cookie ของ session ที่ login แล้ว เขียนหนึ่งแถวได้ โดยไม่ต้องพิสูจน์ว่าคำขอนั้นมาจากหน้าเว็บของแอปเอง
เอาบรรทัดนั้นออก แล้ววัดใหม่
หัวข้อที่มีชื่อว่า “เอาบรรทัดนั้นออก แล้ววัดใหม่”ทรง
csrf-onปฏิเสธคำขอที่ไม่มี CSRF token และรับคำขอที่มีค่านั้นทั้งสองช่องทาง
คำขอที่ยิงใส่ทรง csrf-on | รหัส | แถวใน csrf_visit |
|---|---|---|
POST /visits ไม่มี CSRF token | 403 | +0 |
POST /visits · ส่งค่านั้นทาง header X-CSRF-TOKEN | 200 | +1 |
POST /visits · ส่งค่านั้นทาง parameter ของฟอร์ม _csrf | 200 | +1 |
body ของ 403 ใบนั้นมีช่อง error เป็น Forbidden และช่อง path เป็น /visits
ชื่อ header กับชื่อ parameter ทั้งสองตัวไม่ได้ถูกเดา probe อ่านมาจากคำตอบของ /csrf
สองแถวล่างจึงบอกอย่างเดียวกันสองครั้ง คือกลไกนี้ตรวจว่า ค่านั้นมาถึงหรือไม่ ไม่ได้ตรวจว่ามันเดินทางมาทางช่องไหน
สองช่องที่เปลี่ยน และช่องที่ไม่เปลี่ยน
หัวข้อที่มีชื่อว่า “สองช่องที่เปลี่ยน และช่องที่ไม่เปลี่ยน”เทียบสองทรงช่องต่อช่อง ความต่างทั้งหมดมาจากบรรทัดเดียว
| ช่องที่วัด | ทรง csrf-off | ทรง csrf-on |
|---|---|---|
/csrf ฉีด CsrfToken ให้คำขอไหม | ไม่ฉีด | ฉีด |
POST /visits ไม่มี CSRF token | 200 · +1 แถว | 403 · +0 แถว |
POST /visits ส่งค่านั้นทาง header | ไม่มีค่าให้ส่ง | 200 · +1 แถว |
POST /visits ส่งค่านั้นทาง parameter ของฟอร์ม | ไม่มีค่าให้ส่ง | 200 · +1 แถว |
authorizeHttpRequests เหมือนกันทั้งสองทรง formLogin เหมือนกัน ผู้ใช้คนเดียวกัน
และปลายทางเดียวกันทุก byte ช่องที่ขยับจึงขยับเพราะบรรทัดเดียวนั้น
สิ่งที่บรรทัดนั้นปิด ไม่ใช่การยืนยันตัว ผู้เรียกในทั้งสองทรง login สำเร็จเท่ากัน สิ่งที่มันปิดคือคำถามข้อที่สอง คือคำขอใบนี้มาจากหน้าเว็บของแอปเองหรือไม่
คำขอใบเดียวกัน เดินคนละทาง
หัวข้อที่มีชื่อว่า “คำขอใบเดียวกัน เดินคนละทาง”เส้นทางของคำขอที่ไม่มี CSRF token แยกออกเป็นสองทางที่บรรทัดเดียวนั้น
flowchart TB
Q["POST /visits · พก cookie ของ session ที่ login แล้ว · ไม่มี CSRF token"] --> L{"SecConfig มีบรรทัด http.csrf ที่ปิดทิ้งไหม"}
L -->|"ทรง csrf-off · มีบรรทัดนั้น"| OFF["ไม่มีอะไรตรวจ CSRF token"]
OFF --> OR["200 · csrf_visit เพิ่ม 1 แถว"]
L -->|"ทรง csrf-on · ไม่มีบรรทัดนั้น"| ON["มีตัวตรวจว่าค่านั้นมาถึงหรือไม่"]
ON -->|"คำขอใบนี้ ที่ไม่มีค่านั้น"| NR["403 · csrf_visit เพิ่ม 0 แถว"]
ON -->|"คำขออีกใบ ที่แนบค่านั้นมาทาง header หรือทาง parameter"| OK["200 · csrf_visit เพิ่ม 1 แถว"]
คำบรรยายภาพ: คำขอใบเดียวกันเดินเข้ามาที่ทั้งสองทรง ทรงที่ปิดไว้ไม่มีอะไรตรวจ จึงเขียนหนึ่งแถว ส่วนทรงที่เปิดไว้ปฏิเสธจนกว่าค่านั้นจะมาถึง
ที่วัดได้ กับที่สเปกกำหนด
หัวข้อที่มีชื่อว่า “ที่วัดได้ กับที่สเปกกำหนด”ตัวเลขทั้งบทมาจาก probe ฝั่ง server ไม่มีเบราว์เซอร์อยู่ในการวัดสักคำขอเดียว
probe ยิงทุกคำขอด้วย http.client ของ Python และตั้ง Origin Referer และ Cookie
เองทุกตัว ตัวเลขในบทนี้จึงเป็นข้ออ้างเรื่องสิ่งที่ server ทำ ไม่ใช่เรื่องสิ่งที่เบราว์เซอร์ทำ
ตัวที่กำหนดว่าเบราว์เซอร์ ตัดสินใจ ใส่ Origin ให้คำขอใบไหนคือ Fetch Standard
ซึ่งเป็นสเปก ไม่ใช่สิ่งที่บทนี้วัด แถวที่สองของตารางทรง csrf-off จึงอ้างได้ข้อเดียว
คือ server ทำอะไรกับ header ที่ประกาศต้นทางอื่นมา
ผลทั้งหมดในบทนี้วัดบน Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25 รุ่นอื่นเป็นคนละการวัด และบทนี้ไม่ได้อ้างถึงมัน
403 กับ +0 แถว เป็นผลของทรงที่ probe สร้างขึ้น ไม่ใช่กฎของ Spring
ที่อ่านได้จากที่อื่น บทที่ 3 จะแสดงคำขอที่รูปเหมือนกันแต่ได้รหัสคนละตัว
คำถามที่บทนี้ทิ้งไว้
หัวข้อที่มีชื่อว่า “คำถามที่บทนี้ทิ้งไว้”รู้แล้วว่าบรรทัดนั้นปิดอะไร ยังไม่รู้ว่าค่าที่ต้องแนบมานั้นเป็นของใคร
ทรง csrf-on รับคำขอที่แนบค่านั้นมา และปฏิเสธคำขอที่ไม่แนบ ทั้งสองผลวัดได้แล้ว
แต่ยังไม่มีข้อไหนบอกว่า server เทียบค่านั้นกับอะไร
ถ้ามันเป็นค่าที่ใครก็หยิบไปใช้ได้ กลไกนี้ก็ไม่กันอะไรเลย ถ้ามันผูกกับผู้เรียกรายหนึ่ง คำถามถัดไปคือผูกกับอะไรของผู้เรียกรายนั้น และหยิบค่านั้นมาสองครั้งได้ค่าเดิมหรือไม่
บทที่ 2 ยิงคำถามนี้เป็นคำขอ มันหยิบค่านั้นจาก session หนึ่ง แล้วส่งไปกับคำขอของ อีก session หนึ่ง แล้วนับแถวดู
-
Spring Security — Cross Site Request Forgery (CSRF) (เข้าถึง 2026-09-05) — ที่มาของความหมายของ
http.csrfและของค่าที่ผู้เรียกต้องแนบมา -
Fetch Standard —
Originheader (เข้าถึง 2026-09-05) — สเปกที่กำหนดว่าเบราว์เซอร์ใส่Originให้คำขอใบไหน · บทนี้อ้างมันเป็นสเปกเท่านั้น ไม่ได้วัดมัน -
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/clinic-cross-origin/probe.pyซึ่งสร้างโปรเจกต์ ขึ้นใหม่ทีละทรงแล้วเทียบผลทีละช่อง · ต้องสั่งscripts/clinic-cross-origin/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55437
เช็กความเข้าใจ — บทที่ 1
ข้อ 1 / 3ทรง `csrf-off` ยิง `POST /visits` โดยไม่มี CSRF token มาด้วย ได้อะไรกลับมา