การพรีวิวเว็บไซต์ภายนอกอย่างปลอดภัย — การนำพร็อกซี + การกำจัดสคริปต์อันตราย + iframe sandbox มาใช้งานจริง

HeatMapX Engineering Team4 min read
  • engineering
  • security
  • nextjs
  • iframe

สรุปบทความนี้

  • การโหลดเว็บไซต์ภายนอกลงใน iframe บนโดเมนของเราเองมีความเสี่ยงที่ JavaScript ภายนอกจะเข้าถึงเซสชันของเราได้
  • มาตรการรับมือคือการป้องกันแบบหลายชั้น (defense in depth) ได้แก่ "พร็อกซีที่มีการป้องกัน SSRF + การกำจัด JavaScript ภายนอก + CSP ที่จำกัดสิทธิ์ + iframe sandbox"
  • sandbox ที่เข้มงวดเกินไปอาจไปบล็อกสคริปต์ของเราเองด้วย และการลืมใส่ allow-scripts ก็เป็นกับดักที่ทำให้ฟีเจอร์ไม่ทำงาน

ใน A/B testing ของ HeatMapX มีฟีเจอร์ที่ให้ "พรีวิวหน้าตาหลังการเปลี่ยนแปลง (Variant B) โดยนำไปใช้กับหน้าเป้าหมายจริง" ในทางเทคนิคแล้ว นี่คือกระบวนการที่ต้อง "แสดงเว็บไซต์ภายนอกของผู้ใช้อย่างปลอดภัยภายใน iframe บนโดเมนของเราเอง" ซึ่งมีความละเอียดอ่อนเกินคาด บทความนี้จะแชร์แนวคิดการออกแบบและกับดักที่เราเจอจริงระหว่างการพัฒนา

ทำไมการทำแบบตรงไปตรงมาถึงอันตราย

หากดึง HTML ของเว็บไซต์ภายนอกมาแสดงตรง ๆ บนโดเมนของเราเอง (เช่น heatmapx.com) แท็ก <script> หรือ onclick= ที่อยู่ใน HTML นั้นจะ ถูกรันด้วยสิทธิ์ของโดเมนเราเอง ซึ่งอาจทำให้ Cookie หรือเซสชันการเข้าสู่ระบบถูกอ่านออกไปได้ นี่คือสิ่งที่ต้องป้องกันให้ได้

โครงสร้างการป้องกันแบบหลายชั้น

ดังนั้นเราจึงวางมาตรการซ้อนกันเป็นชั้น ๆ ดังนี้

1. พร็อกซีที่มีการป้องกัน SSRF

ตรวจสอบ URL ปลายทางก่อน โดยปฏิเสธคำขอที่ไม่ใช่ http/https และบล็อก localhost, IP ภายในเครือข่ายส่วนตัว (private IP), รวมถึง endpoint ของ metadata บนคลาวด์ (เช่น 169.254.169.254) นอกจากนี้ยังไม่ follow redirect โดยอัตโนมัติ แต่ใช้ redirect: 'manual' เพื่อตรวจสอบซ้ำทีละขั้น ป้องกันไม่ให้ถูกเปลี่ยนเส้นทางเข้าสู่เครือข่ายภายใน

2. การกำจัด JavaScript ภายนอก (sanitize)

ลบแท็ก <script>, แอตทริบิวต์ handler แบบ inline on*= และ scheme javascript: ออกจาก HTML ที่ดึงมา เนื่องจากในการพรีวิวไม่จำเป็นต้องรัน JavaScript ของเว็บไซต์ลูกค้าจริง (การที่ SPA จะไม่สามารถแสดงผลได้สมบูรณ์แบบ 100% นั้นเป็นข้อจำกัดที่เรายอมรับไว้ล่วงหน้า)

3. CSP ที่จำกัดสิทธิ์และ base href

แทรก <base href> ไว้ทันทีหลัง <head> เพื่อให้ path แบบ relative ของรูปภาพและ CSS สามารถ resolve ได้ พร้อมกับแนบ CSP ที่จำกัดสิทธิ์แบบ "ไม่อนุญาตให้รัน JavaScript ภายนอก" ไปกับ response ส่วนรูปภาพและ CSS ยังคงอนุญาตไว้เพื่อให้แสดงผลได้ตามปกติ

4. iframe sandbox

ขั้นสุดท้าย คือการแนบแอตทริบิวต์ sandbox ไว้ที่ iframe ฝั่งแสดงผล เพื่อแยกส่วนการทำงาน (isolate) อีกชั้นหนึ่ง

กับดักที่เจอจริง: sandbox เข้มงวดเกินไป

ฟีเจอร์พรีวิวทำงานโดยการแทรก "สคริปต์เล็ก ๆ ของเราเองที่ใช้นำการเปลี่ยนแปลง (changeSet) ไปใช้จริง" เข้าไปใน HTML ที่ดึงมา แล้วรันภายใน iframe เพื่อเปลี่ยนหน้าตาของหน้านั้น แต่ปรากฏว่า sandbox ของ iframe ที่ใช้พรีวิวมีเพียง allow-same-origin เท่านั้น โดยไม่มี allow-scripts อยู่เลย

ผลลัพธ์คือ สคริปต์ของเราเองก็ไม่ถูกรันด้วยเช่นกัน กลายเป็นบั๊กที่ การเปลี่ยนแปลงไม่ถูกนำไปใช้ และแสดงหน้าเดิมออกมาแทน ในขณะที่ iframe ฝั่งตัวแก้ไข (editor) ทำงานได้ถูกต้องเพราะตั้งค่า allow-scripts allow-same-origin ไว้ ทำให้พบว่าสาเหตุคือการตั้งค่าที่ไม่ตรงกันระหว่างสองฝั่ง

วิธีแก้ไขนั้นเรียบง่าย เพียงปรับฝั่งพรีวิวให้ตั้งค่าเป็น allow-scripts allow-same-origin ให้ตรงกัน และเราได้เพิ่ม regression test สำหรับตรวจสอบแอตทริบิวต์ sandbox ของ iframe ไว้ด้วย

sandbox="allow-same-origin"  →  sandbox="allow-scripts allow-same-origin"

สิ่งที่ได้เรียนรู้

  • การฝังคอนเทนต์จากภายนอกควรคิดในรูปแบบ "การป้องกันหลายชั้น" อย่าพึ่งพามาตรการเดียว
  • sandbox ไม่ใช่ว่า "ใส่แล้วปลอดภัย" แต่สิ่งสำคัญคือต้อง อนุญาตเฉพาะสิทธิ์ที่จำเป็นเท่านั้นอย่างแม่นยำ หากเข้มงวดเกินไปก็อาจไปบล็อกฟีเจอร์ของตัวเองด้วย
  • เมื่อมี "สองเส้นทางที่ทำงานคล้ายกัน" (เช่น editor กับ preview) ต้องตรวจสอบเสมอว่าแอตทริบิวต์ด้านความปลอดภัยตรงกันหรือไม่ เพราะการแก้ไขเพียงฝั่งเดียวอาจทำให้อีกฝั่งพัง

สรุป

การพรีวิวเว็บไซต์ภายนอกอย่างปลอดภัยสามารถทำได้ด้วยการป้องกันหลายชั้น ได้แก่ การป้องกัน SSRF, การกำจัด JavaScript ภายนอก, CSP ที่จำกัดสิทธิ์ และ iframe sandbox และสิทธิ์ของ sandbox นั้นจะเป็นปัญหาได้ทั้งกรณีที่น้อยเกินไปและมากเกินไป กรณีที่ลืมใส่ allow-scripts ในครั้งนี้คือตัวอย่างคลาสสิกของปัญหาดังกล่าว

Heatmap จาก Claude Code — เริ่มฟรี

วางแท็ก tracker หนึ่งบรรทัด รับการวิเคราะห์และข้อเสนอ CRO จาก CLI