คำขอที่ถูกปฏิเสธด้วยเหตุผลเดียวกัน ได้ 401 ที่ทรงหนึ่ง และ 403 ที่อีกทรง
บทที่ 1 จบด้วยรหัส 403
สำหรับ POST /visits ที่ไม่มี CSRF token มาด้วย บทนี้ยิงคำขอรูปเดิมอีกครั้ง
ใส่ทรงที่ต่างออกไปหนึ่งทรง แล้วได้ 401 กลับมา
รหัสสองตัวนั้นไม่ได้มาจากค่าที่ใครตั้งไว้ ทั้งสองทรงไม่มีบรรทัดไหนเขียน 401 หรือ 403
เอาไว้เลย บทนี้วัดว่าอะไรเป็นตัวเลือกรหัส
สั่ง scripts/clinic-cross-origin/up.sh แล้วตามด้วย
python3 scripts/clinic-cross-origin/probe.py ทรงที่บทนี้ใช้ชื่อ csrf-on-basic
และทรงเทียบชื่อ csrf-on ซึ่งเป็นทรงเดียวกับที่บทที่ 1 วัด
ทรงที่สาม ต่างจากทรงเทียบแค่ SecConfig.kt
หัวข้อที่มีชื่อว่า “ทรงที่สาม ต่างจากทรงเทียบแค่ SecConfig.kt”
SecConfig.ktของสองทรงต่างกันสี่จุด และทุกอย่างที่เหลือเหมือนกันทุก 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.config.http.SessionCreationPolicyimport 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.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } http.authorizeHttpRequests { it.requestMatchers("/ping", "/csrf").permitAll() it.anyRequest().authenticated() } http.httpBasic { } return http.build() }
@Bean fun users(): UserDetailsService = InMemoryUserDetailsManager( User.withUsername("kaen-a").password("{noop}pw-for-probe-only").roles("USER").build(), )}เทียบกับทรง csrf-on ของบทที่ 1 แล้ว จุดที่ต่างกันมีสี่จุด
importของ SessionCreationPolicySessionCreationPolicyค่าที่บอกว่าแอปสร้าง session เมื่อใด · ค่า STATELESS อ่านเหมือนคำสั่งห้ามสร้าง แต่ไม่ได้ห้ามส่วนอื่นสร้างเอง วัดแล้วว่ายังมี Set-Cookie ออกมา 1 ตัว เพิ่มเข้ามาหนึ่งบรรทัด- บรรทัด
http.sessionManagementที่ตั้งค่านั้นเป็นSTATELESSเพิ่มเข้ามาหนึ่งบรรทัด - รายการที่
permitAllตัด/loginออก เพราะทรงนี้ไม่มีหน้า login ให้เข้า http.formLogin { }กลายเป็นhttp.httpBasic { }
ปลายทางที่ยิงใส่ ผู้ใช้ที่ยิง และค่าที่ตั้งไว้ทุกตัวนอก SecConfig.kt
เหมือนกันทุก byte ระหว่างสองทรง จุดที่ต่างจึงอยู่ในนี้ทั้งหมด
รหัสที่ออกมาไม่เท่ากัน
หัวข้อที่มีชื่อว่า “รหัสที่ออกมาไม่เท่ากัน”คำขอรูปเดียวกัน ปฏิเสธด้วยเหตุผลเดียวกัน แต่ผู้เรียกได้ตัวเลขคนละตัว
ทุกคำขอในตารางนี้ยิง POST /visits เหมือนกัน ทรง csrf-on พก cookie ของ session
ที่ login สำเร็จแล้ว ส่วนทรง csrf-on-basic พก header Authorization มาเองทุกใบ
| คำขอที่ไม่มี CSRF token มาด้วย | รหัส | แถวใน csrf_visit |
|---|---|---|
ทรง csrf-on · ยืนยันตัวด้วย session | 403 | +0 |
ทรง csrf-on-basic · ยืนยันตัวด้วย Basic | 401 | +0 |
ช่องขวาเท่ากัน ทั้งสองทรงไม่มีแถวลงสักแถว การปฏิเสธเกิดขึ้นจริงทั้งคู่ สิ่งที่ต่างคือตัวเลขที่ผู้เรียกอ่านได้จากคำตอบ
ในทรง csrf-on-basic คำขอที่พก Authorization ใบเดียวกันนี้ ยิงจากการเชื่อมต่อที่หยิบ
CSRF token มาจาก /csrf และแนบค่านั้นไปด้วย ได้ 200 และแถวเพิ่มหนึ่งแถว
กลไกกันปลอมจึงทำงานเต็มตัวในทรงนี้
ตัวตัดสินไม่ได้อยู่ในค่าที่ตั้งไว้
หัวข้อที่มีชื่อว่า “ตัวตัดสินไม่ได้อยู่ในค่าที่ตั้งไว้”
Authorizationใบที่ได้401คือใบเดียวกับที่ได้200ผู้เรียกจึงมีสิทธิ์อยู่แล้ว
RFC 9110 นิยาม 401 ว่าคำขอไม่ถูกนำไปทำ เพราะมันขาดค่ายืนยันตัวที่ใช้ได้กับปลายทางนั้น
ส่วน 403 นิยามไว้กว้างกว่า คือ server เข้าใจคำขอแล้วแต่ปฏิเสธที่จะทำให้
และ RFC เขียนกำกับไว้เองว่าคำขออาจถูกปฏิเสธด้วยเหตุที่ไม่เกี่ยวกับค่ายืนยันตัวก็ได้
แต่คำขอที่ได้ 401 ในทรง csrf-on-basic พก Authorization ที่ถูกต้องมาด้วย
probe รู้ว่ามันถูกต้อง เพราะ header ใบเดียวกันนั้นได้ 200 และแถวลงหนึ่งแถว
เมื่อยิงจากการเชื่อมต่อที่หยิบ CSRF token มา พร้อมแนบค่านั้นไปด้วย
สิ่งที่ยังไม่เกิดตอนถูกปฏิเสธ ไม่ใช่การส่งรหัสผ่าน มันคือการอ่านรหัสผ่านใบนั้น
ตัวที่ปฏิเสธทำงานจบไปก่อนที่จะมีใครแตะ Authorization ผู้เรียกจึงยังเป็นคนไม่มีชื่อ
ณ จุดที่คำตอบถูกเขียน
ลำดับใน filter chain เป็นตัวเลือก
หัวข้อที่มีชื่อว่า “ลำดับใน filter chain เป็นตัวเลือก”เอกสารของ Spring Security พิมพ์ลำดับของสายออกมาตรงๆ และตำแหน่งของสองตัวนี้อธิบายผลได้
filter chainfilter chainลำดับของ filter ที่คำขอเดินผ่านก่อนถึง controller · วัดแล้วว่าคำขอทรงเดียวกันได้ 401 เมื่อยืนยันตัวด้วย Basic และได้ 403 เมื่อมาพร้อม session ตัวตัดสินจึงเป็นลำดับ ไม่ใช่ค่าใน config คือลำดับที่คำขอเดินผ่านก่อนถึงปลายทาง เอกสารของ Spring Security
พิมพ์สายตั้งต้นออกมาเป็นรายชื่อ ในรายชื่อนั้น SecurityContextHolderFilter ไม่ได้อยู่ต้นสาย
มันตามหลัง DisableEncodeUrlFilter กับ WebAsyncManagerIntegrationFilter และระหว่างมัน
กับ CsrfFilterCsrfFilterfilter ที่ตรวจ CSRF token ของคำขอ · เอกสารของ Spring Security วางมันไว้ก่อน BasicAuthenticationFilter ในสาย · รหัสที่ผู้เรียกได้จึงขึ้นกับว่าตอนถูกปฏิเสธผู้เรียกมีตัวตนแล้วหรือยัง ไม่ใช่ค่าเดียวตายตัวของ Spring ยังมี HeaderWriterFilter คั่นอยู่อีกตัวหนึ่ง
ข้อที่บทนี้ใช้จริงมีข้อเดียว คือ BasicAuthenticationFilter อยู่หลัง CsrfFilter
ส่วน SecurityContextHolderFilter อยู่ก่อน มัน สองข้อนี้พอสำหรับอธิบายรหัสที่วัดได้
ผลของลำดับนั้นต่อสองทรงของบทนี้อ่านได้ตรงๆ
- ทรงที่ใช้ session ได้ตัวตนคืนมาจาก cookie ที่ filter ซึ่งอยู่ก่อน
CsrfFilterตอนCsrfFilterปฏิเสธ ผู้เรียกจึงมีชื่ออยู่แล้ว - ทรงที่ใช้ Basic ได้ตัวตนจาก
Authorizationที่ filter ซึ่งอยู่หลังCsrfFilterตอนถูกปฏิเสธ จึงยังไม่มีใครอ่าน header ใบนั้น
entry pointAuthenticationEntryPointจุดที่ Spring Security ส่งคำขอซึ่งยังไม่ถูกยืนยันตัวไปให้ตัดสิน · คอร์สนี้วัดผลของมันเป็นรหัสที่ผู้เรียกได้รับกลับมา ไม่ได้อ่านจากค่าที่ประกาศไว้ คือจุดที่ Spring Security ส่งคำขอซึ่งยังไม่มีตัวตนไปให้ตัดสิน คอร์สนี้วัดผลของมันเป็นรหัสที่ผู้เรียกได้รับกลับมา ไม่ได้อ่านจากค่าที่ประกาศไว้
flowchart TB
Q["POST /visits · ไม่มี CSRF token มาด้วย"] --> K{"ตอนที่ CsrfFilter ปฏิเสธ ผู้เรียกมีตัวตนแล้วหรือยัง"}
K -->|"ทรง csrf-on · ตัวตนมาจาก cookie ที่ filter ก่อนหน้า CsrfFilter"| HAS["มีตัวตนแล้ว"]
HAS --> R403["403 · csrf_visit เพิ่ม 0 แถว"]
K -->|"ทรง csrf-on-basic · ตัวตนมาจาก filter ที่อยู่หลัง CsrfFilter"| NONE["ยังไม่มีตัวตน"]
NONE --> R401["401 · csrf_visit เพิ่ม 0 แถว"]
คำบรรยายภาพ: คำขอใบเดียวกันถูกปฏิเสธที่จุดเดียวกันทั้งสองทรง สิ่งที่ต่างคือสถานะของผู้เรียก ณ จุดนั้น และรหัสที่ออกมาก็ต่างตามไปด้วย
ที่วัดได้ กับที่อ่านมาจากเอกสาร
หัวข้อที่มีชื่อว่า “ที่วัดได้ กับที่อ่านมาจากเอกสาร”สองรหัสกับสองจำนวนแถวคือของที่วัด ส่วนลำดับของสายคือของที่อ่านมา
probe วัดสี่ค่า คือ 401 กับ +0 ในทรง csrf-on-basic และ 200 กับ +1
เมื่อคำขอที่พก Authorization ใบเดียวกันนั้น ยิงจากการเชื่อมต่อที่หยิบ CSRF token มา
และแนบค่านั้นไปด้วย ทั้งสี่ค่ามาจากการยิงจริงและนับแถวจริง
ลำดับของ filter ไม่ใช่ค่าที่ probe วัด มันเป็นสิ่งที่เอกสารของ Spring Security พิมพ์ไว้เป็นรายชื่อ บทนี้อ่านมาจากที่นั่น แล้วใช้มันอธิบายผลที่วัดได้
ส่วนที่บทนี้ไม่อ้าง คือชื่อของ component ที่เขียนเลข 401 ลงในคำตอบ
probe ไม่ได้วัดข้อนั้น และการเดาชื่อมาใส่ก็ไม่ได้ทำให้มันจริงขึ้น
STATELESS แล้วยังมี cookie ออกมาหนึ่งใบ
หัวข้อที่มีชื่อว่า “STATELESS แล้วยังมี cookie ออกมาหนึ่งใบ”ทรงที่ประกาศว่าไม่ใช้ session คาย cookie ของ session ออกมาที่คำขอแรกของการเชื่อมต่อ
SessionCreationPolicy.STATELESS อ่านเหมือนคำสั่งห้ามสร้าง session
แต่คำขอที่ probe ใช้หยิบ CSRF token คือ GET /csrf ที่พก Authorization มาด้วย
ซึ่งเป็นคำขอแรกของการเชื่อมต่อใบนั้น ได้ header Set-Cookie กลับมา 1 ใบ
ใบนั้นมีหน้าตาแบบนี้ตรงตัว โดยที่ probe แทนค่าที่สุ่มทุกรอบด้วยคำคงที่
JSESSIONID=<id>; Path=/; HttpOnlyเทียบกับทรง csrf-off ของบทที่ 1 ซึ่งใช้หน้า login คำตอบของ /login ที่นั่น
ก็มี Set-Cookie 1 ใบ และหน้าตาของใบนั้นเท่ากับใบข้างบนทุกตัวอักษร
ทรงหนึ่ง login แล้วได้ cookie ส่วนอีกทรงประกาศว่าไม่ใช้ session แล้วได้ cookie หน้าตาเดียวกัน JSESSIONIDJSESSIONIDcookie ที่ server คายออกมาเพื่อชี้ session ของผู้เรียก · เป็นหน่วยที่นับได้ และคอร์สนี้นับมันเป็นจำนวน ไม่ใช่เป็นอายุ ในคอร์สนี้จึงเป็นของที่นับได้ ไม่ใช่ของที่อนุมานจากค่าที่ตั้งไว้
เหตุผลอ่านมาจากเอกสารของ Spring Security ซึ่งระบุว่าที่เก็บ CSRF token ตั้งต้นคือ
HttpSessionCsrfTokenRepositoryHttpSessionCsrfTokenRepositoryที่เก็บ CSRF token ตั้งต้นของ Spring Security ซึ่งวางค่านั้นไว้ใน HttpSession · ค่าที่ผู้เรียกแนบมาจึงถูกเทียบกับค่าที่ session ใบนั้นถืออยู่ ไม่ใช่เทียบกับตัวตนของผู้ใช้ และมันเก็บค่านั้นไว้ใน HttpSession
ค่าที่ตั้งไว้ห้ามส่วนของ session สร้างเอง แต่ไม่ได้ห้ามส่วนอื่นสร้าง
ค่าที่ถูกต้อง ยิงโดยไม่พก cookie ใบนั้น
หัวข้อที่มีชื่อว่า “ค่าที่ถูกต้อง ยิงโดยไม่พก cookie ใบนั้น”cookie ที่ทรงนี้ไม่ควรมี กลายเป็นของที่ขาดไม่ได้
probe หยิบ CSRF token มาจาก /csrf ในการเชื่อมต่อที่ถือ cookie ใบนั้นอยู่ แล้วเอาค่าเดิม
ไปยิง POST /visits จากการเชื่อมต่อใหม่ที่พกแค่ Authorization ไม่พก cookie
ผลที่ได้คือ 401 และแถวไม่ลง เท่ากับคำขอที่ไม่มี CSRF token มาเลยทุกช่อง
ค่านั้นเพียงลำพังไม่ได้แปลว่าอะไร สิ่งที่ server เทียบมันด้วยอยู่ในที่ที่ cookie ใบนั้นชี้ไป
ทรงนี้จึงพึ่ง session อยู่ ทั้งที่บรรทัดในตัวมันเองประกาศว่า STATELESS
ผลทั้งหมดในบทนี้วัดบน Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25 รุ่นอื่นเป็นคนละการวัด และบทนี้ไม่ได้อ้างถึงมัน
401 403 และ cookie ใบนั้นเป็นผลของทรงที่ probe สร้างขึ้น ไม่ใช่กฎของ Spring
ที่ยกไปใช้กับ config ชุดอื่นได้เลย
คำถามที่บทนี้ทิ้งไว้
หัวข้อที่มีชื่อว่า “คำถามที่บทนี้ทิ้งไว้”รู้แล้วว่าอะไรเลือกรหัส ยังไม่รู้ว่ากลไกนี้ตรวจคำขอใบไหนบ้าง
สามบทแรกวัดกลไกกันปลอมด้วยคำขอ POST ทั้งหมด และกลไกนั้นก็ตรวจทุกใบ
ทั้งใบที่ผ่านและใบที่ไม่ผ่าน วัดออกมาเป็นรหัสกับจำนวนแถวได้ครบ
แต่ยังไม่มีข้อไหนบอกว่ากลไกนี้ตรวจคำขอทุกใบหรือไม่ ถ้ามันเลือกตรวจเฉพาะบางใบ คำถามถัดไปคือใบที่มันปล่อยผ่านเปลี่ยนข้อมูลได้หรือเปล่า
บทที่ 4 ยิงคำขอที่สเปกจัดว่าไม่เปลี่ยนสถานะ ใส่ทรงที่เปิดการกันปลอมไว้เต็มตัว แล้วนับแถวดู
-
Spring Security — Architecture (เข้าถึง 2026-09-05) — ที่มาของลำดับในสาย ซึ่งพิมพ์
SecurityContextHolderFilterไว้หลังDisableEncodeUrlFilterกับWebAsyncManagerIntegrationFilter· วางHeaderWriterFilterคั่นระหว่างมันกับCsrfFilter· และวางBasicAuthenticationFilterไว้หลังCsrfFilter -
Spring Security — Cross Site Request Forgery (CSRF) (เข้าถึง 2026-09-05) — ที่มาของข้อที่ว่าที่เก็บ CSRF token ตั้งต้นเก็บค่าไว้ใน
HttpSession -
RFC 9110 — 401 Unauthorized (เข้าถึง 2026-09-05) — ความหมายของ
401เทียบกับ403· บทนี้อ้างมันเป็นสเปกเท่านั้น ไม่ได้วัดมัน -
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/clinic-cross-origin/probe.pyข้อ D1 ถึง D4 และข้อ H1 · ต้องสั่งscripts/clinic-cross-origin/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55437
เช็กความเข้าใจ — บทที่ 3
ข้อ 1 / 3ทรง `csrf-on-basic` ยิง `POST /visits` โดยไม่มี CSRF token แต่พก `Authorization` ที่ถูกต้องมาด้วย ได้อะไรกลับมา