Go 1.27 จำกัดจำนวนค่า Header ก่อน Request กินทรัพยากรเกินจำเป็น
HTTP server ต้องอ่าน header ให้จบก่อนส่ง request ให้ handler ครับ ถ้าผู้ส่งยัด header ชื่อเดิมมาหลายร้อยบรรทัด งานส่วนนี้ก็กิน CPU และหน่วยความจำก่อนตรรกะ rate limit ในแอปจะได้ทำงาน Go 1.27 จึงเพิ่ม http.Server.MaxHeaderValueCount เพื่อให้เรากำหนดเพดานจำนวน ค่า header ที่ยอม parse ได้
WHY: จำกัดขนาดอย่างเดียวยังตอบไม่ครบ
MaxHeaderBytes คุมจำนวนไบต์รวม ส่วน MaxHeaderValueCount คุมจำนวนค่าที่ parser รับ ทั้งสองแก้คนละแกนและควรตั้งร่วมกับ ReadHeaderTimeout ค่าเริ่มต้นของเพดานใหม่นี้คือ http.DefaultMaxHeaderValueCount ซึ่งเท่ากับ 500; ถ้าฟิลด์เป็นศูนย์ server จะใช้ค่านี้
รายละเอียดที่ชวนพลาดคือ header หนึ่งบรรทัดซึ่งมีค่าคั่นด้วย comma นับเป็นหนึ่งค่า แต่ header ชื่อเดิมที่ส่งแยกหลายบรรทัดจะถูกนับหลายค่า เพราะฉะนั้นเพดานนี้ช่วยหยุด request ที่มี field lines มากผิดปกติ แต่ไม่ใช่ตัวตรวจจำนวนสมาชิกเชิงความหมายใน Accept, Forwarded หรือ header เฉพาะของระบบ
ตัวอย่างนี้ตั้งเพดานแบบ explicit แล้วให้ HTTP client ส่ง header ซ้ำผ่าน parser ของ server จริง ถ้าเกินงบ server ต้องตอบ 431 ก่อนถึง handler ส่วน request ที่อยู่ในงบและค่าที่คั่นด้วย comma ต้องยังผ่าน ใช้ httptest.NewTestServer ของ Go 1.27 ซึ่งเชื่อม client/server ผ่าน network ในหน่วยความจำ จึงไม่ต้องขอสิทธิ์เปิดพอร์ตบนเครื่องที่รันทดสอบ
package main
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestHeaderValueBudget(t *testing.T) {
reachedHandler := make(chan struct{}, 1)
server := httptest.NewTestServer(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reachedHandler <- struct{}{}
}))
server.Config.MaxHeaderValueCount = 3
server.Config.ReadHeaderTimeout = time.Second
client := server.Client()
client.Timeout = 2 * time.Second
client.Transport.(*http.Transport).DisableCompression = true
for _, tc := range []struct {
name string
values []string
status int
}{
{"within-budget", []string{"a", "b"}, http.StatusOK},
{"over-budget", []string{"a", "b", "c", "d"}, http.StatusRequestHeaderFieldsTooLarge},
{"comma-separated", []string{"a,b,c,d"}, http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
request, err := http.NewRequest(http.MethodGet, server.URL, nil)
if err != nil {
t.Fatal(err)
}
request.Header.Set("User-Agent", "") // คุมจำนวนค่า header ที่ client เพิ่มเอง
request.Header["X-Trace"] = tc.values
response, err := client.Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != tc.status {
t.Fatalf("status = %d, want %d", response.StatusCode, tc.status)
}
select {
case <-reachedHandler:
if tc.status != http.StatusOK {
t.Fatal("oversized header set reached the handler")
}
default:
if tc.status == http.StatusOK {
t.Fatal("valid request did not reach the handler")
}
}
})
}
}
เลือกเพดานจาก traffic จริง
อย่าคัดลอกเลขต่ำจากตัวอย่างไป production ทันที ระบบ authentication, tracing และ proxy หลายชั้นอาจเพิ่ม header ของตัวเอง เก็บ distribution จาก access log ที่ตัดค่าลับออก แล้วตั้งเพดานเหนือ traffic ปกติพร้อมเผื่อ rollout, canary และ client รุ่นเก่า เมื่อถูกปฏิเสธให้ดู metric ของ 431 แยกตาม ingress ไม่ควร log ค่า Authorization, cookie หรือ token เพื่อวิเคราะห์
หากอยู่หลัง reverse proxy ต้องตั้งขีดจำกัดทั้งสองชั้น เพราะ proxy อาจรวม header ซ้ำเป็นบรรทัดเดียวก่อนส่งต่อ ทำให้จำนวนที่แอป Go เห็นไม่เหมือน request ต้นทาง และยังต้องใช้ MaxHeaderBytes, timeout, connection limit และ rate limit ตามปกติ ฟิลด์ใหม่นี้เป็นด่านเพิ่ม ไม่ใช่เกราะชิ้นเดียว
Recap
MaxHeaderValueCountจำกัดจำนวนค่า header ที่ parser ของ Go ยอมรับ- ตั้งคู่กับ
MaxHeaderBytesและReadHeaderTimeout - header หลายบรรทัดนับหลายค่า แต่ comma-separated ในบรรทัดเดียวมองเป็นหนึ่ง
- วัด traffic จริงก่อนลดจากค่าเริ่มต้น 500 และติดตามอัตรา 431 หลัง rollout