ข้ามไปยังเนื้อหา

Security Model

โดย default โค้ด Node รันด้วยสิทธิ์เต็มของ OS user ที่เริ่มมัน — ไม่มี sandbox, ไม่มี capability system เว้นแต่คุณจะ opt-in เข้าไปเอง บทนี้ครอบคลุมขอบเขตที่ Node ให้คุณจริง ๆ และ — สำคัญพอกัน — จุดที่ดูเหมือนเป็นขอบเขตแต่จริง ๆ ไม่ใช่

ด้วย --permission Node ปฏิเสธการเข้าถึง filesystem, network, child-process, และ worker โดย default และคุณให้สิทธิ์คืนเฉพาะที่ process ต้องการ:

Terminal window
node read-secret.js
top secret
Terminal window
node --permission read-secret.js
Error: Access to this API has been restricted. Use --allow-fs-read to manage permissions.
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: './secret.txt'
Terminal window
node --permission --allow-fs-read=./secret.txt read-secret.js
top secret

นี่คือขอบเขตความปลอดภัยจริง (ต่างจากสอง “ขอบเขต” ด้านล่าง): permission model ถูกบังคับใช้โดย Node เองก่อน operation จะรัน ไม่ใช่แค่ตามธรรมเนียม มันยังถูกติดป้าย experimental อยู่ — ปฏิบัติกับมันเป็น defense-in-depth สำหรับ process ที่รันโค้ดที่เชื่อถือได้น้อยกว่า ไม่ใช่สิ่งเดียวที่กั้นระหว่าง process ของคุณกับ filesystem ยัง

Node อ่านไฟล์สไตล์ .env ได้ตรง ๆ ไม่ต้องมี package dotenv:

Node.js
Output (verified on Node 24)
API_KEY without --env-file: undefined
Terminal window
node --env-file=.env read-env.js
API_KEY is super-secret-value

นิสัยที่เกี่ยวกับความปลอดภัยที่สิ่งนี้แทนที่: การพิมพ์ process.env (หรือ object ที่รวมมันไว้) ใน error handler หรือ debug log เป็นวิธีที่ secret รั่วเข้าไปใน log aggregator ที่คนจำนวนมากอ่านได้ — ปฏิบัติกับ process.env เองว่าเป็นข้อมูล sensitive ไม่ใช่แค่ไฟล์ .env ที่มันมาจาก

ความผิดพลาดที่พบบ่อยสามอย่างเจาะจงและวิธีแก้ ทั้งหมดเป็น behavior จริงบน Node 24.13:

Node.js
Output (verified on Node 24)
tokens must come from a CSPRNG, not Math.random
random token length (hex chars): 32
same password + same salt -> same hash: true
timingSafeEqual result: true

ใช้ scrypt/pbkdf2 (ไม่ใช่ sha256/sha512 ตรง ๆ) สำหรับ password เพราะ hash ทั่วไปที่เร็วเปิดโอกาสให้ผู้โจมตีที่มี hash database ที่หลุดออกไปลองเดาเป็นพันล้านครั้งต่อวินาทีบนฮาร์ดแวร์ทั่วไป — ต้นทุน memory ที่ scrypt จงใจใส่ไว้ทำให้แพงขึ้นมาก timingSafeEqual สำคัญทุกที่ที่คุณเทียบ secret กับ input ที่ผู้ใช้ส่งมา (API key, HMAC signature): === แบบไร้เดียงสา short-circuit ที่ byte แรกที่ไม่ตรงกัน และความต่างของเวลาเล็กน้อยระหว่าง “พังที่ byte 1” กับ “พังที่ byte 30” คือ side channel ที่ใช้โจมตีได้จริงถ้ายิง request มากพอ

Recursive merge ที่ไม่เช็ก key __proto__/constructor/prototype เปิดให้ JSON ที่ผู้โจมตีควบคุมไปถึง Object.prototype เอง:

Node.js
Output (verified on Node 24)
innocentObject.isAdmin (should not exist): true

การ merge ไม่เคยแตะ innocentObject ตรง ๆ เลย — มันปนเปื้อน Object.prototype ดังนั้น plain object ทุกตัว ใน process ตอนนี้ inherit isAdmin: true รวมถึงตัวที่ถูกสร้างก่อนหรือหลังการ merge ที่เป็นอันตรายนั้นนานแล้วก็ตาม นี่ไม่ใช่ตัวอย่างที่สร้างขึ้นมาลอย ๆ — มันคือ vulnerability class จริงที่ถูกเปิดเผยซ้ำแล้วซ้ำเล่าใน npm package ที่ implement deep-merge/clone/extend ของตัวเอง วิธีแก้: ปฏิเสธ key __proto__/constructor/prototype อย่างชัดเจนก่อน recurse, ใช้ Object.create(null) สำหรับ object ที่ตั้งใจให้ถือ key จากผู้ใช้แบบใดก็ได้, หรือใช้ merge utility จาก library ที่แพตช์เรื่องนี้ไว้แล้ว (แล้วอัปเดตมันไว้เสมอ — บั๊กประเภทนี้เป๊ะ ๆ ถูกนำกลับมาโดย library ที่ “แก้แล้ว” มากกว่าหนึ่งครั้ง) โมดูล practical-mastery ของคอร์ส TypeScript เดินผ่าน vulnerability เดียวกันนี้จากมุม type-safety ถ้าคุณอยากได้อีกครึ่งหนึ่งของภาพ

vm.createContext ดูเหมือนจะแยกโค้ดออกจากกัน — มันแยก global ไม่ใช่ object model ที่อยู่ข้างใต้ constructor.constructor เข้าถึง Function constructor ตัวจริงจากข้างใน context ที่ “sandboxed” ได้ และ constructor นั้นไม่ได้ถูก scope ให้อยู่ใน vm เลย:

Node.js
Output (verified on Node 24)
escaped the vm context, read a value from the host process: v24.13.0

บรรทัดเดียวนั้นใช้แค่ this.constructor.constructor ไม่มี import ไม่มีอะไรผิดปกติ — และมันอ่าน process.version จาก process host ได้ ซึ่งเป็นสิ่งที่ object context ที่ “sandboxed” ไม่เคยได้รับ reference ไปหาเลย eval() และ new Function() ไม่มีเรื่อง isolation เลยแม้แต่น้อย — ทั้งคู่รันใน scope ที่เรียกมันด้วยสิทธิ์เต็มตรง ๆ ถ้าคุณต้องรันโค้ดที่ไม่น่าเชื่อถือจริง ๆ เอกสารของ Node เองระบุชัดว่า vm เพียวๆ ไม่พอ — คุณต้องการ isolation ระดับ OS (process แยกที่ใช้ permission model, container, หรือ project sandboxing เฉพาะทาง) ไม่ใช่เทคนิคระดับ JavaScript

โค้ดส่วนใหญ่ของ Node application ทั่วไปไม่ใช่โค้ดที่ทีมเขียนเอง — มันคือ node_modules checklist ที่ใช้งานได้จริง: npm audit สำหรับ vulnerability ที่รู้จักแล้วใน dependency tree ปัจจุบัน; lockfile ที่ commit ไว้ (package-lock.json) เพื่อให้ “ใช้ได้บนเครื่องฉัน” หมายถึง dependency version เดียวกันทุกที่จริง ๆ ไม่ใช่อะไรก็ตามที่ range ^ บังเอิญ resolve ไปเจอวันนี้; provenance attestation (npm audit signatures, package ที่ publish ด้วย --provenance) เป็นวิธีใหม่ที่กำลังเกิดขึ้นเพื่อยืนยันว่า package ถูก build จาก source ที่ publish จริง ๆ ไม่ได้ถูกแก้ไขหลัง publish; และ --ignore-scripts ตอน install ใน environment ของ CI/build ที่คุณไม่ต้องการ (และไม่อยากเชื่อใจ) ให้ postinstall script ใด ๆ จาก third-party package รันได้ตามใจ

ลองนึกถึง Node process แบบ default เป็นบ้านที่ทุกประตูไม่ได้ล็อก — ไม่ใช่เพราะใครเลือกแบบนั้น แต่เพราะไม่มีใครติดตั้งกุญแจ --permission คือการเลือกล็อกทุกประตูแล้วแจกกุญแจเฉพาะห้องที่ process นั้นต้องการจริง ๆ vm/eval/new Function ไม่ใช่กุญแจล็อกเลย — มันคือม่าน: มันดูเหมือนกำแพงจากมุมมองของโค้ดเอง แต่อะไรก็ตามที่รู้วิธีเอื้อมผ่านม่านไป (constructor.constructor) จะเจอบ้านเปิดโล่งเหมือนเดิมอยู่ข้างหลังมัน Prototype pollution คือบ้านที่ทุกห้องแอบใช้ blueprint แม่แบบเดียวกัน (Object.prototype) — ทำลาย blueprint ครั้งเดียว ทุกห้องที่สร้างจากมัน ทั้งในอดีตและอนาคต มีการเปลี่ยนแปลงของผู้ทำลายติดไปด้วย

Allowlist model ของ --permission คือความปลอดภัยจริง แลกกับการต้องระบุล่วงหน้าว่า process ต้องการอะไรบ้าง — สำหรับ codebase ใหญ่ที่มีอยู่แล้ว การหารายการ path/host/operation ที่มันแตะจริง ๆ ทั้งหมดคืองานที่ไม่เล็กเลยและยังถูกติดป้าย experimental อยู่ การที่ scrypt/pbkdf2 ช้าและกิน memory โดยตั้งใจคือประเด็นทั้งหมดสำหรับ password hashing แต่ต้นทุนเดียวกันนั้นทำให้มันเป็นตัวเลือกที่ผิดทุกที่ที่คุณไม่ได้ป้องกัน offline guessing โดยเฉพาะ (เช่น hash cache key ที่ไม่ secret แต่มี volume สูง) — ใช้ hash ที่เร็วแทนตรงนั้น การ audit dependency (npm audit, lockfile ที่ pin ไว้, --ignore-scripts) มีต้นทุนการดูแลจริง — ต้องมีคนมา review ผล audit แล้วตัดสินว่าอะไรคือความเสี่ยงจริงกับอะไรคือ noise — แต่การข้ามมันไปเลยหมายถึงการเชื่อใจผู้เขียน transitive dependency ทุกคน รวมถึงคนที่คุณไม่เคยเลือก package ของเขาโดยตรงด้วยซ้ำ

  • “vm sandbox โค้ดที่ไม่น่าเชื่อถือได้” เอกสารของ Node เองระบุชัดเจนว่า vm ไม่ใช่กลไกความปลอดภัยสำหรับโค้ดที่ไม่น่าเชื่อถือ — constructor.constructor (และเทคนิค prototype-chain อื่น) เข้าถึง global ของ host จริงได้จากข้างใน vm context
  • “Prototype pollution กระทบแค่ object ที่ถูก merge เข้าไป” มันปนเปื้อน Object.prototype เอง กระทบ plain object ทุกตัวใน process — รวมถึงตัวที่มีอยู่ก่อนการปนเปื้อนเกิดขึ้นและตัวที่ถูกสร้างหลังจากนั้น ที่ไหนก็ได้ใน codebase
  • “Lockfile ที่ commit ไว้อย่างเดียวพอสำหรับป้องกัน supply-chain” มัน pin ว่าจะได้ version ไหน ซึ่งจำเป็นแต่ไม่พอ — มันไม่บอกอะไรเลยว่า version เหล่านั้นปลอดภัยหรือเปล่า มันไม่แทนที่ npm audit, การ review postinstall script, หรือการเช็ก provenance

โค้ดที่ AI สร้างซึ่งสร้าง environment การ evaluate แบบ “จำกัด” เป็นหนึ่งใน pattern ที่อันตรายที่สุดที่จะรับไว้โดยไม่ตรวจสอบ เพราะมันมักดูเหมือนใส่ใจความปลอดภัยโดยตั้งใจ ทั้งที่จริง ๆ แล้วไม่มั่นคงเลยในเชิงพื้นฐาน

🐛 หา bug ในโค้ดนี้

// AI-written "plugin system" -- runs user-submitted expressions
import vm from 'node:vm';
function runUserExpression(expr, allowedVars) {
const context = vm.createContext({ ...allowedVars }); // "sandboxed" -- only exposes allowedVars
return vm.runInContext(expr, context);
}
// Usage: runUserExpression('a + b', { a: 1, b: 2})
เฉลย

Comment อ้างว่านี่ “sandboxed” เพราะ context มีแค่ allowedVars ที่ระบุไว้ชัดเจนเท่านั้น — แต่ตามที่พิสูจน์ไว้ข้างบน expr เข้าถึงได้ไกลเกิน object นั้นผ่าน this.constructor.constructor(...)() ซึ่งคืน Function constructor ตัวจริงและรันด้วยสิทธิ์เต็มของ host process ไม่ว่าจะมีอะไรถูกใส่หรือไม่ใส่ใน context ก็ตาม ไม่มีวิธีแก้ที่ยังคงใช้ vm เปล่า ๆ สำหรับ expression ที่ไม่น่าเชื่อถือจริง ๆ ได้เลย — ทางเลือกจริงคือ: ไม่รับ expression ใดก็ได้จากผู้ใช้เลย (เสนอชุด operation ที่ปลอดภัยและตายตัวแทน), ใช้ expression-evaluator library ที่สร้างมาเฉพาะทางและไม่มีทางเข้าถึง Function/constructor โดยโครงสร้าง, หรือรันการ evaluate ใน process แยกที่ isolated ระดับ OS เต็มรูปแบบ (child process ที่ล็อกไว้แน่น, container) ที่การหนี “sandbox” ไปถึงแค่ process ที่ทิ้งได้นั้น ไม่ใช่ server หลักของคุณ

รัน node --permission script.js โดยไม่มี flag --allow-* เลย จะเกิดอะไรขึ้นเมื่อ script เรียก fs.readFileSync?
ทำไมถึงใช้ scrypt/pbkdf2 แทน hash ที่เร็วอย่าง sha256 สำหรับเก็บ password?
ความเสี่ยงจริงของโค้ดนี้คืออะไร ถ้า untrustedInput คือ {"__proto__": {"isAdmin": true}}?
function merge(target, source) {
  for (const key in source) target[key] = source[key]; // no key filtering
  return target;
}
merge({}, JSON.parse(untrustedInput));
ทำไมโค้ดนี้ถึงคืน process.version ของ host process ได้ ทั้งที่ process ไม่เคยถูกใส่เข้า context เลย?
const context = vm.createContext({ a: 1 });
vm.runInContext("this.constructor.constructor('return process.version')()", context);
package-lock.json ที่ commit ไว้รับประกันอะไรจริง ๆ และ*ไม่*รับประกันอะไร?