Security Uplift · F — Monitor Log (request-log capture)
ติดตั้งการเก็บ request/response ส่งเข้า log service: 2 บรรทัด pipeline คนละที่ transport จาก config และเส้นไหนไม่ถูกเก็บ
อัปเดต: 2026-09-07
📘 แนวคิดเบื้องหลังกลไกนี้ → Monitor Log 101 📕 สิ่งที่พังได้ในแต่ละขั้น → กับดัก F 📗 ภาพรวมทั้ง 6 กลไก · กฎ opt-in · การเปิดเป็นราย env → ติดตั้งใน service ของคุณ
F. Monitor Log — request-log capture
เก็บ request/response ของ service ใส่คิวในหน่วยความจำ แล้วมี background service ทยอยส่งออกทาง transport
🔴 F เลือกราย endpoint ด้วย RequestLog:Coverage (OptIn ค่าตั้งต้น) — แปะ [RequireRequestLog] เส้นไหน เก็บเส้นนั้น · ตั้ง RequireAll = เก็บทุก request แล้วคัดออกด้วย [SkipRequestLog]
🔴 [SkipRequestLog] กับ ExcludedRouteTemplates เป็นคนละแกน อย่าใช้แทนกัน
[SkipRequestLog] | ExcludedRouteTemplates | |
|---|---|---|
| มี record ไหม | ไม่มีเลย | มี — route, method, status, duration, correlation id, oid, ขนาด body |
| เก็บ body ไหม | ไม่มี record ก็ไม่มี body | ไม่เก็บ เก็บแต่ metadata |
| ใช้เมื่อ | การมีอยู่ของ call นั้นเองคือสิ่งที่ห้ามบันทึก | call ต้องถูกนับ แต่ payload ห้ามเก็บ |
ขาออกก็ถูกเก็บ แต่มี 4 เงื่อนไขที่ทำให้ body ขาออกหายไปเงียบ ๆ
RequestLogMessage มีทั้ง RequestBody และ ResponseBody (RequestLogEnvelopeMiddleware.cs:136,149) · ขาออกใช้วิธี tee ไม่ใช่ buffer ⇒ client ได้ response ตามปกติ ไม่ต้องรอให้เก็บเสร็จ
แต่ ShouldRetainResponse (RequestLogEnvelopeMiddleware.cs:288-306) ตัดสินตอนเขียนไบต์แรก และ คืน false 4 กรณี — ทั้ง 4 กรณีนี้ record ยังมีอยู่ (route/method/status/duration/ขนาด) แต่ ไม่มีไบต์ของ body ขาออก:
| เงื่อนไข | ผล |
|---|---|
Content-Type ไม่ใช่ JSON | ไม่เก็บ — allow-list เป็น application/json และ +json เท่านั้น (ไฟล์ดาวน์โหลด, multipart, text, XML ไม่เข้าข่าย) |
endpoint ไม่อยู่ใน scope (Coverage) | ไม่เก็บ — และไม่ถือไบต์ไว้ในหน่วยความจำด้วย |
🔴 endpoint แปะ [EncryptedWholeBodyResponse] | ไม่เก็บเลย — สิ่งที่ไหลผ่าน stream คือ ciphertext ซึ่งอ่านย้อนไม่ได้อยู่แล้ว |
route อยู่ใน ExcludedRouteTemplates | ไม่เก็บ |
🔴 ผลที่กระทบการวางแผนพิสูจน์โดยตรง: endpoint ที่เข้ารหัสทั้ง body ขาออกจะ ไม่มี response body ให้ investigate เลย · ถ้าเป้าหมายคือ “เปิดดูย้อนหลังว่า response จริงคืออะไร” ต้องเลือก endpoint ที่ ไม่ได้ แปะ [EncryptedWholeBodyResponse] — เช่นเส้นที่ใช้ field-level ([EncryptedField]) ซึ่งเก็บได้ปกติโดยค่าที่ mark ถูก mask เป็น ***
ขาเข้าเก็บที่ RequestLogBodyCaptureMiddleware ซึ่ง mount ลึกกว่า — อ่าน body หลัง authentication และหลังการถอดรหัส payload ⇒ สิ่งที่เก็บคือ body ที่ action bind จริง ไม่ใช่ ciphertext
🔴 ไม่ต้องเขียน IRequestLogTransport เอง — AzureServiceBusRequestLogTransport ถูก register ให้เมื่อ RequestLog:ServiceBus ระบุ topic ครบ (เขียนเองก็ยังได้ ตัวที่ service register ชนะ) · ไม่ตั้ง ServiceBus และไม่ register เอง = ไม่มี transport เปิด flag แล้วบันทึกถูกทิ้ง เหลือ log เตือน 1 บรรทัด ไม่ throw ไม่พัง request
ทั้งเส้นทำงานยังไง
sequenceDiagram
autonumber
participant U as Caller
participant CAP as UseRequestLogCapture (ก่อนสุด)
participant MID as auth · signature · decryption
participant BODY as UseRequestLogBodyCapture (ท้ายสุด)
participant H as Handler
participant Q as คิวในหน่วยความจำ
participant SB as Service Bus topic
participant L as Log service
U->>CAP: request
alt RequestLog Enabled = false
CAP->>MID: ปล่อยผ่าน ไม่มี record ไม่มี buffering
else Enabled = true
CAP->>CAP: EnableBuffering ให้ทุก request เพราะยังไม่รู้ว่า endpoint ไหน
CAP->>CAP: ห่อ response stream ด้วย tee ที่ตัดสินตอนเขียนไบต์แรก
CAP->>MID: ส่งต่อ
MID->>BODY: ผ่านด่านและถอดรหัสเรียบร้อยแล้ว
BODY->>BODY: อ่าน body ที่ action จะ bind จริง แล้วแทนค่าที่แปะ EncryptedField ด้วยดอกจัน
BODY->>H: เข้า handler
H-->>U: response ไหลกลับหาผู้ใช้ทันที ไม่ต้องรอการบันทึก
CAP->>CAP: endpoint นอก Coverage — ไม่มี record เลย ไม่ใช่ record ที่ไม่มี body
CAP->>Q: หย่อน record ลงคิว
alt คิวเต็ม
Q--xCAP: drop แล้วนับไว้ ไม่หน่วง request
else มีที่ว่าง
Q->>SB: ตัวส่งเบื้องหลังทยอย publish ผ่าน AmqpWebSockets 443
SB->>L: log service consume ไปเก็บลงตาราง
end
end
Note over CAP,Q: 4 กรณีที่มี record แต่ไม่มี body ขาออก — content-type ไม่ใช่ JSON ·<br/>endpoint นอก scope · endpoint แปะ EncryptedWholeBodyResponse · route อยู่ใน ExcludedRouteTemplates
ติดตั้ง 4 ขั้น
1. DI
services.AddRequestLogCapture(configuration);
register ให้: options (bind จาก section RequestLog) กับ queue เสมอ · background sender · MVC convention ที่อ่าน [EncryptedField] มาใช้ mask · และ AzureServiceBusRequestLogTransport เมื่อ ServiceBus ครบ — สามอย่างหลังเฉพาะเมื่อ Enabled=true
register options กับ queue ให้เสมอแม้ปิด flag ⇒ ใส่ Use… ไว้ได้โดยไม่พัง แต่กลับกัน ใส่ Use… โดยไม่เรียก AddRequestLogCapture = DI พังตอน request แรก
2. transport — ตั้ง config พอ ไม่ต้องเขียนโค้ด
"RequestLog": {
"ServiceBus": {
// ต้องมี TopicName และอย่างน้อยหนึ่งในสองตัวล่าง ครบทั้งคู่ = ConnectionString ชนะ
"TopicName": "request-log",
// 🔴 เป็น secret ต้องมาจาก Key Vault เป็น env var RequestLog__ServiceBus__ConnectionString
// ห้ามใส่ค่าจริงลง appsettings.json
"ConnectionString": "",
// ทางที่ไม่ต้องมี secret เลย — ใช้คู่กับ managed identity ของ pod
"FullyQualifiedNamespace": "sua-sb-nonprd.servicebus.windows.net",
// ไม่ตั้ง = AmqpWebSockets (443) ซึ่งเป็นค่าที่ใช้งานได้บน cluster
// ตั้งเป็น "AmqpTcp" (5671) เฉพาะ env ที่เปิด port นั้นและมีเหตุผลจะใช้
"Transport": "AmqpWebSockets"
}
}
🔴 Transport ต้องเป็น AmqpWebSockets (443) บน cluster นี้ — egress ออกได้เฉพาะ 443 · AmqpTcp (5671) ถูกบล็อก
publish จะล้มทุกใบด้วย SocketException (110): Connection timed out แล้วบันทึกถูกทิ้งโดยที่แอปยัง boot ปกติ ·
เป็น default อยู่แล้ว ไม่ต้องตั้งอะไรเพิ่ม 📕 กับดัก F
ครบเมื่อไหร่ lib register transport ให้เอง · ไม่ครบ = ไม่ register อะไร (สถานะที่รองรับ ไม่ error)
อยากใช้ transport ของตัวเองแทน — register IRequestLogTransport เอง จะก่อนหรือหลัง AddRequestLogCapture ก็ได้ ตัวของ service ชนะทั้งสองลำดับ
services.AddSingleton<IRequestLogTransport, MyOwnTransport>();
IRequestLogBlobStore ยังต้องเขียนเอง แต่เป็น optional — ไม่ register = body ที่ใหญ่เกิน MaxInlineBodyBytes ถูกบันทึกไว้แค่ขนาด ไม่เก็บเนื้อ (ไม่ error)
3. Pipeline — 2 บรรทัด คนละที่
app.Use(async (ctx, next) => { ctx.Request.EnableBuffering(); await next(); }); // ของเดิมที่ A ต้องใช้ — ห้ามถอด
app.UseRequestLogCapture(); // ก่อนสุด — ก่อน exception handler / authentication / output cache
// …
app.UseAuthentication();
app.UseRedisUserInfo();
app.UseAuthorization();
app.UseClientSignatureVerification();
app.UsePayloadFieldDecryption();
// …
app.UseRequestLogBodyCapture(); // ท้ายสุด — หลัง routing / auth / การถอดรหัสทุกชั้น
UseRequestLogCapture()ต้องอยู่ก่อนสุด เพื่อให้ request ที่ถูก exception handler หรือ auth ตอบไปเลยยังถูกบันทึกUseRequestLogBodyCapture()ต้องอยู่ท้าย เพื่อให้ body ที่อ่านได้เป็นตัวเดียวกับที่ action จะ bind (ถอดรหัสแล้ว)UseRequestLogCapture()เรียกEnableBuffering(RequestBufferThresholdBytes)ให้เอง เฉพาะตอนEnabled=true⇒ ห้ามพึ่งบรรทัดนี้แทนEnableBufferingของ A ปิด flag เมื่อไหร่ A พังทันที
🔴 EnableBuffering สองตัวชนกัน — ตัวที่รันก่อนชนะ และมันทำให้ RequestBufferThresholdBytes เป็น key ตาย
EnableBuffering ของ ASP.NET Core ทำงานเฉพาะเมื่อ Request.Body.CanSeek == false ⇒ ตัวที่รันทีหลังเป็น no-op เงียบ ๆ ไม่มี error ไม่มี log · service ส่วนใหญ่มี EnableBuffering() เปล่า ๆ (ใช้ threshold ตั้งต้นของ framework) อยู่หัว pipeline อยู่แล้วเพราะ A บังคับ ⇒ วาง UseRequestLogCapture() ต่อจากมัน = RequestLog:RequestBufferThresholdBytes ตั้งเท่าไหร่ก็ไม่มีผล
ทางเลือกมี 2 ทาง เลือกอย่างใดอย่างหนึ่ง อย่าปล่อยให้คิดว่าตั้งแล้วมีผล
- ยอมรับ — ไม่ต้องตั้ง
RequestBufferThresholdBytesเลย รู้ว่าใช้ค่าตั้งต้นของ framework · นี่คือทางที่ปลอดภัยและเป็นทางที่ service แรกที่ apply ใช้จริง - จะให้ค่ามีผลจริง — ต้องแก้
EnableBuffering()เดิมให้รับ threshold เดียวกัน หรือย้ายUseRequestLogCapture()ขึ้นไปก่อนมัน · ทั้งสองทางแตะบรรทัดที่ A พึ่งอยู่ ⇒ ต้องทดสอบ A ซ้ำทั้งชุด ห้ามถอดEnableBuffering()เดิมทิ้งเด็ดขาด
4. config
"RequestLog": {
// false = ไม่เก็บอะไรเลย ทั้งสอง middleware ปล่อยผ่าน (ค่าตั้งต้น)
"Enabled": false,
// เก็บเส้นไหน
// "OptIn" = เฉพาะ endpoint ที่แปะ [RequireRequestLog] (ค่าตั้งต้น)
// "RequireAll" = ทุก endpoint ยกเว้นที่แปะ [SkipRequestLog]
"Coverage": "OptIn",
// ปลายทางที่ส่ง record ออกไป — ครบเมื่อไหร่ lib register transport ให้เอง
// ไม่ครบและไม่ register IRequestLogTransport เอง = บันทึกถูกทิ้ง เหลือ log เตือน 1 บรรทัด
"ServiceBus": {
"TopicName": "",
"ConnectionString": "", // 🔴 secret — ต้องมาจาก KV เป็น RequestLog__ServiceBus__ConnectionString
"FullyQualifiedNamespace": "", // ทางที่ไม่ต้องมี secret ใช้คู่ managed identity ของ pod
"Transport": "AmqpWebSockets" // 10.30.0+ · เป็น default อยู่แล้ว · "AmqpTcp" เฉพาะ env ที่เปิด 5671
},
// ชื่อที่ติดไปกับทุก record — ว่างไว้ = ใช้ ServiceIdentity:ServiceName · ไม่มีทั้งคู่ = "Unknown"
"SourceService": "",
// route ที่ไม่เก็บ body (เก็บแต่ metadata) — เทียบแบบ prefix ไม่สนตัวพิมพ์
// ค่าที่ใส่ตรงนี้ "เพิ่มเข้าไป" ในรายการที่ lib บังคับไว้ ลบของ lib ไม่ได้
"ExcludedRouteTemplates": [],
// path เพิ่มเติมที่ต้อง mask นอกเหนือจากที่ [EncryptedField] บอกไว้แล้ว
// เขียนจาก root ของ body เช่น "$.customer.taxId" · array ไม่นับเป็น segment ⇒ "$.items.taxId" ครอบทุกตัวใน items
"ExtraSensitiveJsonPaths": [],
// เพดานจำนวน byte ของ body หนึ่งก้อนที่ยอมถือไว้ใน memory — เกินแล้วเก็บแต่ขนาด (ค่าตั้งต้น 262144 = 256 KB)
"MaxCapturedBodyBytes": 262144,
// body ที่ใหญ่กว่านี้ไม่เดินทางไปในข้อความ ต้องผ่าน blob store (ค่าตั้งต้น 65536 = 64 KB)
"MaxInlineBodyBytes": 65536,
// threshold ที่ส่งให้ EnableBuffering — ใหญ่กว่านี้ spill ลง temp disk ของ pod (ค่าตั้งต้น 65536 = 64 KB)
"RequestBufferThresholdBytes": 65536,
// จำนวน record ที่รอส่งได้ — เต็มแล้ว record ใหม่ถูก drop และนับไว้ ไม่หน่วง request (ค่าตั้งต้น 1024)
"QueueCapacity": 1024
}
route ที่ lib ตัดออกให้เสมอ ลบไม่ได้ (ถ้าเก็บ จะเป็นการ copy body ของ request อื่นซ้อนกันไปเรื่อย ๆ)
api/log-service/v{version:apiVersion}/request-logs
api/log-service/v{version:apiVersion}/audit-logs
เส้นไหนไม่มี record / ไม่ถูกเก็บ body บ้าง
ไม่มี record เลย
- endpoint นอก scope ของ
Coverage(ไม่แปะ[RequireRequestLog]ตอนOptIn· แปะ[SkipRequestLog]ตอนRequireAll)
มี record แต่ไม่มี body
- content-type ที่ไม่รองรับ
- body อ่านซ้ำไม่ได้ (
EnableBufferingไม่ได้รัน — เช่นUseRequestLogCapture()ไม่ได้ mount) - action ที่แปะ
[EncryptedWholeBody]/[EncryptedWholeBodyResponse](D) — มาถึงชั้นนี้ถอดเป็น plaintext แล้วและไม่มี field map ให้ mask ⇒ ข้ามทั้งก้อนโดยตั้งใจ - route ที่ตรงกับ
ExcludedRouteTemplatesหรือรายการบังคับข้างบน
⚠️ ต้นทุนที่ยังเหลืออยู่แม้ OptIn — EnableBuffering ยังรันให้ ทุก request ตราบใดที่ Enabled=true เพราะ middleware ตัวหน้าอยู่ก่อน routing ตอนนั้นยังไม่รู้ว่า endpoint ไหน · เส้นนอก scope ไม่มี record และไม่ถือ byte ของ body ไว้เลย
เช็คว่า F ทำงาน
| เช็ค | ผลที่ถูก |
|---|---|
Enabled=false | ทั้งสอง middleware ปล่อยผ่าน ไม่มี record ไม่มี buffering |
Enabled=true Coverage=OptIn · เส้นที่ไม่ได้แปะ [RequireRequestLog] | ไม่มี record เลย — ไม่ใช่ record ที่ไม่มี body |
Enabled=true · ไม่ตั้ง ServiceBus:TopicName และไม่ register IRequestLogTransport เอง | request ยังปกติทุกเส้น · มี log Capture is enabled but no IRequestLogTransport is registered 1 บรรทัด แล้วบันทึกถูกทิ้ง |
Enabled=true + ServiceBus ครบ | record ของเส้นใน scope ถูกส่งเข้า topic |
Enabled=true · register transport ของตัวเองด้วย | ตัวของ service ชนะ ไม่ว่า register ก่อนหรือหลัง AddRequestLogCapture |
| namespace ของ Service Bus ต่อไม่ได้ | แอปยัง boot ปกติ — client เป็น lazy ไม่เปิด connection ตอนสร้าง · ความล้มเหลวไปโผล่ตอน publish และถูก log ทิ้งไว้ ไม่กระทบ request |
field ที่แปะ [EncryptedField] | ถูก mask ใน record |
body > MaxInlineBodyBytes แต่ไม่มี blob store | record เก็บขนาด ไม่มีเนื้อ |
| ยิงถี่จนคิวเต็ม | record ใหม่ถูก drop และนับ · request ไม่ช้าลง |
ใส่ UseRequestLogCapture() โดยไม่เรียก AddRequestLogCapture() | พังตอน request แรก (DI resolve ไม่ได้) |
📕 กับดัก F —
CoverageกับExcludedRouteTemplatesคนละแกน · transport หาย = เงียบ · ลำดับของEnableBufferingสองตัว