ข้าม​ไป​ยัง​เนื้อหา

คำขอ​ที่​ถูก​ปฏิเสธ​ด้วย​เหตุผล​เดียวกัน ได้ 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 ของ​สอง​ทรง​ต่าง​กัน​สี่​จุด และ​ทุก​อย่าง​ที่​เหลือ​เหมือน​กัน​ทุก byte

SecConfig.kt ของ​ทรง csrf-on-basic
package clinic.csrf
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.provisioning.InMemoryUserDetailsManager
import org.springframework.security.web.SecurityFilterChain
@Configuration
class 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 · ยืนยัน​ตัว​ด้วย session403+0
ทรง csrf-on-basic · ยืนยัน​ตัว​ด้วย Basic401+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 ผู้​เรียก​จึง​ยัง​เป็น​คน​ไม่มี​ชื่อ ณ จุด​ที่​คำ​ตอบ​ถูก​เขียน

เอกสาร​ของ 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 ไม่​ได้​วัด​ข้อ​นั้น และ​การ​เดา​ชื่อ​มา​ใส่​ก็​ไม่​ได้​ทำให้​มัน​จริง​ขึ้น

ทรง​ที่​ประกาศ​ว่า​ไม่​ใช้ session คาย cookie ของ session ออก​มา​ที่​คำขอ​แรก​ของ​การ​เชื่อม​ต่อ

SessionCreationPolicy.STATELESS อ่าน​เหมือน​คำ​สั่ง​ห้าม​สร้าง session แต่​คำขอ​ที่ probe ใช้​หยิบ CSRF token คือ GET /csrf ที่​พก Authorization มา​ด้วย ซึ่ง​เป็น​คำขอ​แรก​ของ​การ​เชื่อม​ต่อ​ใบ​นั้น ได้ header Set-Cookie กลับ​มา 1 ใบ

ใบ​นั้น​มีหน้า​ตา​แบบ​นี้​ตรง​ตัว โดยที่ probe แทน​ค่าที่​สุ่ม​ทุกร​อบ​ด้วย​คำ​คงที่

Set-Cookie ที่​คำ​ตอบ​ของ GET /csrf ใน​ทรง csrf-on-basic
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 ที่​ทรง​นี้​ไม่​ควร​มี กลาย​เป็น​ของ​ที่​ขาด​ไม่​ได้

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` ที่ถูกต้องมาด้วย ได้อะไรกลับมา