ตอนที่ server เป็นคนปฏิเสธ 403 พร้อมข้อความ Invalid CORS request และแถวไม่ลง
สองบทก่อนหน้าวัดทรงที่ไม่เคยตั้งค่า CORS ไว้เลย server ในทรงนั้นไม่ได้ปฏิเสธใคร คนที่ปฏิเสธคือเบราว์เซอร์ และมันปฏิเสธหลังจากคำขอเดินทางไปถึงแล้ว
บทนี้เพิ่มการตั้งค่า CORS เข้าไปหนึ่งชุด แล้วยิงคำขอข้ามต้นทางใบเดิมอีกครั้ง คำถามมีสองข้อ คือใครเป็นคนปฏิเสธ และแถวลงหรือไม่
ทั้งสามทรงมาจาก probe ตัวเดียวกับบทก่อนๆ สั่ง scripts/clinic-cross-origin/up.sh
แล้วตามด้วย python3 scripts/clinic-cross-origin/probe.py เพื่อวัดเอง
ทุกคำขอในบทนี้ยิงด้วย HTTP client ของ Python ไม่มีเบราว์เซอร์อยู่ในการวัดสักใบเดียว ตัวเลขทั้งหมดจึงเป็นข้ออ้างเรื่องสิ่งที่ server ทำ
server ตอบเอง และบอกเหตุผลมาเป็นข้อความ
หัวข้อที่มีชื่อว่า “server ตอบเอง และบอกเหตุผลมาเป็นข้อความ”ทรง
cors-configuredปฏิเสธคำขอข้ามต้นทางที่ไม่อนุญาต ด้วยรหัสของตัวเองและ body ของตัวเอง
ทรงนี้ตั้งกฎไว้ว่าอนุญาตต้นทางเดียวคือ https://allowed.example
คำขอทุกใบในตารางนี้พก cookie ของ session ที่ login สำเร็จแล้ว ต่างกันที่ header Origin
POST /visits ที่ยิงใส่ทรง cors-configured | รหัส | แถวใน csrf_visit |
|---|---|---|
Origin: https://evil.example | 403 | +0 |
Origin: https://allowed.example | 200 | +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
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(), )}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 ไม่มี
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("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 ไม่ได้เป็นตัวตัดสินในทรงเหล่านี้
ทางที่ง่ายกว่าคือแก้ด่านให้จับบรรทัด http.cors ให้ได้ แล้วเดินต่อ
ถ้าทำแบบนั้น บทนี้จะสอนสาเหตุผิดทั้งบท และผู้อ่านจะถอดบรรทัดผิดตัวออกไปตลอด
ทรงที่สิบของ probe เกิดจากการไม่แก้ด่าน แต่ไปถามว่าทำไมการกลายพันธุ์ถึงไม่ลงแทน
preflight กับคำขอจริง ไม่ได้รับ header ชุดเดียวกัน
หัวข้อที่มีชื่อว่า “preflight กับคำขอจริง ไม่ได้รับ header ชุดเดียวกัน”คำขอสองใบที่ยิงไปหาปลายทางเดียวกัน ได้ header ตอบกลับคนละจำนวนและคนละชื่อ
ทั้งสองใบยิงจาก https://allowed.example ซึ่งเป็นต้นทางที่ทรงนี้อนุญาต และทั้งสองใบผ่าน
คำขอที่ยิงใส่ทรง cors-configured | จำนวน header Access-Control-Allow-* |
|---|---|
OPTIONS /visits | 4 ตัว |
POST /visits | 2 ตัว |
ชื่อของทั้งสองชุดวัดมาตรงตัว ไม่ได้นับเอาจากที่ประกาศไว้
| header | OPTIONS | POST | ค่าที่วัดได้ |
|---|---|---|---|
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 แถว
คำขอที่ไม่พก cookie เดินคนละทางในสองทรง
หัวข้อที่มีชื่อว่า “คำขอที่ไม่พก cookie เดินคนละทางในสองทรง”
OPTIONSใบเดียวกันที่ไม่มี cookie ติดมาด้วย ได้รหัสคนละตัวในสองทรง และเหตุผลคือตำแหน่งในสาย
สเปก Fetch กำหนดไว้ว่าเบราว์เซอร์ไม่แนบ credential ไปกับคำขอ OPTIONS ที่มันยิงถามเอง
นั่นเป็นข้อกำหนดของสเปก probe จึงจำลองรูปนั้นด้วยการยิง OPTIONS โดยไม่ใส่ Cookie
OPTIONS /visits ที่ไม่พก cookie | cors-none | cors-configured |
|---|---|---|
Origin ที่ประกาศมา | https://allowed.example | https://allowed.example |
| รหัสที่ได้ | 302 ไปหน้า login | 200 |
แถวใน 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ข้อE4E5F2ถึงF9xและJ1ถึงJ4· ต้องสั่งscripts/clinic-cross-origin/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55437
เช็กความเข้าใจ — บทที่ 7
ข้อ 1 / 3ทรง `cors-configured` รับ `POST /visits` ที่ประกาศ `Origin: https://evil.example` มาด้วย ได้อะไรกลับมา