
CVE-2026-19264 - Critical unauthenticated path traversal to full instance takeover in Postiz (< 2.22.1). Technical writeup: decode-order bypass, JWT_SECRET escalation, and analysis of the upstream fix.

المؤلف: Krithik Babu P (@DarkLycn1976)
نُشر: 2026-08-10
CVE: CVE-2026-19264
الخطورة: حرِج - CVSS 4.0 9.3 / CVSS 3.1 9.8
CWE: CWE-22 - تقييد غير سليم لاسم المسار إلى دليل مقيد
متأثر: gitroomhq/postiz-app < 2.22.1
أُصلح في: v2.22.1
يقدّم Postiz الوسائط المخزّنة محليًا عبر مسار يضمّ مقاطع المسار المورّدة في عنوان URL إلى دليل الرفع ثم يبث النتيجة - دون أي تطبيع للمسار، أو فحص احتواء، أو مصادقة.
يُرجع حمولة الاجتياز الواضحة 404، لأن Next.js يطوي مقاطع ../ قبل التوجيه. لكن فواصل الترميز بعنوان URL تنجو من مطابقة المسار وتُفك ترميزها مرة واحدة إضافية بالضبط في الطريق إلى استدعاء نظام الملفات، مما يعيد الاجتياز إلى الجانب الآخر من كل فحص.
يمكن لمهاجم غير مصادَق قراءة أي ملف يمكن لعملية التطبيق قراءته - بما في ذلك بيئته الخاصة، التي تحمل سر توقيع JWT. ولأن Postiz يوقّع رموز الجلسة بذلك السر ويصدرها بدون مطالبة انتهاء الصلاحية، فإن استرجاعه يحوّل بدائية قراءة الملفات إلى جلسة دائمة قابلة للتزوير بأي مستخدم، بما في ذلك المسؤول.
طلب GET واحد غير مصادَق يؤدي إلى السيطرة الكاملة على المثيل.
Postiz هو منصة جدولة وسائل تواصل اجتماعي مفتوحة المصدر - نحو 34,000 نجمة على GitHub وقت كتابة هذا التقرير - مبنيّة بواجهة أمامية Next.js وخلفية NestJS. تستضيفه ذاتيًا وكالات وفرق صغيرة على نطاق واسع لإدارة حسابات التواصل الاجتماعي المتصلة والمحتوى المجدول والفواتير.
يمكن للنشر الذاتي تخزين الوسائط المرفوعة محليًا بدلًا من التخزين الكائني. يتحكم في هذا السلوك متغير بيئة واحد:
STORAGE_PROVIDER=local
هذه هي القيمة المضمّنة في .env.example، لذا فهي ما يشغّله معظم المستضيفين ذاتيًا ما لم يكوّنوا S3 أو Cloudflare R2 عمدًا.
عندما يكون التخزين المحلي مفعّلًا، يعيد next.config.js كتابة المسار العام /uploads/:path* إلى مسار API داخلي:
apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts
خاصيتان تجعلان هذا المسار مثيرًا للاهتمام قبل أي ثغرة:
[[...path]] يعني أن كل مقطع مسار متبقٍ يصل كمصفوفة يمكن للمعالج تفسيرها بحرية.عندما يكون STORAGE_PROVIDER أي قيمة غير local، تشير إعادة الكتابة إلى /404 ويصبح المعالج غير قابل للوصول. بوابة التكوين هذه هي الشيء الوحيد الذي يفصل بين النشر وهذه الثغرة.
المعالج، قبل الإصدار v2.22.1:
export const GET = async (request: NextRequest, context) => {
const { path } = await context.params;
const filePath =
process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
const response = createReadStream(filePath);
const fileStats = statSync(filePath);
// ... stream the file back to the caller
};
ثلاثة عيوب في أربعة أسطر:
path.normalize()، path.resolve() - لا يُستدعى أي منهما. المقاطع الواصلة تُضم حرفيًا كما وصلت.filePath الناتج ما يزال داخل UPLOAD_DIRECTORY.+ '/' + يعامل المكونات كنص، لا كمسار له دلالات.النتيجة تذهب مباشرة إلى createReadStream() وتُبث البايتات إلى المتصل بنوع MIME يُستنتج من اسم الملف. لا توجد قائمة سماح بالامتدادات ولا مرشح محتوى.
الهجوم النموذجي هو:
GET /uploads/../../../etc/passwd
يُرجع هذا 404 على Postiz، وهذا الـ 404 هو السبب الكامل وراء بقاء هذه الثغرة حتى اكتُشفت.
يطبّع Next.js مسار الطلب أثناء التوجيه. تُطوى مقاطع ../ الخام قبل أن يقرر الموجّه أي معالج يستدعي. بحلول وصول الطلب إلى مسار الالتقاط، يكون الاجتياز قد أُزيل بالفعل - إما أن المسار يُحل إلى مكان لا يوجد فيه مسار مطابق، أو يُحل إلى داخل /uploads مع اختفاء المقاطع المنقوطة.
بالنسبة لمن يختبر بسرعة، يقرأ ذلك الـ 404 كأنه "الإطار يعالج هذا". إنه دفاع حقيقي وفعّال. المشكلة ليست في غيابه - بل في مكان تنفيذه في خط الأنابيب.
لا تؤدي مطابقة المسار ومعالج الطلب نفس عدد مرات فك ترميز النسبة المئوية.
إذا كانت الفواصل مشفّرة بالنسبة المئوية، فإن التسلسل ليس فاصل مسار أثناء مطابقة المسار. %2e%2e%2f مجرد سلسلة معتمة - نص خامل ليس لدى المطبّع سبب للمسه. يمر عبر التوجيه سليمًا، يطابقه مسار الالتقاط، ويُفك ترميزه في طريقه إلى params الخاصة بالمعالج، حيث يصبح ../ من جديد.
عند تلك النقطة يُضم إلى UPLOAD_DIRECTORY ويُسلَّم إلى createReadStream() - متجاوزًا التوجيه والتطبيع وكل ضابط كان سيمنعه.
أشكال مجدية:
GET /uploads/%2e%2e%2fsecretdir%2fsecret.txt → 200, ملف خارج دليل الرفع
GET /uploads/..%2f..%2f..%2fetc%2fpasswd → 200
GET /uploads/%2e%2e%2f%2e%2e%2f...%2fetc%2fpasswd → 200, أعاد ملف /etc/passwd الفعلي
الترميز المضاعف لا ينجح - يبقى %252e حرفيًا عبر مرحلة فك الترميز الوحيدة ولا يصبح نقطة أبدًا. طبقة واحدة بالضبط من الترميز هي النقطة المثلى، وهو تذكير مفيد بأن "شدد الترميز أكثر" ليس استراتيجية.
الثابت الذي يجب التمسك به:
الضابط الذي يعمل قبل اكتمال فك الترميز لا يحمي نقطة الوصول (sink).
بدائية قراءة الملفات هي ثغرة عالية بحد ذاتها. ما يجعلها حرجة هو ما تصل إليه.
الخطوة 1 - قراءة البيئة. إعدادات عملية Node نفسها موجودة على القرص في جذر النشر. يوفّر .env، من بين أمور أخرى:
JWT_SECRET - مفتاح توقيع رموز الجلسةDATABASE_URL - بيانات اعتماد Postgres كاملةالخطوة 2 - تزوير جلسة. يوقّع Postiz رموز الجلسة بـ JWT_SECRET باستخدام HS256 عبر jsonwebtoken. والأهم أن الرموز تُصدر بدون expiresIn، لذا يكون الرمز المزوّر صالحًا إلى أجل غير مسمى.
الخطوة 3 - كن أي شخص. يعيد برمجية المصادقة الوسيطة حل المستخدم من قاعدة البيانات باستخدام مطالبة id. وهي عمدًا لا تثق بمطالبة مثل isSuperAdmin من الرمز - تصميم جيد - لكن هذا التحصين غير ذي صلة بمجرد أن تتمكن من توقيع id عشوائي. توقيع { id: <معرف المستخدم الضحية> } ينتج جلسة لا يمكن تمييزها عن تسجيل دخول مشروع:
read .env → JWT_SECRET → sign({ id: victim }) → authenticated as victim, forever
تحققت من هذا مقابل منطق التحقق الحقيقي للمشروع مع اعتماد jsonwebtoken الفعلي: قُبل رمز موقّع بالسر المسترجع، ورُفض رمز موقّع بسر خاطئ. حالة التحكم مهمة - بدونها لديك افتراض، لا اكتشاف.
الخطوة 4 - المسار الموازي. DATABASE_URL وحده كافٍ للوصول المباشر إلى Postgres: اقرأ كل حساب متصل، أو اقلب علامة المسؤول مباشرة.
لا كلمة مرور. لا وصول سابق. لا تفاعل مستخدم. طلب HTTP واحد غير مصادَق.
CVSS 4.0 9.3 AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1 9.8 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
PR:N وUI:N هما المقياسان اللذان يقومان بالعمل. المسار لا يتطلب جلسة ولا تفاعل ضحية - المهاجم يعمل وحده، عبر الشبكة، ضد تكوين افتراضي.
الحد الوحيد الصادق هو بوابة التكوين: النشر على S3 أو R2 غير معرض، لأن المسار يُعاد كتابته إلى /404. هذا يقلل عدد المتأثرين لكنه لا يقلل الخطورة لأي شخص داخلهم - وlocal هي الافتراضي المُسلَّم.
تصحيح المطوّرين (7936062) ثمانية أسطر ويستحق القراءة، لأنه صحيح بطريقة لا تكون عليها هذه الإصلاحات غالبًا:
+import { resolve, sep } from 'path';
...
- const filePath =
- process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
+ const base = resolve(process.env.UPLOAD_DIRECTORY!);
+ const filePath = resolve(base, (path ?? []).join('/'));
+ // Confine reads to UPLOAD_DIRECTORY. resolve() collapses any `..` segments
+ // (including URL-decoded ones), so this blocks every path-traversal variant.
+ if (filePath !== base && !filePath.startsWith(base + sep)) {
+ return new NextResponse('Not found', { status: 404 });
+ }
شيئان فعلهما بشكل صحيح:
resolve() يطوي .. بعد اكتمال فك الترميز، لذا لا يهم كيف هُرّب الاجتياز عبر التوجيه. الفحص الآن في مكان الخطر.base + sep، لا بـ base. قبول filePath.startsWith(base) ساذجًا كان سيقبل /app/uploads-evil/x كأنه داخل /app/uploads - تجاوز كلاسيكي لمطابقة البادئة. إلحاق الفاصل يسدّ هذا الثغر، وجملة filePath !== base تحافظ على صلاحية الدليل نفسه.هذا هو الشكل الصحيح لفحص الاحتواء: حلّ، ثم قارن بالقاعدة مع فاصل لاحق.
كل الأوقات UTC، 2026-07-20 ما لم يُذكر خلاف ذلك.
| الوقت | الحدث |
|---|---|
| 05:55 | الإبلاغ عن الاستشارة الأمنية لفريق Postiz |
| 07:44 | أقرّ المطوّرون بالثغرة وتحققوا منها |
ست ساعات وثلاث وعشرون دقيقة من التقرير إلى إصدار التصحيح، على مشروع مفتوح المصدر بلا برنامج مكافآت مرتبط. رأيت تقارير تبقى دون معالجة لأشهر في مؤسسات لديها فرق أمنية مخصصة. الفضل لـ Enno Gelhaus للتنسيق وNevo David للمعالجة.
اجتياز ضابط لاختبارك لا يعني أن الضابط في المكان الصحيح. الـ 404 كان حقيقيًا. Next.js يطوي ../ فعلًا. الدفاع ببساطة عمل قبل اكتمال فك ترميز المدخلات، ما يعني أنه كان يحرس الموجّه لا استدعاء نظام الملفات. عندما تجد تخفيفًا، اسأل متى ينفَّذ بالنسبة لنقطة الوصول (sink) - ليس فقط إن كان موجودًا.
الترميز طبقة، والطبقات تُقشر بمعدلات مختلفة. كلما اختلف مكوّنان في خط أنابيب الطلب حول عدد مرات فك الترميز، كانت الفجوة بينهما قابلة للاستغلال. مطابقو المسارات والبرمجيات الوسيطة والمعالجات يختلفون غالبًا.
قيّم بدائية قراءة الملفات بما يمكن للعملية الوصول إليه، لا بالبدائية نفسها. "قراءة ملفات عشوائية" تبدو ككشف معلومات. أصبحت حرجة لأن البيئة كانت قابلة للقراءة، والسر فيها يوقّع الجلسات، وتلك الجلسات لا تنتهي أبدًا. اتبع السلسلة قبل تقييمها.
الرموز غير المنتهية تحوّل التسريب إلى اختراق دائم. كشف مفتاح توقيع مع رموز قصيرة العمر هو يوم سيء. بدون expiresIn، لا يمكن الاسترداد دون تدوير السر - ومعظم المشغلين لن يعرفوا أبدًا أنهم بحاجة إلى ذلك.
شغّل حالة التحكم. التحقق من أن رمزًا موقّعًا بالسر الخاطئ مرفوض هو ما يفصل اكتشافًا مُثبتًا عن مفترض.
إذا كنت تستضيف Postiz ذاتيًا:
JWT_SECRET مخترق إذا شغّلت إصدارًا متأثرًا على مضيف متاح عمومًا مع STORAGE_PROVIDER=local. دَوّره. ولأن الرموز لا تحمل انتهاء صلاحية، فإن التدوير هو الطريقة الوحيدة لإبطال أي رمز مُزوّر.DATABASE_URL وأي أسرار OAuth للمزود المتصل المخزنة في نفس البيئة.GET إلى /uploads/ تحتوي على %2e أو %2f.بحث أُجري باستقلالية وأُفصح عنه للبائع ضمن إفصاح منسق. نُشر بعد إصدار الإصلاح ونشر الاستشارة. لم تُصل إلى أي أنظمة طرف ثالث - كل التحقق تم ضد مثيل محلي مبني من مصدر المشروع نفسه.
هذا التقرير مرخّص بموجب CC BY 4.0 - شاركه وعدّله بحرية مع الإسناد. مقتطفات الكود من gitroomhq/postiz-app مقتبسة للتحليل الأمني وتبقى خاضعة لترخيص ذلك المشروع.
Krithik Babu P - @DarkLycn1976
| 12:18 | تم الالتزام بالإصلاح والتحقق منه ونشره |
| 2026-08-07 14:13 | CVE-2026-19264 معيّنة من قبل Postiz (CNA) |
| 2026-08-07 14:15 | نشر GitHub Security Advisory |