
يمكن لمستخدم Docmost ذو صلاحية منخفضة تقديم attachmentId ضحية إلى نقطة نهاية التحميل العامة واستبدال المرفق المخزن لصفحة أخرى داخل نفس مساحة العمل.
يمكن لمستخدم Docmost ذي صلاحيات منخفضة توفير attachmentId خاص بالضحية إلى نقطة نهاية التحميل العامة والكتابة فوق مرفق مخزّن في صفحة أخرى داخل نفس مساحة العمل.
لقد حددت وكشفت بشكل مسؤول وأعدت إنتاج ثغرة في التفويض عالية الخطورة في Docmost، منصة التوثيق التعاوني مفتوحة المصدر.
يقدم الموقع الرسمي لـ Docmost نفسه كويكي محلي جاهز للمؤسسات مع أكثر من 3 ملايين تحميل، ويذكر أنه موثوق من قبل فرق في مؤسسات تشمل مدينة فيلنيوس، Bechtle، الحكومة الأسترالية، الصليب الأحمر، وETS Quebec.
كان الخلل موجودًا في مسار تحميل الملفات العام الذي يستخدمه Docmost أيضًا لتدفقات حفظ/تحديث المخططات.
كنت أراجع ذلك الكود مع سؤال محدد جدًا في ذهني:
ماذا يحدث إذا أثبتت نقطة نهاية التحميل صلاحية التحرير في صفحة واحدة، ولكن الهدف المراد الكتابة فوقه يتم تحديده باستخدام معرف مرفق منفصل يتحكم به المستخدم؟
في هذه الحالة، أدى هذا السؤال مباشرة إلى فشل حقيقي في ربط الكائنات.
سمح Docmost للمتصل بإرسال:
pageId لصفحة يُسمح له بتحريرها، وattachmentId ينتمي إلى صفحة مختلفة في نفس مساحة العملقام الخادم بالفعل بإجراء فحص تناسق للكتابة فوق، لكن الحارس استخدم منطقًا منطقيًا خاطئًا.
هذا يعني أن الطلب يمكن أن يمر عبر التفويض ويكتب فوق مرفق الضحية على أي حال.
أصبحت هذه المشكلة CVE-2026-34213.
Docmost: docmost/docmost
الاستشارة:
CVE-2026-34213
v0.71.0
pageId يتحكم به المهاجم مع صلاحية تحرير -> attachmentId ضحية يتحكم به المهاجم -> حارس كتابة فوق معيب يعالج الكتابة فوق عبر الصفحات على أنها صالحة -> إعادة بناء مسار التخزين من attachmentId الضحية -> بايتات المهاجم تحل محل ملف الضحية -> تستمر صفحة الضحية في خدمة المرفق المعدل
يقوم Docmost بتخزين المرفقات المرفوعة للصفحات كسجلات قاعدة بيانات بالإضافة إلى ملفات داعمة في التخزين.
بالنسبة للتحميلات العادية، يقوم الخادم بإنشاء معرف مرفق جديد وكتابة ملف جديد.
بالنسبة لتدفقات حفظ/تحديث المخططات، يعيد العميل استخدام attachmentId موجود عن قصد بحيث يمكن تحديث نفس ملف المخطط في مكانه بدلاً من إنشاء سجل مرفق جديد في كل مرة.
هذا السلوك مشروع في حد ذاته.
المشكلة هي أنه يُنشئ مسارًا عالي المخاطرة:
عندما تخلط نقطة نهاية بين هاتين المسؤوليتين، يجب على التنفيذ ربطهما معًا بالضبط.
لم يفعل Docmost ذلك.
نقاط النهاية المختلطة للإنشاء/التحديث هي أماكن شائعة لأخطاء التفويض.
السبب بسيط:
هذا هو النمط بالضبط هنا.
قام POST /api/files/upload بالتحقق من أن المتصل يمكنه تحرير الصفحة المسماة بـ pageId.
ولكن إذا تم توفير attachmentId أيضًا، تحول الخادم إلى مسار الكتابة فوق وحدد سجل مرفق موجود بشكل منفصل.
هذا جعل السؤال الأمني الحرج هو:
هل يُثبت مسار الكتابة فوق أن المرفق المحدد ينتمي بالفعل إلى الصفحة المفوّضة؟
كانت الإجابة في الإصدارات الضعيفة: لا.
كان السبب الجذري تجاوز التفويض من خلال مفتاح يتحكم به المستخدم، بالإضافة إلى خطأ في المنطق المنطقي في حارس الكتابة فوق.
بدا التدفق الضعيف كالتالي:
AttachmentController.uploadFile() قرأ pageId من بيانات النموذج متعدد الأجزاء.validateCanEdit(page, user).attachmentId اختياريًا من نفس الطلب.AttachmentService.uploadFile() قام بتحميل المرفق الموجود عبر ذلك المعرف الذي قدمه المهاجم.&& بدلاً من الرفض عند أي عدم تطابق.كان الحارس الضعيف:
if (
existingAttachment.pageId !== pageId &&
existingAttachment.fileExt !== preparedFile.fileExtension &&
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
هذا الشرط يرفض الطلب فقط إذا:
كل ذلك في نفس الوقت.
هذا هو عكس ما يجب أن يفعله حارس الكتابة فوق.
بالنسبة لحالة الهجوم الحقيقية، بقي المهاجم عن قصد داخل نفس مساحة العمل.
لذا:
existingAttachment.workspaceId !== workspaceId كانت falseبمجرد أن يصبح هذا المعامل false، تم تقييم شرط && بالكامل إلى false، حتى لو كان المرفق ينتمي إلى صفحة مختلفة.
لذا تعامل الخادم مع الكتابة فوق عبر الصفحات على أنها صالحة.
كان هذا هو النصف الأول من الخلل.
النصف الثاني هو ما جعل التأثير حقيقيًا.
بعد الفحص، أعادت الخدمة بناء مسار التخزين الوجهة باستخدام attachmentId واسم الملف اللذين قدمهما المهاجم:
const filePath =
`${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/` +
`${attachmentId}/${preparedFile.fileName}`;
ثم، في مسار التحديث، قام Docmost فقط بتحديث البيانات الوصفية القابلة للتغيير مثل:
fileSizeupdatedAtلم يقم بإعادة ربط الملكية بصفحة المهاجم.
لذا استمرت صفحة الضحية في الإشارة إلى نفس سجل المرفق ونفس معرف المرفق. فقط بايتات الملف الأساسية تغيرت.
لهذا لم يكن هذا مجرد عدم تطابق غير ضار.
كانت بدائية كتابة فوق غير مصرح بها ودائمة.
لم يكن هذا خطأ تجميليًا ولم يكن مشكلة تضارب في اسم الملف.
لم يحتاج المهاجم إلى سباق. لم يحتاج المهاجم إلى تخمين مسار عشوائي. لم يحتاج المهاجم إلى صلاحية كتابة على صفحة الضحية.
كل ما احتاجه هو:
من هناك، يمكنه استبدال بايتات الملف المخزنة لمرفق صفحة أخرى بينما تستمر صفحة الضحية في الإشارة إلى ذلك المرفق وخدمته كما لو لم يتغير شيء.
هذا فشل مباشر في التكامل.
من الناحية العملية، يمكن للمهاجم:
النقطة المهمة هي هذه:
قبل الخادم هدفًا للكتابة فوق اختاره المهاجم، دون ربطه بالصفحة التي تم التحقق من صلاحية تحريرها بالفعل.
هذا فشل في التحكم بالوصول، وليس مجرد سوء النظافة المنطقية.
كان الاستغلال عمليًا بشكل خاص لمرفقات المخططات.
يعيد عميل Docmost استخدام attachmentId عن قصد لحفظ المخططات ويستخدم أسماء ملفات حتمية:
diagram.excalidraw.svgdiagram.drawio.svgهذا مهم لأنه يقلل من متطلبات المهاجم.
بالنسبة للمرفقات العامة، يحتاج المهاجم إلى كليهما:
بالنسبة للمخططات، اسم الملف متوقع بالفعل.
لذا إذا كان بإمكان المهاجم قراءة محتوى صفحة الضحية، فغالبًا ما يمكنه استرداد القطعة المفقودة الوحيدة التي يحتاجها:
attachmentId الضحيةفي إعداد التحقق الخاص بي، استخدمت هذا المسار بالضبط:
كان ذلك كافياً.
عبر الاستغلال حدود الصفحات وحدود المساحات داخل نفس مساحة العمل، مع استمرار تلبية فحص مساحة العمل المعيب.
لقد تحققت من المشكلة مباشرة ضد Docmost v0.70.3 باستخدام مختبر قابل للتصميم مبني من docmost/docmost:0.70.3 و Postgres و Redis.
كان تدفق PoC كالتالي:
attachmentId الضحية.POST /api/files/upload مع:
pageId = معرف صفحة المهاجمattachmentId = معرف مرفق الضحيةfile = ملف بديل يتحكم به المهاجم باستخدام اسم ملف الضحيةكان شكل الطلب الأدنى:
POST /api/files/upload
Content-Type: multipart/form-data
pageId=<attackerPageId>
attachmentId=<victimAttachmentId>
[email protected];filename=diagram.excalidraw.svg
كانت النتيجة الحية المرصودة:
019d18ae-b176-751c-8525-b5f3cede131d019d18ae-b15b-70e9-ac67-64948e87cc5e019d18ae-b12f-75ec-8c1c-5aff3ba6be9c200 OK686a0a0ede90ece1cbb975bb29304a6c3a90373a9c3ab2496345cf7ca59cc8fa
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
e0168298846cdaf75c4d880f4b721d7c0ef0ef310f75617bf2b833af34cdbeba
بديل المهاجم من صفحة أخرى
هذا إثبات كامل للكتابة فوق من البداية إلى النهاية، وليس مجرد مراجعة مصدر نظرية.
استخدمت نمطين من الإثبات أثناء التقييم:
كانت الأداة المستقلة مفيدة لعزل فشل المنطق المنطقي.
كان PoC الحي عبر HTTP هو الأثر الأقوى لأنه أثبت القصة الأمنية الكاملة:
attachmentId الضحيةهذا التمييز مهم في أخطاء التحكم بالوصول.
"الشرط خاطئ" ليس كافيًا بحد ذاته.
"الشرط خاطئ، ويمكن قيادة التطبيق من البداية إلى النهاية إلى كتابة فوق غير مصرح بها ودائمة" هو الحالة الكاملة.
تم شحن الإصلاح في v0.71.0 وقام بتغيير حارس الكتابة فوق من && إلى ||:
if (
existingAttachment.pageId !== pageId ||
existingAttachment.fileExt !== preparedFile.fileExtension ||
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException("File attachment does not match");
}
هذا التصحيح بسيط ومباشر وصحيح للخلل الذي تم الإبلاغ عنه.
يعيد القاعدة الصحيحة:
الكتابة فوق مسموح بها فقط عندما يطابق المرفق الموجود تمامًا افتراضات الصفحة/مساحة العمل/النوع المفوّضة.
بمجرد أن يرفض الحارس أي عدم تطابق:
كان هذا النوع الصحيح من الإصلاح:
مجرد ربط صارم بين الصفحة المفوّضة وهدف الكتابة فوق.
لا يزال هناك درس هندسي أوسع هنا:
يجب معاملة نقاط نهاية التحميل العامة التي تخدم أيضًا تدفقات التحديث الموضعي كأسطح API عالية المخاطر.
حتى عند إصلاح الخلل الفوري، فإن التصميمات الأقوى على المدى الطويل هي:
لكن بالنسبة للثغرة نفسها، أغلق التصحيح المنشور المشكلة الأساسية بشكل نظيف.
سواء أضاف المشروع اختباراته الخاصة الخاصة بالإصلاح أم لا، فهذه هي الحالات المهمة للتغطية طويلة المدى:
الهدف من هذه الاختبارات ليس فقط الصحة.
هو تثبيت ربط التفويض بحيث لا تؤدي إعادة هيكلة التحميل "المفيدة" المستقبلية إلى إعادة فتح نفس فئة الخلل.
صنفت الاستشارة المنشورة هذه المشكلة على أنها:
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L
هذا يصل إلى 7.1 / عالية، وهو الاستنتاج الصحيح.
المقياس المهم هنا هو التكامل.
لم يكن هذا خطأ بيانات وصفية منخفض المستوى. سيطر المهاجم بشكل كامل على بايتات الاستبدال المكتوبة في مسار مرفق صفحة أخرى، واستمرت صفحة الضحية في خدمة الكائن المعدل بعد ذلك.
هذا هو بالضبط نوع العبث المسجل عبر السجلات الذي يستحق تكامل عالي.
تبقى التوفر منخفضة منطقية أيضًا لأن إتلاف مخطط أو مستند مرفق يمكن أن يجعل محتوى الضحية غير قابل للاستخدام، ولكن التأثير الأساسي لا يزال تعديلًا غير مصرح به بدلاً من تعطيل الخدمة بالكامل.
أبلغت عن المشكلة بشكل خاص من خلال استشارات أمان GitHub مع:
تم قبول المشكلة من قبل المشرف، وتم تعيين CVE-2026-34213، ونشرت في 14 أبريل 2026.
تسرد الاستشارة العامة:
>= v0.3.0v0.71.0تطابق هذا التاريخ أيضًا مع مراجعة المصدر المحلية الخاصة بي: كان منطق الكتابة فوق الضعيف موجودًا في أقدم إصدار تم إصداره قمت بفحصه في السطر الضعيف.
الدرس المثير للاهتمام هنا ليس مجرد "استخدام || بدلاً من &&."
هذا هو العرض.
الدرس الأعمق هو:
إذا أثبت حقل واحد يتحكم به المستخدم التفويض وحدد حقل آخر يتحكم به المستخدم الكائن الذي يتم تحديثه، يجب ربط هذين الحقلين معًا بشكل صريح ودقيق.
تظهر هذه القاعدة في كل مكان:
في اللحظة التي يقول فيها النظام:
فقد خلق حدًا أمنيًا يجب فرضه باستخدام ثوابت تطابق تام.
أي شيء أقل من ذلك يتحول إلى خطأ مفتاح يتحكم به المستخدم عاجلاً أم آجلاً.
تعزز هذه المشكلة أيضًا نقطة ثانية يسهل التقليل من شأنها:
يمكن أن يكون للأخطاء المنطقية الصغيرة في كود الحارس عواقب أمنية من الدرجة الأولى.
كان شرط ثلاثي يبدو "معقولاً" للوهلة الأولى كافياً لعكس نموذج الحماية لمسار الكتابة فوق.
لهذا السبب تستحق هذه الأسطح مراجعة متعمدة بدلاً من الثقة العابرة.
pageId الذي قدمه المتصل، لكن اختيار هدف الكتابة فوق استخدم attachmentId منفصلًا قدمه المتصل.v0.71.0 بتغيير الحارس بشكل صحيح لرفض أي عدم تطابق.لم تكن هذه الثغرة تتعلق بسلوك تخزين غريب.
كانت تتعلق بمسار تحديث يثق في معرف كائن يختاره المهاجم أكثر مما ينبغي.
أثبت Docmost صلاحية التحرير في صفحة واحدة، وقبل معرف مرفق موجود من صفحة أخرى، ثم ترك فحص كتابة فوق معيب يحول هذا عدم التطابق إلى استبدال ملف ناجح عبر الصفحات.
لهذا أصبحت CVE-2026-34213.
أصلح التصحيح في v0.71.0 المشكلة الفورية بشكل نظيف، لكن الدرس الأوسع لا يزال قيماً:
عندما يتم تقسيم التفويض واختيار الكائن عبر حقول منفصلة يتحكم بها المستخدم، فإن الربط الدقيق هو خاصية الأمان.