ความปลอดภัยของปลั๊กอิน
installation token ของ Dee Wan คืออะไร ถูกเก็บและ rotate อย่างไร สิทธิ์อำนาจของบุคคลหนึ่งไปถึง คำสั่งของปลั๊กอินได้อย่างไร grant จำกัดขอบเขตอะไรบ้างจริง ๆ และผู้ที่ยึดปลั๊กอินได้สามารถทำอะไรได้ และทำอะไรไม่ได้
ทุกตัวอย่างในหน้านี้ถูก execute กับ route จริงโดย backend/test/plugin-docs.test.ts
id และ secret ทั้งหมดเป็นข้อมูลสังเคราะห์
ดูเพิ่มเติมที่ Protocol v1 สำหรับพื้นผิวทีละ route และ การเขียนปลั๊กอิน สำหรับขั้นตอนการติดตั้ง
ขอบเขต (boundary)
หัวข้อที่มีชื่อว่า “ขอบเขต (boundary)”browser ── user session ──► Dee Wan admin Worker │ control database: installations, token hashes, grants, launch codes │ tenant database: content, versions, workflow, audit │ │ HTTPS + installation bearer tokenplugin service ◄──────────────┘ │ its own database, its own clock, its own sessions └── HTTPS + installation bearer token ──► /api/plugin/v1 ──► the same lifecycle service a person usesคุณสมบัติสามข้อค้ำขอบเขตนั้นไว้:
- Plugin API ไม่ใช่ admin API มันอยู่ภายใต้
/api/plugin/v1มี middleware ของตัวเอง และใช้ร่วมกันเพียง lifecycle service — ไม่มี implementation ที่สองของ transition ที่ อาจคลาดเคลื่อนไปจากตัวที่บุคคลใช้งาน - ไม่มีโค้ดของปลั๊กอิน ไม่มี markup ของปลั๊กอิน core ไม่โหลด module ใด ไม่ประมวลผล script ใด ไม่ render
HTML หรือ CSS จากระยะไกล และไม่ฝัง iframe อิทธิพลเดียวที่ปลั๊กอินมีต่อ admin UI คือ action
{ id, label }ที่มีขอบเขตจำกัด และลิงก์หนึ่งลิงก์ - ปลั๊กอินไม่มีสิทธิ์เข้าถึงฐานข้อมูล ปลั๊กอินไม่ได้รับ Prisma client ไม่ได้รับ D1 binding ไม่ได้รับ credential ของ R2 และไม่ได้รับสิทธิ์เข้าถึงบัญชี Cloudflare ทุกสิ่งที่มันอ่านหรือเปลี่ยนได้ต้องผ่านห้า route
| รูปแบบ | dwp_ ตามด้วยอักขระแบบ URL-safe 43 ตัว — ความสุ่ม 256 bit จาก crypto.getRandomValues |
| ให้แก่ปลั๊กอิน | ครั้งเดียว ในการส่งมอบตอน activation ผ่าน HTTPS ไปยัง origin ของ manifest เอง |
| core เก็บ | เฉพาะ hash แบบ SHA-256 ไม่มีเส้นทางโค้ดใดที่พิมพ์ token ออกมาได้อีก |
| ปลั๊กอินส่ง | Authorization: Bearer <token> ในทุก request |
| ไม่มีวันอยู่ใน | URL, query parameter, error body, บรรทัด log, audit entry หรือ fixture |
| ขอบเขต | installation (การติดตั้ง Dee Wan หนึ่งชุด) หนึ่งรายการ ซึ่งคือปลั๊กอินหนึ่งตัวบนไซต์หนึ่งไซต์ |
prefix มีไว้เพื่อให้สตริงที่รั่วไหลถูกจดจำได้ว่าเป็น credential ของปลั๊กอิน Dee Wan ส่วน entropy คือสิ่งที่ทำให้มันเป็น credential ค่า bearer ที่ผิดรูปแบบจะถูกปฏิเสธจากรูปแบบก่อนการอ่านฐานข้อมูลใด ๆ ดังนั้นความพยายามเดาจะทำให้ผู้โจมตีเสีย lookup ที่ไปไม่ถึงเลย
Rotation
หัวข้อที่มีชื่อว่า “Rotation”rotation คือการส่งมอบ ไม่ใช่การ reset และต้องใช้โค้ดแบบใช้ครั้งเดียวใหม่จากปลั๊กอิน — มิฉะนั้น ใครก็ตามที่เข้าถึง admin route ได้ก็สามารถแทนที่ credential ได้อย่างเงียบ ๆ
- ผู้ดูแลได้รับ activation code ใหม่จากปลั๊กอิน
- core ออก token ใหม่และส่งมันพร้อมโค้ดนั้นไปยัง
activation_urlที่ถูก pin ไว้จาก manifest ที่เก็บไว้ ไม่ใช่ไปยัง URL ที่ส่งมาตอน rotation - core จะสลับ hash เฉพาะเมื่อได้ 2xx เท่านั้น หากปลั๊กอินปฏิเสธหรือติดต่อไม่ได้ token เก่า ยังคงใช้งานได้ — rotation ที่ล้มเหลวต้องไม่มีวันล็อก installation ที่ใช้งานได้อยู่ออกไป
- hash ก่อนหน้ายังใช้ได้นานสูงสุด 10 นาที เพื่อไม่ให้ request ที่กำลังส่งอยู่ ถูกตัดกลางคันระหว่าง rotation
- request แรกที่ถือ token ใหม่ (NEW) จะยุติช่วงซ้อนทับนั้นทันที ปลั๊กอินที่รับ token ใหม่มาใช้อย่างรวดเร็วจะลดช่วงเวลาที่ตัวเองเสี่ยงให้แคบลง
activation code ไม่มีวันถูกเก็บและไม่มีวันถูกบันทึกลง log ไม่ว่าฝั่งใด
Revocation
หัวข้อที่มีชื่อว่า “Revocation”ทั้ง disabled และ revoked หยุด plugin API ตั้งแต่หน้าประตู — ก่อนการเข้าถึง tenant ก่อนที่
body จะถูกอ่าน revoked เป็นสถานะสุดท้าย: hash ถูกทิ้ง และ token นั้นไม่มีวันใช้ได้อีก
การ uninstall จะ revoke ก่อน และ Dee Wan ไม่อ้างสิทธิ์ใด ๆ เกี่ยวกับข้อมูลที่มันมองไม่เห็น
Launch delegation และ delegation_id
หัวข้อที่มีชื่อว่า “Launch delegation และ delegation_id”UI สำหรับจัดการของปลั๊กอินเป็นหน้าแยกต่างหาก และ session cookie ของ Dee Wan ไม่มีวันออกนอก Dee Wan ตัวเชื่อมระหว่างทั้งสองคือ launch code:
- สุ่ม ใช้ครั้งเดียว เก็บแบบ hash และใช้ได้ 60 วินาที
- ผูกกับ installation, ไซต์, ผู้ใช้, action และ — สำหรับ action บนเนื้อหา — instance ของเนื้อหาหนึ่งรายการ
- ถูกแลกเปลี่ยนโดยแบ็กเอนด์ (BACKEND) ของปลั๊กอิน ด้วย bearer token ของตัวเอง เพื่อรับ delegation record
- โค้ดที่ออกให้ installation หนึ่งไม่สามารถถูกแลกเปลี่ยนโดยอีก installation หนึ่งได้
- launch response มี
Referrer-Policy: no-referrerและหน้าระยะไกลต้องไม่โหลด resource จากบุคคลที่สามใด ๆ ก่อนการแลกเปลี่ยน
core ตรวจสอบก่อนออกโค้ด: ผู้ร้องขอมี session และสิทธิ์เข้าถึงไซต์ manage ต้องการ
site:plugins และ action บนเนื้อหาต้องการสิทธิ์อ่านรายการนั้นโดยเฉพาะ
delegation ไม่ใช่ credential มันไม่มี capability ใด ๆ สิ่งที่มันมีคือตัวตน — และ นั่นคือสิ่งที่ทำให้การอ่านสิทธิ์อำนาจใหม่เป็นไปได้:
คำสั่งของปลั๊กอินทุกคำสั่งระบุ
delegation_idและ core จะอ่านสิทธิ์อำนาจปัจจุบัน (CURRENT) ของบุคคลนั้นใหม่ จาก control database ก่อนจะเขียนอะไรก็ตาม สิทธิ์อำนาจที่บันทึกไว้ตอนสร้างตารางเวลา จะไม่มีวันถูกส่งต่อไปใช้
ดังนั้นกรณีเหล่านี้ทั้งหมดจะถูกปฏิเสธตอน execute แม้ว่า delegation จะใช้ได้ตอนที่ถูกสร้าง: บุคคลนั้น
เสียบทบาท (role) บนไซต์ บัญชีของเขาถูกปิดใช้งาน บทบาทของเขาไม่มีสิทธิ์
เผยแพร่อีกต่อไป หรือ delegation เป็นของอีก installation หนึ่ง การอ่านที่มี
delegation_id จะจำกัด transition ที่เสนอให้แคบลงในลักษณะเดียวกัน และปฏิเสธแทนที่จะคืน
รายการที่สั้นลงอย่างเงียบ ๆ
บุคคลที่ร้องขอไม่มีสิทธิ์อำนาจที่จำเป็นอีกต่อไป
curl -X POST "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001/transitions/published" \ -H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: sched:plg_0000000000000000000000000000d1ee:sch_0009:r1" \ --data '{"kind":"publish","expected_current_version_id":"cdocsversion00000000000001","expected_workflow_revision":0,"delegation_id":"pld_0000000000000000000000000000d1ee"}'{ "outcome": { "code": "requester_unauthorized", "message": "The person who requested this no longer holds the authority it needs.", "retry": false }}ไม่มีอะไรถูกเขียน นี่เป็นผลแบบ terminal: ปลั๊กอินต้องไม่ retry และต้องแสดงให้ผู้ใช้เห็นว่า ตารางเวลานี้ต้องการบุคคลที่มีสิทธิ์อำนาจที่ถูกต้อง
ข้อยกเว้นเดียวที่ตั้งใจไว้คือ replay Idempotency-Key ที่ถูกนำไปใช้แล้วจะคืน
ผลลัพธ์แรกแม้ผู้ร้องขอจะเสียสิทธิ์อำนาจไปแล้วก็ตาม — งานเสร็จไปแล้ว และ
การตอบซ้ำไม่ใช่การมอบสิทธิ์ใหม่
ขอบเขตของไซต์และโมเดล
หัวข้อที่มีชื่อว่า “ขอบเขตของไซต์และโมเดล”ไซต์มาจาก token และไม่มาจากสิ่งใดที่ผู้เรียกส่งมา ไม่มี X-Site-Id ใน
request ของปลั๊กอิน ไม่มีไซต์ใน body และไม่มีชื่อฐานข้อมูลที่ใดเลย: ทั้งสองอย่างถูกปฏิเสธทันที แทนที่จะ
ถูกเพิกเฉย ดังนั้น bug ในปลั๊กอินจึงไม่สามารถกลายเป็นการอ่านข้าม tenant ได้
การระบุชื่อฐานข้อมูล
curl "https://cms.example/api/plugin/v1/context" \ -H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp" \ -H "X-D1-Binding: SOME_OTHER_TENANT"{ "outcome": { "code": "invalid_request", "message": "A plugin request cannot name a database.", "retry": false }}สิทธิ์เข้าถึงโมเดลคือส่วนร่วม (intersection) ของ capability และรายการ id ของโมเดลเนื้อหาที่เก็บไว้ สิ่งที่อยู่นอก
ขอบเขตจะตอบ content_not_found — คำตอบเดียวกับ “ไม่มีเนื้อหานี้” ดังนั้นปลั๊กอินจึงไม่สามารถทำแผนที่
โครงสร้างของไซต์ด้วยการสุ่มตรวจได้
id จากไซต์อื่น
curl "https://cms.example/api/plugin/v1/content/cotherinstance0000000000001" \ -H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"{ "outcome": { "code": "content_not_found", "message": "No such content in this installation’s scope.", "retry": false }}grant ที่ถูกจำกัดให้แคบลงมีผลกับ request ถัดไปทันที:
หลังจากผู้ดูแลลบโมเดลออกจาก grant
curl "https://cms.example/api/plugin/v1/content/cdocsinstance00000000000001" \ -H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"{ "outcome": { "code": "content_not_found", "message": "No such content in this installation’s scope.", "retry": false }}grant เก็บ id ของโมเดล ดังนั้นการเปลี่ยนชื่อโมเดลจะคง grant ไว้ และการลบโมเดลจะทำให้ส่วนนั้น ของ grant ไม่มีผล UI ด้านการดูแลระบบแสดง slug ปัจจุบันไว้ข้าง id
การยืนยันตัวตนเป็นแบบทางเดียว
หัวข้อที่มีชื่อว่า “การยืนยันตัวตนเป็นแบบทางเดียว”cookie ของผู้ใช้ไม่ใช่การยืนยันตัวตนของปลั๊กอิน route ของปลั๊กอินอ่าน Authorization และไม่อ่านอย่างอื่น ดังนั้น
browser session ที่ถูกขโมยไปจึงไม่สามารถนำมา replay กับ plugin API ได้:
session cookie บน route ของปลั๊กอิน
curl "https://cms.example/api/plugin/v1/context" \ -b "__Secure-better-auth.session_token=$SESSION"{ "outcome": { "code": "plugin_unauthorized", "message": "The installation token is missing, unknown, disabled or revoked.", "retry": false }}ทิศทางกลับกันก็เป็นจริงเช่นกัน และครอบคลุมโดย backend/test/plugin-installation.test.ts: token ของปลั๊กอิน
ที่ถูกนำไปใช้กับ route ด้านการดูแลระบบจะถูกปฏิเสธในฐานะที่ไม่ได้ยืนยันตัวตน route เหล่านั้นต้องการ
session ของผู้ใช้ header ของไซต์ และ site:plugins ปลั๊กอินไม่มีทั้งสามอย่าง
installation ที่ถูกปิดใช้งาน ก่อนการเข้าถึง tenant ใด ๆ
curl "https://cms.example/api/plugin/v1/context" \ -H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"{ "outcome": { "code": "plugin_unauthorized", "message": "The installation token is missing, unknown, disabled or revoked.", "retry": false }}คำตอบเดียวครอบคลุมทั้งกรณีไม่มี ไม่รู้จัก ถูกปิดใช้งาน และถูก revoke ไม่มีสิ่งใดบอกผู้เรียกว่าเป็นกรณีใด
Rate limit
หัวข้อที่มีชื่อว่า “Rate limit”request ถูกจำกัดต่อ installation — 120 ครั้งต่อช่วง 60 วินาที — โดยใช้กลไกบังคับใช้
พื้นฐานเดียวกับส่วนอื่นของผลิตภัณฑ์ และตอบกลับเป็น envelope ของโปรโตคอลเองพร้อม
Retry-After สิ่งเหล่านี้คือการควบคุมการใช้งานในทางที่ผิด ไม่มีสิ่งใดในนี้ถูกวัด รายงาน หรือเก็บไว้เป็น
telemetry
เกินขีดจำกัด
curl "https://cms.example/api/plugin/v1/context" \ -H "Authorization: Bearer dwp_ExampleTokenNotRealExampleTokenNotRealExamp"{ "outcome": { "code": "rate_limited", "message": "Too many requests for this installation.", "retry": true }}เคารพ Retry-After และปฏิเสธ record ของคุณเองแทนการรอ หากระยะหน่วงเกินค่าสูงสุด
ที่คุณเลือกไว้ล่วงหน้า
การเรียกออกไปภายนอกที่ core ทำ
หัวข้อที่มีชื่อว่า “การเรียกออกไปภายนอกที่ core ทำ”core เรียกปลั๊กอินเพียงสองครั้งเท่านั้น: การดึง manifest และการส่งมอบตอน activation หรือ rotation ทั้งสอง มีขอบเขตจำกัดและถูก pin ไว้
- HTTPS เท่านั้น credential ใน URL, fragment, loopback, link-local, ปลายทาง private และ reserved ถูกปฏิเสธทั้งหมด รวมถึง IP literal และชื่อที่ไม่มีจุด
- redirect ถูกปฏิเสธ ไม่ถูกติดตาม 3xx คือ
remote_redirect_refused - response body ถูกจำกัดที่ 32 KB และการเรียกที่ 5 วินาที
- ทุก URL — manifest, base, activation, management — ต้องใช้ origin เดียวกัน ดังนั้น activation จึง ไม่สามารถถูกชี้ไปยังที่ที่ manifest ที่ผ่านการตรวจทานไม่ได้ระบุไว้
- response จากระยะไกลถูก parse เป็น input ที่ไม่น่าเชื่อถือเทียบกับ manifest schema key ที่ไม่รู้จักคือ การปฏิเสธ ไม่ใช่สิ่งที่จะเพิกเฉย
- Workers ไม่สามารถ resolve DNS ก่อน
fetchได้ ดังนั้นสิ่งที่ตรวจคือสิ่งที่ URL ระบุเอง ชื่อสาธารณะ ที่ resolve ไปเป็นที่อยู่ private อยู่นอกสิ่งที่การตรวจมองเห็น และมีระบุไว้เช่นนั้นในurl-policy.ts - HTTP บนเครื่อง local เป็นไปได้ เฉพาะ เมื่อ
ENVIRONMENT=developmentและ origin ที่ตรงตัวอยู่ในรายการPLUGIN_DEV_ORIGINSไม่มี wildcard ไม่มีทางลัดสำหรับช่วง private ไม่มี fallback ใน production
ปลั๊กอินเป็นผู้กระทำ (actor) ไม่ใช่ผู้ใช้ ไม่มีการสร้างผู้ใช้ปลอม และไม่มีการเขียน installation id ลงใน foreign key ของผู้ใช้
transition ของปลั๊กอินบันทึก installation id, plugin id ที่ได้รับการยอมรับ ชื่อและเวอร์ชัน, เวอร์ชันของ โปรโตคอล, digest ของ idempotency key — ไม่ใช่ตัว key เอง — สถานะผลลัพธ์และ publication pointer และอีเมลของบุคคลที่ delegation ของเขาอนุญาตการกระทำนั้น ตัวตนของ installation อยู่ใน control database และ workflow audit อยู่ใน tenant database โดยไม่มี foreign key ข้ามฐานข้อมูล: event ของ tenant เก็บ snapshot ที่เปลี่ยนแปลงไม่ได้ นั่นคือเหตุผลเช่นกันว่าทำไม การ uninstall จึงไม่สามารถลบการระบุผู้กระทำได้
การกระทำด้านการดูแลระบบ — install, การเปลี่ยน grant, การยอมรับ manifest, rotation, disable, enable,
uninstall — ถูกเขียนลงใน admin audit ledger โดยผู้ใช้ที่กระทำ และจะปฏิเสธ (503
audit_unavailable) แทนที่จะเปลี่ยนแปลงโดยไม่มี audit
Threat model
หัวข้อที่มีชื่อว่า “Threat model”หากปลั๊กอินถูกยึดครองทั้งหมด ผู้โจมตีจะถือ installation token หนึ่งตัวสำหรับไซต์หนึ่งไซต์ และสามารถ:
- อ่านสรุปเนื้อหาแบบแคบของโมเดลที่ได้รับ grant — id, label, slug, version id, สถานะ workflow และ transition ที่ขอบเขต
- เผยแพร่หรือยกเลิกการเผยแพร่เนื้อหาในโมเดลเหล่านั้น แต่ต้องระบุ
delegation_idที่ยังใช้ได้ ซึ่งบุคคลนั้นยังมีสิทธิ์อำนาจอยู่ pin เวอร์ชันที่ตรงตัว และตรงกับ workflow revision ปัจจุบันเท่านั้น - เปิด route แลกเปลี่ยนด้วยโค้ดที่มันถืออยู่แล้ว
มันไม่สามารถ:
- เข้าถึงไซต์อื่น โมเดลอื่น หรือ delegation ของ installation อื่น
- อ่านหรือเขียนค่าฟิลด์ media ผู้ใช้ บทบาท session การตั้งค่า โมเดล หรือ audit
- รันโค้ด render markup หรือเข้าถึงฐานข้อมูล R2 หรือ credential ใด ๆ ของ Cloudflare
- เผยแพร่ draft ที่ใหม่กว่าตัวที่ถูก pin ข้าม workflow ที่เปลี่ยนไปแล้ว override เนื้อหาที่ ขึ้นต่อกัน หรือใช้ override route ใด ๆ — ไม่มีเลยใน protocol v1
- กระทำแทนบุคคลที่เสียสิทธิ์อำนาจไปแล้ว หรือปลอมแปลงบุคคล:
delegation_idคือการค้นหา ไม่ใช่ การยืนยัน - replay การเปลี่ยนแปลงให้เกิดผลครั้งที่สอง หรือซ่อนการเปลี่ยนแปลงจาก audit trail
หาก launch code รั่วไหล — ผ่าน referrer, log หรือ URL ที่แชร์ — มันใช้ได้ครั้งเดียว อายุไม่เกิน 60 วินาที เก็บแบบ hash และไร้ประโยชน์หากไม่มี installation token ที่ใช้แลกเปลี่ยนมัน
หากผู้ดูแลถูกยึดครอง เขาสามารถติดตั้งปลั๊กอินและมอบสิ่งที่ตนถืออยู่ให้มันได้ นั่น
คือความเสี่ยงที่แท้จริงในการออกแบบนี้ และเป็นเหตุผลที่ site:plugins เป็นสิทธิ์แยกต่างหากที่ถูก seed ไว้
เฉพาะในบทบาทผู้ดูแล ไม่มีวันถูกรวมโดยนัยในสิทธิ์เผยแพร่ และเป็นเหตุผลที่ทุกขั้นตอนของ
การติดตั้งถูก audit และทุก grant ถูกเลือกด้วยมือจากคำขอที่ระบุชัดเจนใน manifest
สิ่งที่ Dee Wan ไม่สามารถป้องกันได้ คือการดำเนินงานของปลั๊กอินเอง: uptime นาฬิกา นโยบาย retry สิ่งที่มันเก็บในฐานข้อมูล และมันลบข้อมูลของคุณหรือไม่เมื่อคุณขอ เอกสารของปลั๊กอินเองต้องตอบคำถามเหล่านั้น และผู้ดูแลต้องอ่านมันก่อนจะมอบ grant ใด ๆ ดู ปลั๊กอิน Scheduling สำหรับตัวอย่างว่า สิ่งนั้นมีหน้าตาอย่างไร