Private Docs

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-openif (ไม่มี 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. ตัดสินยังไงว่าเส้นไหน “ต้องปิด” เส้นไหน “เปิดต่อได้”

ถามตามลำดับ หยุดที่ข้อแรกที่ตอบว่าใช่:

  1. เป็นเส้น login / ออก token / jwks / health check ใช่ไหม → เปิดต่อ (ปิดแล้วระบบเข้าไม่ได้)
  2. มี ผู้เรียกจริงที่ยืนยันได้ ว่าเป็นระบบภายนอกที่ยังไม่มี token ใช่ไหม → เปิดต่อ แต่ต้องเขียนไว้ว่าใครเรียก และตั้งเรื่องหา auth ให้มันในรอบถัดไป
  3. นอกนั้น → ปิด ([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 ผ่านจริง