BE 1 — ปิดประตูที่เปิดค้าง: endpoint ไม่มี auth และโค้ดที่ fail-open
งานที่ให้ผลตอบแทนสูงสุดต่อเวลาที่ใช้ ไม่ต้องรอ library ไม่ต้องรอใคร — วิธีไล่หาเส้นที่เปิดอยู่จริง 3 แบบ, วิธีตัดสินว่าเส้นไหนควรเปิดต่อ, และ pattern fail-open ที่เจอซ้ำในหลาย service
อัปเดต: 2026-08-23
ทำก่อนอย่างอื่นเสมอ — ไม่ต้อง bump library ไม่ต้องแก้ frontend ไม่ต้องรอ config จาก IaC และเป็นช่องโหว่ที่ถูกใช้ได้จริงทันทีถ้ามีคนเจอ
1. “ประตูที่เปิดค้าง” มี 3 แบบ ไม่ใช่แบบเดียว
| แบบ | หน้าตา | ทำไมอันตราย |
|---|---|---|
| A. ไม่มี attribute เลย | controller/action ไม่มีทั้ง [Authorize] และ [AllowAnonymous] | ถ้า service ไม่ได้ตั้ง global authorization policy ไว้ = เปิด และมันเงียบมาก เพราะ “ไม่มีอะไรผิดปกติให้เห็น” |
B. [AllowAnonymous] ที่ไม่มีใครตั้งใจ | มีคนใส่ไว้ตอน debug แล้วลืมถอด หรือ copy มาจาก controller อื่น | ดูเหมือนจงใจ เลยไม่มีใครกล้าแตะ |
| C. โค้ด fail-open | if (ไม่มี key) { ทำงานต่อแบบไม่เข้ารหัส } | ผ่านการรีวิวมาได้เพราะดูเหมือน “กันพัง” แต่จริง ๆ คือปิดระบบป้องกันเงียบ ๆ ตอน config หาย |
2. วิธีไล่หา — 3 คำสั่ง
# A. action ที่ไม่มี attribute ใดเลย (ดูผลแล้วไล่ทีละ controller — คำสั่งนี้ช่วยตีกรอบ ไม่ใช่คำตอบสำเร็จรูป)
git grep -n "public class .*Controller" -- "src/*01.API/Controllers/**"
# B. ทุกจุดที่เปิด anonymous ไว้
git grep -n "AllowAnonymous" -- "src/*01.API/**"
# C. pattern fail-open ที่พบบ่อย: ไม่มี key แล้วยังทำงานต่อ
git grep -n -B3 -A3 "IsNullOrEmpty" -- "src/*01.API/**" | grep -i -A3 "key\|secret\|token"
ทำไมต้องไล่ด้วยตา ไม่ใช่ regex อย่างเดียว
เพราะ [Authorize] สืบทอดจาก base controller ได้ และบาง service ตั้ง global filter ไว้ใน Program.cs — regex ตัวเดียวจึงตอบไม่ได้ว่า “เส้นนี้เปิดจริงไหม” เกณฑ์ตัดสินสุดท้ายคือยิงจริงโดยไม่แนบ token แล้วดูว่าได้ 401 ไหม
3. ตัดสินยังไงว่าเส้นไหน “ต้องปิด” เส้นไหน “เปิดต่อได้”
ถามตามลำดับ หยุดที่ข้อแรกที่ตอบว่าใช่:
- เป็นเส้น login / ออก token / jwks / health check ใช่ไหม → เปิดต่อ (ปิดแล้วระบบเข้าไม่ได้)
- มี ผู้เรียกจริงที่ยืนยันได้ ว่าเป็นระบบภายนอกที่ยังไม่มี token ใช่ไหม → เปิดต่อ แต่ต้องเขียนไว้ว่าใครเรียก และตั้งเรื่องหา auth ให้มันในรอบถัดไป
- นอกนั้น → ปิด (
[Authorize])
🔴 กับดักที่เผาคนมาแล้ว: endpoint ฝั่ง admin ของบาง service เปิด anonymous อยู่ โดยมีระบบอื่นเรียกใช้จริง การใส่
[Authorize]โดยไม่ตรวจผู้เรียกก่อน = ทำระบบอื่นพังแบบไม่รู้ตัว ตรวจ caller ก่อนเสมอ (ไล่จาก log จริง หรือ grep หา HttpClient ที่ยิงมาที่ path นั้นทุก repo)
4. Fail-open — ตัวอย่างจริงที่แก้ไปแล้ว
ก่อน — ไม่มีคีย์ ⇒ ส่งข้อมูลผู้ใช้ออกไปเป็น plaintext เงียบ ๆ (ระดับ log แค่ Warning):
if (string.IsNullOrEmpty(_encryptionSettings.EncryptionKey))
{
_logger.LogWarning("[TokenIssuance] ไม่มี EncryptionKey — ส่ง plain JSON");
return plainText;
}
หลัง — ไม่มีคีย์ ⇒ ไม่ส่งอะไรเลย และ log เป็น Error:
if (string.IsNullOrEmpty(_encryptionSettings.EncryptionKey))
{
_logger.LogError(
"[TokenIssuance] EncryptedClaim:EncryptionKey ไม่ได้ตั้งค่า — ปฏิเสธการส่ง UserProfile claim (fail-closed, ไม่ส่ง plaintext)");
return string.Empty;
}
และฝั่งผู้เรียกต้องรับมือกับ “ไม่มีค่า” ได้ด้วย ไม่ใช่ปล่อยให้ระเบิด:
var encryptedProfile = EncryptProfile(profileJson);
if (string.IsNullOrEmpty(encryptedProfile))
{
return Ok(BuildClaimsResponse(new TokenIssuanceClaims { UserProfile = string.Empty }));
}
หลักการที่เอาไปใช้ต่อได้: ทุกที่ที่เขียนว่า “ถ้าไม่มี X ให้ทำงานต่อ” ให้ถามว่า X คือระบบป้องกันหรือเปล่า ถ้าใช่ คำตอบที่ถูกคือ ปฏิเสธคำขอ ไม่ใช่ ทำงานต่อแบบไม่มีการป้องกัน
5. Acceptance test
[Fact]
public async Task EncryptProfile_WhenKeyMissing_ReturnsEmptyProfile_NotPlaintext()
{
var controller = CreateController(encryptionKey: string.Empty);
var result = await controller.GetClaims(validRequest, CancellationToken.None);
var claims = ExtractClaims(result);
claims.UserProfile.Should().BeEmpty(); // ไม่ส่งอะไรออกไป
claims.UserProfile.Should().NotContain("cardId"); // และต้องไม่ใช่ plaintext แน่ ๆ
}
บวกกับการยิงจริง 1 ครั้งต่อ endpoint ที่เพิ่งปิด:
curl -s -o /dev/null -w '%{http_code}\n' https://{host}/api/{service}/v1/{path}
# ต้องได้ 401 — ถ้าได้ 200 แปลว่ายังมีทางเข้าอื่นอยู่ ไปหาต่อ
6. Checklist
- ไล่ครบทั้ง 3 แบบ (A/B/C) ไม่ใช่แค่
AllowAnonymous - ทุกเส้นที่ยังเปิด anonymous มีเหตุผลเขียนกำกับไว้ในโค้ด ว่าอยู่ในกลุ่มไหนของ §3
- เส้นที่ปิดใหม่ ตรวจ caller จริงแล้วว่าไม่มีใครเรียกโดยไม่มี token
- มี test ที่ จะพังถ้ามีคนเผลอเปิดกลับ
-
dotnet build -c Release+dotnet test -c Releaseผ่านจริง