Login ผ่าน ≠ มีสิทธิ์: ทำไม authentication ไม่ใช่ authorization และ scope ต้อง enforce ที่จุด write จริง
ช่องโหว่ IDOR และ cross-tenant leak ที่ผมเจอซ้ำๆ ข้ามหลายระบบ ล้วนมาจากความสับสนเดียว — endpoint เช็คแค่ว่า "login แล้ว" แต่ไม่เคยถามว่า "คุณเป็นเจ้าของ resource ตัวนี้ไหม" มาดูวิธีคิดที่แยกสองเรื่องนี้ออกจากกันแล้วบังคับที่จุด write จริง
อ่าน ~8 นาที
สองคำถามที่คนละเรื่องกัน
ผมรีวิว backend มาหลายระบบ และมีบั๊กประเภทหนึ่งที่โผล่ซ้ำแทบทุกที่ ไม่ว่าจะเป็นระบบ SaaS ไทยแห่งหนึ่ง ระบบสั่งของในองค์กร หรือระบบจัดการอุปกรณ์ตามอาคาร รากของมันเหมือนกันหมด คือทีมเอาสองคำถามที่คนละเรื่องกันไปปนกัน
- Authentication (authN) ตอบคำถาม "คุณคือใคร" — token ถูกไหม session ยัง valid อยู่ไหม
- Authorization (authZ) ตอบคำถาม "คุณมีสิทธิ์ทำอะไรกับ resource ตัวนี้ไหม" — order ใบนี้เป็นของคุณจริงหรือเปล่า อุปกรณ์ตัวนี้อยู่ในหน่วยที่คุณดูแลไหม
middleware ตัวเดียวที่เช็คว่า "มี token ที่ valid" ตอบได้แค่คำถามแรก แต่ทีมจำนวนมากเข้าใจว่าผ่าน guard ตัวนั้นแล้ว = ปลอดภัยแล้ว ผลคือ endpoint ที่รับ id มาจาก client ตรงๆ แล้วไป mutate ข้อมูลโดยไม่เคยถามคำถามที่สอง
IDOR แบบคลาสสิก: mutation ที่ลืมเทียบเจ้าของ
IDOR (Insecure Direct Object Reference) คือช่องโหว่ที่ผู้ใช้ส่ง id ของ resource คนอื่นเข้ามา แล้วระบบทำตามให้เพราะเช็คแค่ว่า login แล้ว ตัวอย่างที่ผมเจอบ่อยที่สุดคือ mutation แบบนี้
// ❌ เช็คแค่ว่า login แล้ว — ใครก็ยกเลิก order ของใครก็ได้
async function cancelOrder(req) {
const user = await requireAuth(req); // authN ผ่าน = จบ (ผิด)
const order = await Order.findById(req.body.orderId);
order.status = 'CANCELLED';
await order.save();
return { ok: true };
}
requireAuth ยืนยันแค่ว่า caller เป็น user จริง แต่ไม่มีบรรทัดไหนถามว่า order ใบนี้เป็นของ user คนนี้หรือเปล่า ผู้ใช้ B ส่ง orderId ของผู้ใช้ A เข้ามาก็ยกเลิกได้ทันที เจอ pattern เดียวกันในฟังก์ชันอย่าง setVolume(deviceId) หรือ updateProfile(userId) — ทุกครั้งที่ id มาจาก client และไม่มีการเทียบ ownership
// ✅ เพิ่มการเทียบเจ้าของก่อน mutate
async function cancelOrder(req) {
const user = await requireAuth(req);
const order = await Order.findById(req.body.orderId);
if (!order) throw new NotFoundError();
if (order.ownerId !== user.id) throw new ForbiddenError(); // authZ ตัวจริง
order.status = 'CANCELLED';
await order.save();
return { ok: true };
}
เรื่องนี้ยิ่งอันตรายเมื่อระบบมี endpoint แบบ enumeration ที่ leak id ออกมา เช่น list ที่คืน id ของทุกคนแบบเรียงเลข ผู้โจมตีไม่ต้องเดา แค่ไล่ id ที่ได้มาแล้วยิงเข้า mutation ที่ไม่เช็ค ownership การออกแบบ id ให้เดายาก (UUID) ช่วยลด surface แต่ไม่ใช่การป้องกัน — การป้องกันจริงคือการเช็ค ownership ที่ทุก mutation
Dead authorization: เก็บ field ไว้แต่ไม่มีใครอ่าน
มีบั๊กที่ร้ายกว่า IDOR แบบเปิดโล่ง เพราะมันหลอกได้ทั้งทีม ทั้ง reviewer และแม้แต่ auditor ผมเรียกมันว่า dead authorization — schema มี field ที่บ่งบอก ownership ครบ เช่น device.orgUnitId หรือ user.assignedUnit ข้อมูลถูกเก็บถูกต้องทุก row แต่ไม่มีโค้ดบรรทัดไหน "อ่าน" field นั้นตอนตัดสินใจว่าอนุญาตหรือไม่
// schema ดูปลอดภัยมาก — มี field ownership ครบ
Device { id, name, orgUnitId } // orgUnitId เก็บทุก row
User { id, role, assignedUnit }
// ...แต่ enforcement point ไม่เคยเทียบสองค่านี้เลย
async function setVolume(req) {
const user = await requireRole(req, 'operator'); // เช็คแค่ role
const device = await Device.findById(req.body.deviceId);
device.volume = req.body.volume; // ไม่เทียบ orgUnitId!
await device.save();
}
เวลามีคนเปิด schema มาดู จะเห็น orgUnitId กับ assignedUnit แล้วสรุปว่า "ระบบมี scope แยกตามหน่วยงานแล้ว" ทั้งที่ความจริง field พวกนั้นเป็น decoration ที่ไม่มีใครใช้ตัดสินใจ operator หน่วย A ปรับอุปกรณ์หน่วย B ได้สบายๆ บทเรียนคือ การมี field ownership ใน schema ไม่ได้แปลว่ามีการ enforce สิ่งเดียวที่พิสูจน์ว่ามีการ enforce คือ path ของโค้ดตอน mutation อ่าน field นั้นจริงและ block เมื่อไม่ตรง
authZ = capability × scope อย่ายัดสองแกนลง enum เดียว
อีก anti-pattern ที่ผมเจอคือ role list แบนที่รวมสองมิติที่ควรแยกกันไว้ในค่าเดียว
// ❌ ปนสองแกน: capability (ทำอะไรได้) กับ scope (แตะได้แค่ไหน)
enum Role {
SUPER_ADMIN, // ทำได้ทุกอย่าง ข้ามทุก tenant
ORG_ADMIN, // ทำได้ทุกอย่าง แต่ใน tenant เดียว
ORG_VIEWER, // อ่านอย่างเดียว ใน tenant เดียว
}
ปัญหาคือ authorization จริงๆ เป็นผลคูณของสองแกนอิสระ:
- Capability = ทำ action อะไรได้บ้าง (read / write / delete / manage-users)
- Scope = ขอบเขตของ resource ที่ action นั้นครอบคลุม (global / tenant นี้ / หน่วยนี้ / ของตัวเองเท่านั้น)
เมื่อยัดสองแกนลง enum เดียว มันขยายแบบ combinatorial (ทุก capability ต้องมีคู่ทุก scope) และที่อันตรายกว่าคือ role ที่ cross-tenant (SUPER_ADMIN) กับ role ที่ tenant-scoped มาอยู่ใน guard ตัวเดียวกัน วันหนึ่งมีคนเขียน guard ว่า "ถ้า role อยู่ในลิสต์ admin ให้ผ่าน" แล้วเผลอไม่ได้ใส่ tenant filter สำหรับ ORG_ADMIN — ผลคือ silent cross-tenant leak ที่ไม่มี error ไม่มี log อะไรผิดปกติ ข้อมูลข้าม tenant ไหลออกเงียบๆ
// ✅ แยกสองแกน แล้วตรวจแยกกัน
const cap = user.capabilities; // Set: ['device:write', ...]
const scope = user.scope; // { kind: 'tenant', tenantId } | { kind: 'global' }
function assertCan(user, action, resource) {
if (!user.capabilities.has(action)) throw new ForbiddenError();
assertScope(user.scope, resource); // แยกชั้น scope ออกมาชัดเจน
}
Enforce ที่ชั้นต่ำสุดที่ทุก path ผ่าน
หลักที่ผมยึดคือ ถ้าการเช็ค scope เป็น optional ในโค้ด สักวันจะมีคนลืม ทางแก้ไม่ใช่ "เตือนให้ทุกคนอย่าลืม" แต่คือทำให้ลืมไม่ได้เชิงโครงสร้าง สองวิธีที่ได้ผล
1. assertScope() helper ที่บังคับให้เรียก — รวม logic การเทียบ scope ไว้ที่เดียว แล้วทำให้ทุก write path ต้องผ่านมัน อย่าให้แต่ละ endpoint เขียน if เอง เพราะแต่ละคนจะเขียนไม่เหมือนกันและบางคนจะลืม
// merge mandatory filter ลงใน data layer — caller เลี่ยงไม่ได้
function scopedQuery(user, baseFilter = {}) {
const filter = { ...baseFilter };
if (user.scope.kind === 'tenant') filter.tenantId = user.scope.tenantId;
filter.isActive = true; // filter บังคับที่ห้ามหาย
return filter;
}
// ทุก read/write ผ่าน scopedQuery — ลืม tenantId ไม่ได้แล้ว
const devices = await Device.find(scopedQuery(user, { orgUnitId }));
2. Merge mandatory filter ใน data layer — เงื่อนไขที่ห้ามหายอย่าง tenantId, isApproved, isActive ควรถูก inject ที่ชั้น data access ให้ caller ไม่มีโอกาสลืม แทนที่จะหวังว่าทุก query จะจำใส่เอง ยิ่งชั้นที่ enforce อยู่ต่ำและทุก path ต้องผ่าน โอกาสรั่วยิ่งน้อย
capability flag สำหรับ UI ไม่ใช่ gate จริง
bug สุดท้ายที่ผมอยากเตือน backend มักคำนวณ flag อย่าง canManage: true ส่งไปให้ frontend เพื่อซ่อน/โชว์ปุ่ม นั่นถูกต้องสำหรับ UX แต่ผิดมหันต์ถ้าเอา flag นั้นมาเป็น gate จริง frontend ปิดปุ่มได้ แต่ผู้โจมตียิง API ตรงได้เสมอ ไม่เคยเห็นปุ่มของคุณด้วยซ้ำ
กฎเหล็ก: capability ที่คำนวณเพื่อ UI ต้องถูก enforce ซ้ำที่ server เสมอ flag เป็นแค่ hint ให้ UI ตัวจริงที่ตัดสินใจต้องอยู่ที่ server ตอนรับ request และวิธีพิสูจน์ว่า enforce จริงคือ เขียน negative test เสมอ — ไม่ใช่แค่ทดสอบว่าเจ้าของทำได้ แต่ทดสอบว่าคนอื่นทำไม่ได้
// negative test คือหลักฐานว่า authZ ทำงานจริง
test('user B ยกเลิก order ของ user A ต้องได้ 403', async () => {
const orderA = await createOrder({ ownerId: userA.id });
const res = await api.post('/orders/cancel', { orderId: orderA.id })
.auth(userB.token);
expect(res.status).toBe(403); // ต้อง block
const fresh = await Order.findById(orderA.id);
expect(fresh.status).not.toBe('CANCELLED'); // และ state ต้องไม่เปลี่ยน
});
เทสนี้ทำสองอย่าง: ยืนยัน HTTP 403 และยืนยันว่า state ไม่ถูกแตะจริง เพราะบางระบบคืน 403 แต่ mutate ไปแล้วก่อน throw
สรุป
- authN ≠ authZ — "login แล้ว" ตอบแค่ว่าคุณคือใคร ไม่ได้ตอบว่าคุณมีสิทธิ์กับ resource ตัวนี้ไหม ทุก mutation ที่รับ id จาก client ต้องเทียบ ownership
- IDOR เกิดเมื่อลืมเทียบเจ้าของ —
resource.ownerId === caller.idต้องมีทุก write path; endpoint ที่ leak id ทำให้ exploit ง่ายขึ้น - Dead authorization หลอกทั้งทีม — มี field ownership ใน schema ไม่ได้แปลว่ามีการ enforce ต้องมีโค้ดที่อ่าน field นั้นตอน check จริง
- authZ = capability × scope — อย่ายัดสองแกนลง enum เดียว; role cross-tenant กับ tenant-scoped ห้ามอยู่ guard เดียวกัน ไม่งั้นเกิด silent cross-tenant leak
- Enforce ที่ชั้นต่ำสุด — assertScope() ที่บังคับ หรือ merge mandatory filter ใน data layer ให้ caller ลืมไม่ได้เชิงโครงสร้าง
- UI flag ไม่ใช่ gate —
canManageมีไว้ให้ UI; enforce ซ้ำที่ server เสมอ และเขียน negative test (user B แตะ resource user A → 403) เป็นหลักฐานว่า authZ ทำงานจริง




