HN เปิดตัว: รัน JavaScript ใน QuickJS sandbox บน WebAssembly
(github.com/sebastianwessel)@sebastianwessel/quickjsคือแพ็กเกจ TypeScript สำหรับรันโค้ด JavaScript และ TypeScriptอย่างปลอดภัยภายใน sandbox ที่คอมไพล์เอนจิน QuickJS เป็น WebAssembly- มุ่งเน้นการแยกและรันโค้ดที่ไม่น่าเชื่อถือ โดยให้สภาพแวดล้อมการรันที่เบาและรวดเร็วผ่าน QuickJS WebAssembly sandbox
- รองรับโมดูล Node.js พื้นฐาน, การเมานต์ระบบไฟล์เสมือน, การเมานต์โมดูล Node แบบกำหนดเอง, มี
fetchclient ให้ใช้งาน, รวมถึง test runner และexpectที่อิงกับ Chai - ตัวอย่างการใช้งานคือโหลด QuickJS wasm หนึ่งครั้งด้วย
loadQuickJs()แล้วรันโค้ดด้วยrunSandboxed()และevalCode()พร้อมส่งออปชันอย่างallowFetch,allowFs,env - นักพัฒนาที่ต้องรันโค้ด JavaScript ภายนอกในแอปพลิเคชัน TypeScript สามารถใช้เป็นสภาพแวดล้อมรันที่คำนึงถึงทั้งการแยก, ประสิทธิภาพ และความเรียบง่ายของ API
แพ็กเกจ QuickJS WebAssembly sandbox
@sebastianwessel/quickjsคือแพ็กเกจ TypeScript ที่ทำให้สามารถรันโค้ด JavaScript และ TypeScriptภายใน WebAssembly sandbox ได้- ใช้เอนจิน QuickJS ที่คอมไพล์เป็น WebAssembly และออกแบบมาสำหรับการรันโค้ดที่ไม่น่าเชื่อถือในสภาพแวดล้อมที่แยกออกจากกัน
- แพ็กเกจนี้ใช้ประโยชน์จากเอนจิน QuickJS ที่เบาและรวดเร็ว เพื่อมอบสภาพแวดล้อมที่มั่นคงสำหรับการรันโค้ด
- มีเอกสารครบถ้วน, ตัวอย่าง, และออนไลน์ playground
ความสามารถที่มีให้
- การรันอย่างปลอดภัย: รันโค้ด JavaScript และ TypeScript ที่ไม่น่าเชื่อถือได้อย่างปลอดภัยในสภาพแวดล้อมที่แยกออกจากกัน
- โมดูล Node.js พื้นฐาน: รองรับโมดูลมาตรฐานพื้นฐานของ Node.js สำหรับกรณีใช้งานทั่วไป
- ระบบไฟล์: สามารถเมานต์ระบบไฟล์เสมือนได้
- โมดูล Node แบบกำหนดเอง: สามารถเมานต์โมดูล Node ที่ผู้ใช้กำหนดเองได้
- การเรียกเครือข่าย: มี
fetchclient สำหรับเรียก http(s) - รองรับการทดสอบ: รวม test runner และ
expectที่อิงกับ Chai - ประสิทธิภาพและการผสานรวม: ใช้ความเบาและมีประสิทธิภาพของเอนจิน QuickJS และผสานเข้ากับโปรเจกต์ TypeScript เดิมได้ง่าย
- API ที่เรียบง่าย: มี API ที่ใช้งานง่ายสำหรับรันและจัดการโค้ด JavaScript และ TypeScript ภายใน sandbox
ขั้นตอนการใช้งานพื้นฐาน
- ก่อนอื่นให้นำเข้า
variantจาก@jitl/quickjs-ng-wasmfile-release-syncและใช้loadQuickJsกับSandboxOptionsจาก@sebastianwessel/quickjs loadQuickJs(variant)จะโหลดและเริ่มต้น QuickJS wasm ซึ่งเป็นงานที่ใช้ทรัพยากรสูง จึงแนะนำให้ทำเพียงครั้งเดียวถ้าเป็นไปได้loadQuickJs()จะคืนค่าrunSandboxedเพื่อนำไปใช้รัน sandbox ต่อไป- ตัวอย่าง
SandboxOptionsมีออปชันดังนี้allowFetch: true: injectfetchเข้าไปในโค้ดและอนุญาตให้ร้องขอข้อมูลได้allowFs: true: เมานต์ระบบไฟล์เสมือนและให้โมดูลnode:fsenv: ส่งค่าตัวแปรสภาพแวดล้อมให้โค้ดใน sandbox ใช้งาน
พฤติกรรมที่ทำได้ในตัวอย่างการรัน
- โค้ดใน sandbox สามารถ import
joinจากpathและเรียกjoin('src','dist')เพื่อพิมพ์src/distไปยังล็อกของระบบโฮสต์ - สามารถอ่าน
env.MY_ENV_VARแล้วพิมพ์"env var value"ไปยังล็อกของระบบโฮสต์ - สามารถสร้าง URL ด้วย
new URL('https://example.com')แล้วเรียกfetch(url)ก่อนคืนค่าผลลัพธ์ของf.text() - รันโค้ดในรูปแบบ
runSandboxed(async ({ evalCode }) => evalCode(code), options) - ตัวอย่างผลลัพธ์อยู่ในรูปแบบ
{ ok: true, data: '<!doctype html>\n<html>\n[....]</html>\n' }
ไลเซนส์และกรณีใช้งานที่เหมาะสม
- โปรเจกต์นี้เผยแพร่ภายใต้ MIT License
- เหมาะสำหรับนักพัฒนาที่ต้องการรันโค้ด JavaScript อย่างปลอดภัยภายในแอปพลิเคชัน TypeScript
- มอบสภาพแวดล้อมการรันที่ได้ทั้งประสิทธิภาพและความปลอดภัยผ่าน QuickJS WebAssembly sandbox
1 ความคิดเห็น
ความคิดเห็นบน Hacker News
ในฐานะผู้เขียนไลบรารีรันไทม์ quickjs-emscripten ผมชอบแนวทางสไตล์ ไลบรารีมาตรฐาน ที่ออกแบบมาให้ใช้งานสะดวกสำหรับ quickjs-emscripten
อยากรู้ว่าได้ลองรันในเบราว์เซอร์หรือ bundler แล้วหรือยัง วิธีรับชื่อ variant เป็นสตริงแล้วส่งต่อแบบไดนามิกไปยัง
import(variantName)อาจเข้ากับเครื่องมืออย่าง Webpack ได้ไม่ดีนักคำเตือนด้านความปลอดภัย: ไลบรารีนี้เปิดให้โค้ดที่ไม่น่าเชื่อถือของ guest สามารถเรียก
fetchด้วยคุกกี้เดียวกับฟังก์ชันfetchของ host ได้ ไม่ควรเปิดfetchแล้วรันโค้ดที่ไม่น่าเชื่อถือถ้าโค้ดใน sandbox สามารถเรียก HTTP API ใดๆ ที่ได้รับการยืนยันตัวตนในคอนเท็กซ์ของ host ได้ นั่นก็ไม่ใช่ “sandbox”
เหตุผลที่ quickjs-emscripten อยู่ในระดับต่ำและหลีกเลี่ยงฟีเจอร์ที่เหมือนเวทมนตร์ ก็เพื่อให้พูดได้อย่างมั่นใจว่า API ที่ให้มานั้นปลอดภัย โดยทั่วไปจะปฏิเสธคำขอฟีเจอร์อย่างการ serialize แบบอัตโนมัติหรือการเข้าถึงเครือข่าย/ไฟล์ซิสเต็มที่ง่ายเกินไป เพราะโค้ดลักษณะนี้เป็นพื้นที่ที่เกิดความผิดพลาดด้านความปลอดภัยได้ง่าย
เมื่อรันโค้ดที่ไม่น่าเชื่อถือ ต้องตรวจสอบอย่างรอบคอบไม่เพียงแค่ตัว sandbox เอง แต่รวมถึงโค้ดที่เขียนขึ้นสำหรับ API ที่เปิดให้ sandbox ใช้ด้วย
ผมสังเกตเห็นประเด็นความปลอดภัยที่อาจเกิดขึ้นนี้หลังจากเห็นคอมเมนต์ที่ผู้ใช้ HN อีกคนถามเรื่องคุกกี้ของ Fetch
อ่านเพิ่มเติม: บทความด้านความปลอดภัยของ sandbox สำหรับปลั๊กอิน Figma https://www.figma.com/blog/how-we-built-the-figma-plugin-sys..., https://www.figma.com/blog/an-update-on-plugin-security/, README ของ Quickjs-emscripten https://github.com/justjake/quickjs-emscripten
มีหลายวิธีในการแซนด์บ็อกซ์ JavaScript ทั้งฝั่งเซิร์ฟเวอร์และฝั่งเบราว์เซอร์ แต่ไม่แน่ใจว่ามีวิธี “แซนด์บ็อกซ์” การเข้าถึง DOM หรือไม่
เช่น ให้บุคคลที่สามที่ไม่น่าเชื่อถือเข้าถึงได้เฉพาะองค์ประกอบ DOM ในตำแหน่งที่กำหนดไว้ล่วงหน้า เท่าที่รู้ เทคโนโลยีเดียวที่อนุญาตให้ทำแบบนี้ได้คือ iframe แต่น่าเสียดายที่มันหนักและช้า
กำลังสร้างแอปที่โฮสต์ปลั๊กอินได้อยู่ แต่ถ้าให้ปลั๊กอินเข้าถึง DOM ได้ ก็ดูเหมือนว่าจะทำอะไรก็ได้จริง ๆ
ฟังก์ชันประเมินผลที่ปลอดภัยนั้นยอดเยี่ยมมาก แก่นหลักน่าจะอยู่ตรงนี้: https://github.com/Agoric/realms-shim/blob/v1.1.0/src/evalua...
ยังมีไอเดีย เว็บคอมโพเนนต์แบบแยกกัก เพื่อแก้ปัญหานี้ในระดับแพลตฟอร์มด้วย: https://github.com/WICG/webcomponents/issues/1002
อาจลองทำบางอย่างอย่าง “dommirrors” เองโดยได้แรงบันดาลใจจาก jsmirrors ได้ แต่นั่นเป็นงานใหญ่ มีวิธีอ้อม ๆ ที่ใช้ jsmirrors ตามเดิมเพื่อทำสิ่งที่ต้องการได้ในระดับหนึ่ง แต่ก็ไม่สะดวกต่อการใช้งาน
อย่างไรก็ตาม การเป็นตัวกลางหรือจำลองการเข้าถึง DOM ให้แทบจะแน่นอนว่าไม่ใช่สิ่งที่ต้องการจริง ๆ ควรระบุให้ได้ว่าอยากอนุญาตให้อีกฝ่ายทำอะไรจริง ๆ แล้วให้เฉพาะ capability ที่ทำให้พฤติกรรมนั้นเป็นไปได้
ตัวอย่างเช่น ถ้าต้องการให้เพิ่มปุ่มที่ไหนสักแห่ง อาจคิดว่าต้องให้จุดยึดเป็นองค์ประกอบ parent แล้วให้สร้างโหนด DOM ด้วย
document.createElementแต่สิ่งที่ต้องการจริง ๆ ไม่ใช่ให้สิทธิ์เข้าถึงdocument.createElementแก่พวกเขา แต่คือ สิทธิ์ add-button ดังนั้นก็แค่ implementaddButtonให้อ่านเพิ่มเติม: <https://news.ycombinator.com/item?id=30703531#30706060>
ไม่ควรฟังคนที่บอกว่า CSP มีไว้สำหรับกรณีนี้ มันไม่ใช่ พูดให้ชัดคือ แม้แต่ในวัตถุประสงค์เดิมของ CSP เองก็ยังเป็นขยะที่ออกแบบแย่และเป็นปรปักษ์ต่อผู้ใช้ จึงคิดว่าไม่ควรถูก implement และขยายออกมาแบบที่เป็นอยู่ตอนนี้ การพึ่งพามันจึงอันตราย
ในหน้า parent สามารถวัดขนาดเนื้อหา DOM แล้วปรับขนาด iframe ภายในขอบเขตที่กำหนดได้ จึงผสานเข้ากับ UI ของแอปได้เป็นธรรมชาติมากขึ้น
ต้องระวังความพยายามแอบอ้าง/ฟิชชิงและคลิกแจ็กกิงภายใน iframe ตามระดับการมองเห็นระหว่างผู้ใช้และระดับความไว้วางใจ ตามอุดมคติควรล็อกไม่ให้เฟรมทำเว็บรีเควสต์ได้เลย ซึ่งหมายความว่าโหลดรูปภาพก็ไม่ได้ด้วย
ถ้าทำเช่นนั้น แม้ผู้ใช้จะถูกหลอกให้ป้อนรหัสผ่านลงในฟอร์มรหัสผ่านปลอม ก็จะไม่มีทางรั่วไหลข้อมูลออกนอกเฟรมได้
วิธีหลักในการจำกัดประเภททรัพยากรที่ iframe ร้องขอได้คือ Content-Security-Policy ซึ่งใช้ปิดรูปภาพ สคริปต์ ฯลฯ จากบุคคลที่สามทั้งหมดได้
https://developer.mozilla.org/en-US/docs/Web/API/HTMLIFrameE...
https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
ควรเปิด attribute sandbox อื่น ๆ ด้วย และปิดการเข้าถึง DOM API ที่อ่อนไหวต่อความเป็นส่วนตัว เช่น เว็บแคม
https://developer.mozilla.org/en-US/docs/Web/HTML/Element/if...
https://developer.mozilla.org/en-US/docs/Web/Security/IFrame...
https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Pe...
แบบนี้จะจำกัดองค์ประกอบและแอตทริบิวต์ HTML ที่อนุญาตด้วย whitelist ได้ หากต้องการอนุญาตให้รับ native DOM event ด้วยก็จะซับซ้อนขึ้น แต่ไม่ใช่ว่าทำไม่ได้
ต้องมี API ประมาณว่ารับสตริงชื่อ event และ id ขององค์ประกอบที่จะรับ event จากนั้นตรวจจับ event นั้นใน DOM จริง แล้วทำสำเนา event เดียวกันเข้าไปในแซนด์บ็อกซ์ JavaScript ที่ใช้อยู่ ภายในแซนด์บ็อกซ์นั้นต้องเข้าถึง API ที่กล่าวไปข้างต้นได้
ที่แบ็กเอนด์ ให้แก้ HTML แบบไดนามิกเพื่อใส่แท็ก
divที่มีค่า id เป็นลำดับ hash เข้าไป อีกทั้งแก้ HTML เพื่อใส่แท็กscriptที่ร้องขอโค้ดของบุคคลที่สามจากโดเมนของตัวเอง และเพิ่มค่า hash ลงใน attribute data ของแท็กscriptนั้นเพื่อใช้ติดตามแก้ไขโค้ดของบุคคลที่สามในแบ็กเอนด์ โดยแทนที่ทุกอินสแตนซ์ของ
document.และwindow.ด้วยdocument.getElementById(hash_value).และทำให้ตัวเลือกคิวรีทั้งหมดเริ่มต้นด้วย#hash_valueต้องแทนที่
.parentNodeของโปรโตไทป์Elementด้วยพร็อพเพอร์ตีแบบกำหนดเอง เพื่อตรวจสอบและบล็อกความพยายามที่จะออกนอกคอนเทนเนอร์ที่จัดเตรียมไว้จากนั้นส่งเอกสาร HTML ไปยังเบราว์เซอร์ แม้โค้ดของบุคคลที่สามจะพังก็ไม่เป็นไร ต้องแจ้งข้อจำกัดให้บุคคลที่สามทราบ และพวกเขาต้องทดสอบโค้ดของตัวเองก่อนส่งมายังเซิร์ฟเวอร์
สิ่งสำคัญมีเพียงการทำให้โค้ดไม่สามารถออกนอกคอนเทนเนอร์ที่ให้มาแบบไดนามิกได้ ต้องทดสอบส่วนนี้เป็นประจำเพื่อหาการละเมิดด้านความปลอดภัย
ในทางปฏิบัติอาจไม่ทำงานก็ได้ แต่ดูน่าสนุกถ้าได้ลองทดลองดู
บังเอิญได้ลอง QuickJS เมื่อสัปดาห์ก่อน และสุดท้ายก็ลงเอยกับ isolated-vm ทั้งคู่ตอบโจทย์ด้านสัญญาความปลอดภัยได้ แต่ isolated-vm มีประสิทธิภาพดีกว่ามากในแง่โอเวอร์เฮดของการตั้งค่า การรื้อถอน และการรัน
evalเป็นแนวทางที่น่าสนใจ ในฐานะผู้เขียนไลบรารี JavaScript sandbox อีกตัว[1] ที่แยกด้วย worker และใช้เทคนิคการจัดระเบียบสภาพแวดล้อม JavaScript ผมมองว่าวิธีที่ตีความ JavaScript กล่าวคือ JavaScript ที่อยู่ใน JavaScript หรืออย่างกรณีนี้คือ JavaScript ที่อยู่ใน WASM ให้ระดับการแยกที่สูงที่สุด
อีกทั้งยังไม่ต้องถูกเปิดรับบั๊กของ JavaScript virtual machine ฝั่งโฮสต์โดยตรงด้วย หากเป้าหมายคือ Node เรื่องนี้อาจสำคัญกว่า เพราะถ้าไม่นับแนวโน้มใหม่ ๆ บางส่วน Node.js ดูไม่ได้ถูกออกแบบโดยคำนึงถึง การแยกและการทำ sandboxing ต่างจาก Deno
ดูจาก API แล้วไม่เห็นว่า
createRuntimeสามารถกำหนดการเรียกสภาพแวดล้อมโฮสต์นอกเหนือจากfetchได้หรือไม่ ฟีเจอร์นี้น่าจะมีประโยชน์มาก เพราะจะจำกัดการสื่อสารกับโลกภายนอกได้แบบควบคุม แทนที่จะเป็นการอนุญาตทั้งหมดหรือบล็อกทั้งหมดเช่นเดียวกัน ดูเหมือนว่าจะไม่รองรับเบราว์เซอร์ด้วย อย่างน้อยตอนลองตรวจคร่าว ๆ ผ่าน esm.sh ก็เป็นแบบนั้น ฟีเจอร์นี้ก็น่าจะมีประโยชน์เช่นกัน
ผมสงสัยว่าโอเวอร์เฮดจะอยู่ระดับไหน เลยตั้งใจจะทดสอบดู และอย่างที่บอก แนวทางนี้ดูค่อนข้างแข็งแรงทีเดียว
[1] @exact-realty/lot
นอกจากนี้ยังมี API สำหรับเปิดเผยฟังก์ชันของโฮสต์ เรียกฟังก์ชันของ guest และ custom module loader เป็นต้น: https://github.com/justjake/quickjs-emscripten?tab=readme-ov...
เอกสาร API
newFunction: https://github.com/justjake/quickjs-emscripten/blob/main/doc...ดูเหมือนจะเป็นแนวคิดว่า CPU เร็วขึ้นมากแล้ว งั้นตอนนี้ก็รัน interpreter ซ้อนใน interpreter กันเถอะ
ผมคงไม่ยก ประสิทธิภาพ เป็นข้อดีของการรัน JavaScript ใน QuickJS เพราะ QuickJS แข่งกับ JavaScript virtual machine ของโฮสต์ไม่ได้เลย
แต่ก็อาจเร็วกว่าพวก C interpreter เก่า ๆ หรือ interpreter ที่เขียนด้วย JavaScript
น่าจะใช้สิ่งนี้รันโค้ด JavaScript ที่ผู้ใช้ส่งมาได้ ผมกำลังหาวิธี bundle โค้ด TypeScript ของผู้ใช้ในสภาพแวดล้อม sandbox อยู่
อยากรู้ว่ามีคำแนะนำไหมสำหรับวิธีรัน bundler อย่าง webpack ภายใน QuickJS
[1]: https://webcontainers.io/
เจ๋งมาก ถ้าคอมไพล์เป็น WASM แล้วก็สงสัยว่าจะ รันในเบราว์เซอร์ ได้ไหม ถ้าได้ และสามารถส่งคำขอ
fetchโดยไม่แนบคุกกี้ไปกับ request ได้ ก็น่าสนใจที่ทำงานก่อนหน้านี้ โปรเจกต์ต้องถูกพักไว้เพราะเจอ segmentation fault และข้อผิดพลาดเงียบ ๆ ใน Quickjs-emscripten เยอะเกินไป
ถ้าต้องทำใหม่ คงใช้เอนจินที่ทำงานได้ถูกต้องกับโปรแกรมมากกว่านี้ และมี WASM bundle ที่ได้รับการอนุมัติอย่างเป็นทางการ
เคยคิดว่าสามารถ sandbox โค้ดได้อย่างปลอดภัยด้วย iframe แต่ก็ไม่มั่นใจ แน่นอนว่าถ้าใช้ interpreter ของตัวเอง ก็น่าจะใส่ฟีเจอร์อย่าง time limit ที่ละเอียดกว่า และ custom API ได้มากขึ้น
ตอนนี้ใช้ iframe ที่รับโค้ดผ่าน window message แล้วหลังจากประเมินโค้ดอินพุตก็ส่งคำตอบกลับทาง window message ได้ ซึ่งทำงานค่อนข้างดี แต่ก็ไม่มั่นใจว่าจะมีช่องให้หลุดออกจาก sandbox หรือไม่
ในกรณีที่ซับซ้อนขึ้น เช่น dependency ของแพ็กเกจ ผมพยายามจะ parse ด้วย Babel แล้วสร้าง import map ที่ใช้ CDN (esm.sh) แต่บางครั้งเวอร์ชันที่อยู่บน CDN ก็ทำงานได้ไม่ดี
เลยใช้ StackBlitz ซึ่งทำงานค่อนข้างดี แต่มีปัญหาบ้างในสภาพแวดล้อมที่ไม่ใช่ secure context
สุดท้ายก็ทำเว็บเซิร์ฟเวอร์เล็ก ๆ ที่รับโค้ดและ dependency (package.json) แล้วรัน Vite build ใน Docker container จากนั้นส่งผลลัพธ์กลับมา มันทำงานได้พอใช้ แต่บางครั้งก็ช้า
ถ้าบิลด์ได้ทั้งหมดฝั่ง client ก็คงดี และ StackBlitz ก็ทำงานประมาณนั้นอยู่