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

ตอน​ที่ server เป็น​คน​ปฏิเสธ 403 พร้อม​ข้อความ Invalid CORS request และ​แถว​ไม่​ลง

สอง​บท​ก่อน​หน้า​วัด​ทรง​ที่​ไม่​เคย​ตั้ง​ค่า CORS ไว้​เลย server ใน​ทรง​นั้น​ไม่​ได้​ปฏิเสธ​ใคร คน​ที่​ปฏิเสธ​คือ​เบราว์เซอร์ และ​มัน​ปฏิเสธ​หลัง​จาก​คำขอ​เดิน​ทาง​ไป​ถึง​แล้ว

บท​นี้​เพิ่ม​การ​ตั้ง​ค่า CORS เข้าไป​หนึ่ง​ชุด แล้ว​ยิง​คำขอ​ข้าม​ต้นทาง​ใบ​เดิม​อีก​ครั้ง คำถาม​มี​สอง​ข้อ คือ​ใคร​เป็น​คน​ปฏิเสธ และ​แถว​ลง​หรือ​ไม่

📦 ทรง​ที่​บท​นี้​วัด · cors-none · cors-configured · cors-bean-only

ทั้ง​สาม​ทรง​มา​จาก probe ตัว​เดียว​กับ​บท​ก่อนๆ สั่ง scripts/clinic-cross-origin/up.sh แล้ว​ตาม​ด้วย python3 scripts/clinic-cross-origin/probe.py เพื่อ​วัด​เอง

ทุก​คำขอ​ใน​บท​นี้​ยิง​ด้วย HTTP client ของ Python ไม่มี​เบราว์เซอร์​อยู่​ใน​การ​วัด​สัก​ใบ​เดียว ตัวเลข​ทั้งหมด​จึง​เป็น​ข้อ​อ้าง​เรื่อง​สิ่ง​ที่ server ทำ

ทรง cors-configured ปฏิเสธ​คำขอ​ข้าม​ต้นทาง​ที่​ไม่​อนุญาต ด้วย​รหัส​ของ​ตัว​เอง​และ body ของ​ตัว​เอง

ทรง​นี้​ตั้ง​กฎ​ไว้​ว่า​อนุญาต​ต้นทาง​เดียว​คือ https://allowed.example คำขอ​ทุก​ใบ​ใน​ตาราง​นี้​พก cookie ของ session ที่ login สำเร็จ​แล้ว ต่าง​กัน​ที่ header Origin

POST /visits ที่​ยิง​ใส่​ทรง cors-configuredรหัสแถว​ใน csrf_visit
Origin: https://evil.example403+0
Origin: https://allowed.example200+1

body ของ 403 ใบ​นั้น​ไม่ใช่ JSON ของ error page มัน​เป็น​ข้อความ​เปล่า​ว่า Invalid CORS request ซึ่ง probe อ่าน​ออก​มา​จาก​คำ​ตอบ​ตรง​ตัว บท​นี้​ไม่​อ้าง​ว่า component ตัว​ไหน​เขียน​ข้อความ​นั้น​ลง​ไป เพราะ probe วัด​แค่​ตัว​อักษร​ที่​ออก​มา ไม่​ได้​วัด​ว่า​ใคร​เขียน

นี่​คือ​การ​พลิก​ของ​บท​นี้ บรีฟ​ตั้ง​ต้น​ของ​คอร์ส​นี้​เขียน​ไว้​เอง​ว่า CORS เป็น​เรื่อง​ของ​เบราว์เซอร์ และ server ไม่​เกี่ยว ที่​วัด​ได้​คือ​พอ​ตั้ง​ค่า​มัน server กลาย​เป็น​คน​ปฏิเสธ​เอง

แถว​ไม่​ลง​ด้วย นั่น​แปล​ว่า​คำขอ​ถูก​ตัด​จบ​ก่อน​ถึง controller ไม่ใช่​ถูก​เขียน​แล้ว​ค่อย​ถูก​ปกปิด​คำ​ตอบ

ยิง​กลาย​พันธุ์​ใส่​บรรทัด http.cors { } แล้ว​ผล​เท่า​เดิม​ทุก​ช่อง สวิตช์​จริง​อยู่​ที่​อื่น

รอบ​ที่​ยิง​กลาย​พันธุ์​ใส่​ทรง cors-configured เอา​บรรทัด http.cors { } ออก​จาก SecConfig.kt โดย​หวัง​ว่า​ตัวเลข​จะ​พลิก​กลับ ผล​ที่​ได้​คือ​ไม่มี​ตัวเลข​ไหน​ขยับ​เลย​สัก​ตัว

การก​ลาย​พันธุ์​ที่​ไม่​ลง​แบบ​นี้​อ่าน​เหมือน​รู​ของ​ด่าน แต่​มัน​ไม่ใช่ ทรง​ที่​เหลือ​จาก​การ​ยิง​รอบ​นั้น ถูก​เก็บ​ไว้​เป็น​ทรง​ที่​สิบ​ของ probe ชื่อ cors-bean-only

SecConfig.kt ของ​ทรง cors-bean-only · เท่ากับ​ของ​ทรง cors-none ทุก byte
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.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.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(),
)
}

probe ตรวจ​ข้อ​นี้​เป็น​ข้อ​อ้าง​ของ​ตัว​เอง มัน​เทียบ​สอง​รูป​แล้ว​รายงาน​ว่า SecConfig.kt ของ cors-none กับ​ของ cors-bean-only เท่า​กัน​ทุก byte และ​ไม่มี​บรรทัด http.cors ใน​ทั้ง​คู่

ความ​ต่าง​ทั้งหมด​คือ cors-bean-only มี file เพิ่ม​มา​หนึ่ง file คือ app/src/main/kotlin/clinic/csrf/CorsConfig.kt ส่วน cors-none ไม่มี

CorsConfig.kt — file เดียว​ที่ cors-bean-only มี​เพิ่ม
package clinic.csrf
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
@Configuration
class CorsConfig {
@Bean
fun corsConfigurationSource(): CorsConfigurationSource {
val c = CorsConfiguration()
c.allowedOrigins = listOf("https://allowed.example")
c.allowedMethods = listOf("GET", "POST", "OPTIONS")
c.allowedHeaders = listOf("*")
c.allowCredentials = true
val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", c)
return source
}
}

สอง​ทรง​ที่​ประกาศ​เหมือน​กัน แต่​ให้​ผล​ตรง​ข้าม​กัน

หัวข้อ​ที่​มีชื่อ​ว่า “สอง​ทรง​ที่​ประกาศ​เหมือน​กัน แต่​ให้​ผล​ตรง​ข้าม​กัน”

SecConfig.kt ของ​สอง​ทรง​นี้​เท่า​กัน​ทุก byte แต่​คำขอ​ใบ​เดียวกัน​ได้​คนละ​คำ​ตัดสิน

ทรง cors-bean-only ยิง POST /visits จาก https://evil.example ได้ 403 แถว​เพิ่ม 0 แถว และ body เป็น Invalid CORS request ซึ่ง​เท่ากับ​ผล​ของ​ทรง cors-configured ทุก​ช่อง

ทรง cors-none ที่​มี SecurityFilterChainSecurityFilterChainbean ที่​ประกาศ​ว่า​คำขอ​เดิน​ผ่าน filter ตัว​ไหน​บ้าง ตาม​ลำดับ​ใด · สิ่ง​ที่​มัน​ประกาศ​ไม่​ได้​บอก​ครบ​ว่า​สาย​นั้น​มี filter ตัว​ใด​บ้าง เพราะ bean ตัว​อื่น​เติม filter เข้า​สาย​เดียวกัน​ได้​ด้วย เดียวกัน​ทุก byte รับคำ​ขอ​ใบ​เดียวกัน​นั้น​ไว้ และ​เขียน​แถว​ลง​ตาราง ผล​ของ​มัน​อยู่​ใน​บท​ที่ 5

ทรงมี​บรรทัด http.corsมี bean CorsConfigurationSourceคำขอ​ข้าม​ต้นทาง​ที่​ไม่​อนุญาต
cors-noneไม่มีไม่มีรับ​ไว้ · แถว​ลง
cors-bean-onlyไม่มีมี403 · +0 แถว
cors-configuredมีมี403 · +0 แถว

สวิตช์​คือ bean ไม่ใช่​บรรทัด CorsConfigurationSourceCorsConfigurationSourcebean ที่​ถือ​กฎ​ว่า​ต้นทาง​ใด method ใด และ header ใด​ได้​รับ​อนุญาต · มัน​คือ​สวิตช์​จริง วัด​แล้ว​ว่า​ถอด​บรรทัด http.cors ออก​แล้ว​ทุก​ตัวเลข​เท่า​เดิม แต่​ถอด bean ออก​แล้ว​ผล​พลิก ที่​อยู่​ใน context คือ​สิ่ง​ที่​ทำให้​ผล​พลิก และ​บรรทัด​ใน​เมนู HttpSecurity ไม่​ได้​เป็น​ตัว​ตัดสิน​ใน​ทรง​เหล่า​นี้

การก​ลาย​พันธุ์​ที่​ไม่​ลง คือ finding ไม่ใช่​รู​ของ​ด่าน

ทาง​ที่​ง่าย​กว่า​คือ​แก้​ด่าน​ให้​จับ​บรรทัด http.cors ให้​ได้ แล้ว​เดิน​ต่อ ถ้า​ทำ​แบบ​นั้น บท​นี้​จะ​สอน​สาเหตุ​ผิด​ทั้ง​บท และ​ผู้​อ่าน​จะ​ถอด​บรรทัด​ผิด​ตัว​ออก​ไป​ตลอด

ทรง​ที่​สิบ​ของ probe เกิด​จาก​การ​ไม่​แก้​ด่าน แต่​ไป​ถาม​ว่า​ทำไม​การก​ลาย​พันธุ์​ถึง​ไม่​ลง​แทน

คำขอ​สอง​ใบ​ที่​ยิง​ไป​หา​ปลาย​ทาง​เดียวกัน ได้ header ตอบ​กลับ​คนละ​จำนวน​และ​คนละ​ชื่อ

ทั้ง​สอง​ใบ​ยิง​จาก https://allowed.example ซึ่ง​เป็นต้น​ทาง​ที่​ทรง​นี้​อนุญาต และ​ทั้ง​สอง​ใบ​ผ่าน

คำขอ​ที่​ยิง​ใส่​ทรง cors-configuredจำนวน header Access-Control-Allow-*
OPTIONS /visits4 ตัว
POST /visits2 ตัว

ชื่อ​ของ​ทั้ง​สอง​ชุด​วัด​มา​ตรง​ตัว ไม่​ได้​นับ​เอา​จาก​ที่​ประกาศ​ไว้

headerOPTIONSPOSTค่าที่​วัด​ได้
Access-Control-Allow-Origin✅✅https://allowed.example
Access-Control-Allow-Credentials✅✅true
Access-Control-Allow-Methods✅—GET,POST,OPTIONS
Access-Control-Allow-Headers✅—content-type

ชุด​ของ OPTIONS ครอบ​ชุด​ของ POST ไว้​พอดี สอง​ตัว​ที่​เพิ่ม​เข้า​มา​คือ​คำ​ตอบ​ของ​คำถาม ที่​มี​เฉพาะ​ตอน​ถาม คือ method ไหน​ได้ และ header ไหน​ได้

คำขอ OPTIONS ใบ​นั้น​เป็นการ​ถาม​ล้วนๆ และ​แถว​ใน csrf_visit เพิ่ม 0 แถว

OPTIONS ใบ​เดียวกัน​ที่​ไม่มี cookie ติด​มา​ด้วย ได้​รหัส​คนละ​ตัว​ใน​สอง​ทรง และ​เหตุผล​คือ​ตำแหน่ง​ใน​สาย

สเปก Fetch กำหนด​ไว้​ว่า​เบราว์เซอร์​ไม่​แนบ credential ไป​กับ​คำขอ OPTIONS ที่​มัน​ยิง​ถาม​เอง นั่น​เป็น​ข้อ​กำหนด​ของ​สเปก probe จึง​จำลอง​รูป​นั้น​ด้วย​การ​ยิง OPTIONS โดย​ไม่​ใส่ Cookie

OPTIONS /visits ที่​ไม่​พก cookiecors-nonecors-configured
Origin ที่​ประกาศ​มาhttps://allowed.examplehttps://allowed.example
รหัส​ที่​ได้302 ไป​หน้า login200
แถว​ใน csrf_visit+0+0

ตาราง​ใบ​นี้​ยิง​ด้วย​ต้นทาง​ที่​ทรง​นี้​อนุญาต ถ้า​เปลี่ยน​เป็นต้น​ทาง​ของ​ผู้​โจมตี ทรง cors-configured ตอบ 403 ไม่ใช่ 200 ตาม​ที่​หัวข้อ​แรก​ของ​บท​นี้​วัด​ไว้​แล้ว

ทรง cors-none ไม่มี​อะไร​มา​ตอบ​คำขอ​ใบ​นี้​แทน คำขอ​จึง​เดิน​ต่อ​ไป​ถึง​กฎ​การ​อนุญาต ซึ่ง​เห็น​ว่า​ไม่มี​ใคร​ยืนยัน​ตัว แล้ว​ส่ง​ไป​หน้า login

ทรง cors-configured ตอบ 200 ให้​คำขอ​ใบ​เดียวกัน​นั้น ทั้ง​ที่​ยัง​ไม่มี​ใคร​ยืนยัน​ตัว รหัส​ใบ​นี้​คือ​ของ​ที่​วัด​ได้ และ​มัน​แปล​ว่า​มี​ตัว​ตอบ​ยืน​อยู่​ก่อน​กฎ​การ​อนุญาต

เอกสาร​ของ Spring Security อธิบาย​ลำดับ​นั้น​ไว้​เอง มัน​เขียน​ว่า CORS ต้อง​ถูก​จัดการ ก่อน Spring Security เพราะ​คำขอ preflight ไม่​พก cookie มา​ด้วย และ​เขียน​ต่อว่า Spring Security ตั้ง​ค่า CORS ให้​อัตโนมัติ​เฉพาะ​เมื่อ​มี UrlBasedCorsConfigurationSource อยู่​ใน​บริบท ซึ่ง​ตรง​กับ​สวิตช์​ที่​หัวข้อ​ก่อน​หน้า​วัด​ได้

ส่วน​ชื่อ​ของ component ที่​เขียน​รหัส 200 ใบ​นั้น​ลง​ไป บท​นี้​ไม่​อ้าง probe ไม่​ได้​วัด​ข้อ​นั้น

ลำดับ​ใน​สาย​เป็น​ตัว​ตัดสิน ไม่ใช่​ค่า​ใน config สอง​ทรง​นี้​มี​กฎ​การ​อนุญาต​ชุด​เดียวกัน​ทุก​ตัว​อักษร สิ่ง​ที่​ต่าง​คือ​มี​ตัว​มา​ตอบ​ก่อน​ถึง​กฎ​นั้น​หรือ​ไม่

ยิง OPTIONS ใบ​เดียวกัน​ใหม่​โดย​พก cookie ของ session ที่ login แล้ว ทรง cors-none ตอบ 200 และ​ไม่มี header Access-Control-Allow-* ออก​มา​สัก​ตัว มัน​แค่​ไม่​ปฏิเสธ ไม่​ได้​อนุญาต

คำขอ OPTIONS ใบ​เดียวกัน​แยก​ทาง​กัน​ที่​การ​มี​อยู่​ของ bean ไม่ใช่​ที่​บรรทัด​ใน SecConfig

flowchart TB
  Q["OPTIONS /visits · Origin: https://allowed.example · ไม่พก cookie"] --> B{"context มี bean CorsConfigurationSource ไหม"}
  B -->|"cors-none · ไม่มี bean"| N["ไม่มีตัวตอบคำขอนี้ · เดินต่อไปถึงกฎการอนุญาต"]
  N --> N2["302 ไปหน้า login · csrf_visit เพิ่ม 0 แถว"]
  B -->|"cors-configured และ cors-bean-only · มี bean"| C["มีตัวตอบยืนอยู่ก่อนกฎการอนุญาต"]
  C --> C2["200 · Access-Control-Allow-* 4 ตัว · csrf_visit เพิ่ม 0 แถว"]
  C --> C3["ใบเดียวกันที่ประกาศต้นทางซึ่งไม่อนุญาต · 403 · เพิ่ม 0 แถว"]

คำ​บรรยาย​ภาพ: จุด​ที่​สาย​แยก​คือ​การ​มี​อยู่​ของ bean ทรง cors-bean-only ไม่มี​บรรทัด http.cors เลย แต่​เดิน​ทาง​เดียว​กับ cors-configured ทุก​ช่อง

บท​นี้​วัด​สิ่ง​ที่ server ทำ​กับ header ที่​ประกาศ​มา ไม่​ได้​วัด​สิ่ง​ที่​เบราว์เซอร์​ตัดสิน​ใจ​ทำ

403 กับ Invalid CORS request กับ​จำนวน header ทุก​ตัว​ใน​บท​นี้ เป็น​สิ่ง​ที่​เกิด​ขึ้น​จริง และ​อ้าง​จาก probe ได้ที​ละ​ข้อ

ส่วน​ข้อ​ที่​ว่า​เบราว์เซอร์​ยิง OPTIONS ถาม​ก่อน​ใน​กรณี​ใด และ​ไม่​แนบ credential ไป​กับ​คำขอ​นั้น เป็น​ข้อ​กำหนด​ของ Fetch Standard บท​นี้​อ้าง​มัน​เป็น​สเปก ไม่​ได้​วัด​มัน

ตัวเลข​ทุก​ตัว​ผูก​กับ​รุ่น​ที่​ระบุ​ไว้

ผล​ทั้งหมด​ใน​บท​นี้​วัด​บน Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25 รุ่น​อื่น​เป็น​คนละ​การ​วัด และ​บท​นี้​ไม่​ได้​อ้าง​ถึง​มัน

ข้อความ Invalid CORS request และ​รหัส​ที่​คำขอ​ซึ่ง​ยัง​ไม่​ยืนยัน​ตัว​ได้​รับ เป็น​ผล​ของ​ทรง​ที่ probe สร้าง​ขึ้น​บน​รุ่น​เหล่า​นั้น ไม่ใช่​กฎ​ของ Spring ที่​ยก​ไป​ใช้​กับ config รูป​อื่น​ได้​ทันที

รู้​แล้ว​ว่า​ตั้ง​ค่า CORS แล้ว server ปฏิเสธ​เอง ยัง​ไม่รู้​ว่า​มัน​ทับ​กับ​กลไก​ของ​บท​ที่ 1 หรือ​เปล่า

ตอน​นี้​มี​กลไก​สอง​ตัว​ที่​ตอบ 403 ให้​คำขอ​ที่​มัน​ไม่​ชอบ ตัว​หนึ่ง​ถาม​หา​ค่าที่​ผู้​เรียก​ต้อง​แนบ​มา อีก​ตัว​ถาม​ว่า​ต้นทาง​นี้​อยู่​ใน​รายการ​ที่​อนุญาต​ไหม

คำถาม​ที่​เหลือ​คือ​สอง​ตัว​นี้​ทับ​กัน​หรือ​ปิด​คนละ​รู ถ้า​เปิด​ตัว​หนึ่ง​ไว้​แล้ว ยัง​ต้อง​เปิด​อีก​ตัว​หรือ​ไม่ และ​มี​คำขอ​รูป​ไหน​ที่​ตัว​หนึ่ง​ปล่อย​ผ่าน​แต่​อีก​ตัว​ปิด

บท​ที่ 8 เอา​ทั้ง​สอง​ตัว​มา​เปิด​พร้อม​กัน​ใน​ทรง​เดียว แล้ว​นับ​แถว​ที​ละ​คำขอ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้
  • Spring Security — CORS (เข้า​ถึง 2026-09-05) — หน้า​นี้​เขียน​ว่า CORS ต้อง​ถูก​จัดการ​ก่อน Spring Security เพราะ​คำขอ preflight ไม่​พก cookie มา​ด้วย และ​เขียน​ว่า Spring Security ตั้ง​ค่า CORS ให้​อัตโนมัติ​เฉพาะ​เมื่อ​มี UrlBasedCorsConfigurationSource อยู่ · บท​นี้​อ้าง​สอง​ข้อ​นี้ เป็น​เรื่อง​ลำดับ​เท่านั้น · ข้อความ​ที่ body ของ 403 คาย​ออก​มา ไม่​ได้​อยู่​ใน​หน้า​นี้​เลย บท​นี้​จึง​ไม่​อ้าง​ว่า component ตัว​ไหน​เขียน​ข้อความ​นั้น​ลง​ไป

  • Fetch Standard — CORS protocol and credentials (เข้า​ถึง 2026-09-05) — สเปก​ที่​กำหนด​ว่า​คำขอ OPTIONS ที่​เบราว์เซอร์​ยิง​ถาม​เอง ไม่​แนบ credential ไป​ด้วย · บท​นี้​อ้าง​มัน​เป็น​สเปก​เท่านั้น ไม่​ได้​วัด​มัน

  • ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก scripts/clinic-cross-origin/probe.py ข้อ E4 E5 F2 ถึง F9x และ J1 ถึง J4 · ต้อง​สั่ง scripts/clinic-cross-origin/up.sh ก่อน ซึ่ง​ยก container ของ​คอร์ส​นี้​เอง​ขึ้น​ที่ port 55437

เช็กความเข้าใจ — บทที่ 7

ข้อ 1 / 3

ทรง `cors-configured` รับ `POST /visits` ที่ประกาศ `Origin: https://evil.example` มาด้วย ได้อะไรกลับมา