Private Docs

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 ทาง เลือกอย่างใดอย่างหนึ่ง อย่าปล่อยให้คิดว่าตั้งแล้วมีผล

  1. ยอมรับ — ไม่ต้องตั้ง RequestBufferThresholdBytes เลย รู้ว่าใช้ค่าตั้งต้นของ framework · นี่คือทางที่ปลอดภัยและเป็นทางที่ service แรกที่ apply ใช้จริง
  2. จะให้ค่ามีผลจริง — ต้องแก้ 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 หรือรายการบังคับข้างบน

⚠️ ต้นทุนที่ยังเหลืออยู่แม้ OptInEnableBuffering ยังรันให้ ทุก 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 storerecord เก็บขนาด ไม่มีเนื้อ
ยิงถี่จนคิวเต็มrecord ใหม่ถูก drop และนับ · request ไม่ช้าลง
ใส่ UseRequestLogCapture() โดยไม่เรียก AddRequestLogCapture()พังตอน request แรก (DI resolve ไม่ได้)

📕 กับดัก FCoverage กับ ExcludedRouteTemplates คนละแกน · transport หาย = เงียบ · ลำดับของ EnableBuffering สองตัว