โค้ดเพียงบรรทัดเดียวสามารถทำลายโมเดลการควบคุมการเข้าถึง (access control) ทั้งหมดของคุณได้ หากคุณนำ request body ไปใส่ในการอัปเดตฐานข้อมูลโดยตรง เท่ากับคุณได้ยื่นปากกาให้ฝั่ง client เข้ามาเขียน schema ของคุณใหม่ได้ตามใจชอบ นั่นคือ mass assignment มันไม่ใช่บั๊กที่แปลกประหลาดหรือกรณีที่เกิดขึ้นได้ยาก แต่มันคือความล้มเหลวในการออกแบบที่เกิดขึ้นทุกครั้งที่ API ปฏิบัติต่อ payload เสมือนว่าเป็นนโยบายการอัปเดตของตัวเอง
await db.users.update(req.params.id, { ...req.body });
มันดูสะอาดตา ช่วยประหยัดเวลาในการพิมพ์ แต่ client เป็นผู้ควบคุม key ต่างๆ ผู้โจมตีสามารถเพิ่ม "role": "admin", "accountId": "someone_else" หรือ "credit": 99999" เข้าไปในการอัปเดตโปรไฟล์ที่ดูเหมือนปกติได้ เลเยอร์การตรวจสอบ (validation layer) ของคุณอาจจะเช็คว่าค่าเหล่านั้นเป็น string หรือ number และบอกว่ามันดูถูกต้อง แต่ความถูกต้อง (validity) ไม่ใช่การอนุญาต (authorization) ผู้ใช้อาจจะเป็นเจ้าของ record นั้นอย่างถูกต้องตามกฎ แต่ไม่ได้หมายความว่าพวกเขาควรมีสิทธิ์แก้ไขทุกฟิลด์ที่อยู่ภายในนั้น
ลักษณะที่แท้จริงของ Mass Assignment
อันตรายซ่อนอยู่ในความสะดวกสบาย Framework และ ORM ทำให้การแมป JSON keys เข้ากับคอลัมน์ในฐานข้อมูลโดยตรงเป็นเรื่องง่ายดาย เมื่อคุณทำเช่นนี้ คุณกำลังบอกฐานข้อมูลให้เชื่อใจ client ว่า อะไร ควรจะเปลี่ยน ไม่ใช่แค่บอกว่าควรเปลี่ยน อย่างไร
ผู้ใช้ที่กำลังอัปเดตโปรไฟล์อาจส่งข้อมูลที่ถูกต้องสำหรับ displayName และ bio แต่แอบใส่ role หรือ balance แนบมาด้วย หาก controller ของคุณแค่ส่งต่อ object นั้นไป ฐานข้อมูลก็จะเขียนข้อมูลทั้งหมดลงไป การตรวจสอบ (validation) จะดักจับค่าที่รูปแบบผิดเพี้ยน แต่แทบจะไม่เคยดักจับ key ที่ประสงค์ร้ายได้เลย กฎทางธุรกิจที่บอกว่า "ผู้ใช้รายนี้ได้รับอนุญาตให้อัปเดตโปรไฟล์ของตนเอง" จึงกลายเป็นการให้สิทธิ์ครอบคลุมทุกคอลัมน์ในแถวนั้นไปโดยปริยาย
วิธีแก้ไขไม่ใช่การเพิ่มการตรวจสอบ (validation) ให้มากขึ้น แต่คือการสร้างสถาปัตยกรรมที่เข้มงวดขึ้น
ประตูทั้งสามด่าน
การเปลี่ยนแปลงข้อมูล (mutation) ที่ปลอดภัยจะต้องผ่านการตรวจสอบสามขั้นตอนแยกกัน ก่อนที่จะไปถึงขั้นตอนการจัดเก็บข้อมูล
ฟิลด์ที่ยอมรับ (Allowlist)
เริ่มต้นด้วยการตัดสินใจให้แน่ชัดว่าคุณจะพิจารณา key ใดบ้าง หากฟิลด์ใดไม่อยู่ใน allowlist ให้ปฏิเสธ request นั้นหรือตัด key นั้นทิ้งไป วิธีนี้จะเปลี่ยนท่าทีเริ่มต้น (default posture): คอลัมน์ใหม่ในฐานข้อมูลจะไม่สามารถเขียนได้จนกว่านักพัฒนาจะเปิดให้ใช้งานอย่างชัดเจน Schema จะเติบโตขึ้นตามกาลเวลา เพื่อนร่วมทีมอาจเพิ่ม stripeCustomerId, departmentBudget หรือ flag isVerified เข้ามา หากมี allowlist คอลัมน์ใหม่เหล่านี้จะได้รับการปกป้องจากการเขียนโดย client โดยอัตโนมัติ แต่ถ้าไม่มี แต่ละคอลัมน์ที่เพิ่มมาใหม่จะกลายเป็นช่องโหว่ของ API โดยไม่ตั้งใจ
ค่าที่ถูกต้อง (Valid Values)
เมื่อคุณทราบแล้วว่าฟิลด์ใดบ้างที่ได้รับอนุญาต ให้ตรวจสอบว่าค่าเหล่านั้นสมเหตุสมผลหรือไม่ เช่น string ของ timezone เป็น timezone ที่รู้จักจริงหรือไม่? รูปแบบอีเมลถูกต้องตามรูปแบบอีเมลไหม? ตัวเลขอยู่ในช่วงที่เหมาะสมหรือไม่? นี่คือเรื่องของสุขอนามัยของข้อมูล (hygiene) มันช่วยป้องกันไม่ให้ขยะหลุดเข้ามาในระบบของคุณ แต่ไม่ได้ช่วยหยุดการใช้งานในทางที่ผิด string "admin" ที่ถูกต้องตามรูปแบบทุกประการก็ยังคงอันตรายหากถูกส่งมาจากคนที่ไม่ควรส่งในฟิลด์ role
การเปลี่ยนผ่านที่ได้รับอนุญาต (Authorized Transitions)
นี่คือด่านที่ทีมส่วนใหญ่มักข้ามไป และเป็นจุดที่การป้องกันที่แท้จริงอาศัยอยู่ จงตั้งคำถามที่ละเอียดเจาะจง: ผู้กระทำ (actor) รายนี้มีสิทธิ์แก้ไขฟิลด์เฉพาะนี้ใน record เฉพาะนี้หรือไม่? ไม่ใช่แค่ถามว่า "ผู้ใช้เป็น admin หรือไม่?" หรือ "ผู้ใช้มี scope write:users หรือไม่?" แต่ควรเป็น "ผู้ใช้รายนี้ได้รับอนุญาตให้เปลี่ยน displayName ของตนเองได้ แต่ห้ามเปลี่ยน accountId โดยเด็ดขาดใช่หรือไม่?" การอนุญาตสิทธิ์ระดับฟิลด์ (per-field authorization) จะช่วยป้องกันไม่ให้สิทธิ์ที่กว้างขวางอย่าง "Editor" หรือ "User" กลายเป็นกุญแจผีที่สามารถเข้าถึงได้ทุกคุณสมบัติในแถวนั้น
การสร้างฟังก์ชัน Patch
เชื่อมโยงประตูทั้งสามด่านเข้าด้วยกันเป็น pipeline เดียว เมื่อมี patch request เข้ามา ให้รันผ่านขั้นตอนต่างๆ ตามลำดับ
ขั้นแรก กรอง input ด้วย allowlist ของคุณ หาก role ไม่ใช่ฟิลด์ที่อนุญาตสำหรับ endpoint นี้ ให้หยุดทันที ไม่มีเหตุผลที่จะต้องตรวจสอบหรืออนุญาตค่าที่คุณไม่ควรจะได้รับตั้งแต่แรก
ขั้นที่สอง ตรวจสอบค่าที่ได้รับอนุญาต เช็คทั้งประเภท (type), รูปแบบ (format) และกฎทางธุรกิจ เช่น ฟิลด์สถานที่ต้องเป็น string ที่ระบุ timezone ที่มีอยู่จริง URL ของรูปโปรไฟล์ต้องเป็น URI ที่ถูกต้องและมีความยาวไม่เกินที่กำหนด
ขั้นที่สาม อนุญาตการกระทำ (authorize) ตรวจสอบว่าผู้กระทำเป็นเจ้าของ record เป้าหมาย หรือมีสิทธิ์ที่ตรงตามความต้องการสำหรับฟิลด์นี้ การเป็นเจ้าของ (ownership) เป็นค่าเริ่มต้นที่ดีสำหรับข้อมูลส่วนบุคคล แต่บางฟิลด์ยังคงต้องการด่านป้องกันเพิ่มเติม ผู้ใช้อาจเป็นเจ้าของโปรไฟล์ของตนเอง แต่มีเพียง billing admin เท่านั้นที่ควรจะเข้าถึง taxRegion ได้
ขั้นที่สี่ ปรับข้อมูลให้เป็นมาตรฐาน (normalize) เช่น ตัดช่องว่าง (trim whitespace), ยุบช่องว่างที่ซ้ำกัน, เปลี่ยนอีเมลเป็นตัวพิมพ์เล็ก หรือลบอักขระควบคุม (control characters) ออก ให้ทำขั้นตอนนี้หลังจากการตรวจสอบ (validation) แต่ก่อนการจัดเก็บ เพื่อที่คุณจะได้ไม่ต้องเปรียบเทียบ string ที่ยังไม่สะอาดในระหว่างการตรวจสอบสิทธิ์ (authorization checks)
หากข้อมูลนำเข้าไม่ผ่านเงื่อนไขใดก็ตาม ให้ปฏิเสธ mutation ทั้งหมด อย่าเลือกนำเฉพาะฟิลด์ที่ปลอดภัยไปใช้แล้วแอบทิ้งฟิลด์ที่ไม่ถูกต้องไปเงียบๆ เพราะการตอบสนองแบบผสมผสานจะทำให้ client เรียนรู้ที่จะสุ่มส่งทุก key ที่นึกออกเพื่อดูว่าอันไหนจะผ่านไปได้ จงแจ้งข้อผิดพลาดอย่างชัดเจน
กรณีขอบเขต (Edge Cases) ที่สำคัญจริงๆ
การป้องกัน Mass assignment จะรอดหรือร่วงขึ้นอยู่กับรายละเอียดที่ unit test มักจะมองข้าม
JSON keys ซ้ำกัน. ผู้โจมตีสามารถส่ง payload เช่น {"role": "user", "role": "admin"} ได้ ขึ้นอยู่กับ HTTP parser และ framework ของคุณ ค่าที่สองอาจเขียนทับค่าแรกก่อนที่โค้ดแอปพลิเคชันของคุณจะเห็น object นั้นเสียอีก จงทดสอบพฤติกรรมนี้ในระดับ parser หาก framework ของคุณยอมรับ key สุดท้ายโดยไม่แจ้งเตือน allowlist ของคุณอาจกำลังตรวจสอบ "user" ในขณะที่ฐานข้อมูลได้รับ "admin"
Nested objects, nulls และ arrays. อย่าทึกทักเอาเองว่า payload จะมีโครงสร้างแบบแบน (flat) client อาจห่อหุ้มฟิลด์ที่ถูกจำกัดไว้ภายใน nested object เช่น { "profile": { "role": "admin" } } allowlist ของคุณต้องทำงานแบบ recursion หาก schema ของคุณทำเช่นนั้น ในทำนองเดียวกัน จงตัดสินใจว่าจะจัดการกับ null อย่างไร มันหมายถึง "ข้ามฟิลด์นี้" หรือ "ลบฟิลด์นี้"? และหากคาดหวังว่าเป็น array ตัว validator ของคุณจะปฏิเสธโครงสร้างที่ไม่คาดคิด หรือจะทำการ cast object เดี่ยวให้กลายเป็น array แล้วปล่อยให้ผ่านไป?
Unicode normalization. ข้อความสองชุดอาจดูเหมือนกันสำหรับมนุษย์ แต่กลับเป็นลำดับไบต์ที่ต่างกัน ผู้ใช้อาจส่งตัวอักษร é แบบ precomposed หรือแบบ decomposed (ตัว e รวมกับเครื่องหมาย accent) หากการตรวจสอบสิทธิ์ (authorization check) ของคุณทำ normalization ครั้งหนึ่ง แต่ชั้นการจัดเก็บข้อมูล (storage layer) ทำ normalization ต่างออกไป คุณอาจพบกับข้อมูลที่ไม่สอดคล้องกัน หรือที่แย่กว่านั้นคือการ bypass ที่การซ้ำกันของชื่อผู้ใช้ (username collision) สามารถหลุดรอดตรรกะของคุณไปได้ จงทำ normalization ตั้งแต่เนิ่นๆ และทำอย่างสม่ำเสมอ
Race conditions. การตัดสินใจเรื่องสิทธิ์ (authorization) ไม่ใช่ภาพนิ่ง แต่มันเกิดขึ้น ณ ช่วงเวลาหนึ่ง คำขอ (request) สองรายการอาจอ่าน record เดียวกัน เห็นเหมือนกันว่าผู้กระทำมีสิทธิ์เขียนข้อมูล และต่างคนต่างส่งคำสั่งอัปเดต ในระหว่างนั้น สถานะหรือสิทธิ์ของผู้กระทำอาจเปลี่ยนไปแล้ว จงใช้การอัปเดตฐานข้อมูลโดยมีเงื่อนไขเกี่ยวกับเลขเวอร์ชัน (version number) หรือค่าของ state machine เสมอ เช่นการใช้ UPDATE users SET ... WHERE id = ? AND version = 5 หากแถวข้อมูลมีการเปลี่ยนแปลงนับตั้งแต่ที่คุณอ่านมา การเขียนข้อมูลจะล้มเหลว จงจัดการกับความล้มเหลวด้วยการลองใหม่ (retry) หรือปฏิเสธ (reject) วิธีนี้จะช่วยป้องกันไม่ให้การตรวจสอบสิทธิ์ที่ล้าสมัย (stale authorization checks) เข้ามาทำให้ข้อมูลของคุณเสียหาย
ตรวจสอบสิ่งที่สำคัญ
คุณไม่สามารถรักษาความปลอดภัยในสิ่งที่คุณมองไม่เห็นได้ จงสร้างระบบ audit logging โดยเน้นไปที่การตัดสินใจ ไม่ใช่แค่การกระทำ
บันทึก actor ID และ target ID บันทึกชื่อฟิลด์ที่แน่นอนที่ได้รับการยอมรับและฟิลด์ที่ถูกปฏิเสธ บันทึกเวอร์ชันของนโยบาย (policy version) ที่ใช้ในการตัดสินใจและผลลัพธ์สุดท้าย หากผู้ใช้เริ่มถูกปฏิเสธฟิลด์ role ในการอัปเดตโปรไฟล์อย่างกะทันหัน คุณควรจะทราบเรื่องนั้นทันที
อย่าบันทึก bearer tokens ลงใน log โดยเด็ดขาด และอย่า dump request body ทั้งหมดลงใน log ของคุณ เส้นทางการตรวจสอบ (audit trail) ควรช่วยให้คุณสืบสวนการใช้งานในทางที่ผิด ไม่ใช่กลายเป็นแหล่งเก็บรวบรวมข้อมูลประจำตัว (credentials) และข้อมูลส่วนบุคคล
กฎเพียงข้อเดียวที่คุณต้องมี
request body คือการเสนอข้อมูล แต่มันไม่เคยเป็นตัวกำหนดอำนาจของตัวเอง Client สามารถร้องขออะไรก็ได้ แต่เซิร์ฟเวอร์ของคุณจะเป็นผู้ตัดสินใจเอง ทีละฟิลด์และทีละแถว ว่าสิ่งใดได้รับอนุญาตให้บันทึกลงในที่จัดเก็บข้อมูลถาวร จงสร้าง patch ของคุณโดยคำนึงถึงการแยกส่วนนี้ แล้วปัญหา mass assignment จะกลายเป็นเรื่องที่คุณจัดการได้จบสิ้นไปนานก่อนที่มันจะมาถึงชั้นการตรวจสอบสิทธิ์ (authorization layer) ของคุณ
