
إثبات مفهوم وتحليل لثغرة XSS مخزّنة في مرشّح URL isSafeUrl() الخاص بـ Instatic، حيث تتجاوز أحرف التحكم C0 البادئة حظر مخطط javascript:.
isSafeUrl() عبر أحرف التحكم C0 البادئةالمتأثر: Instatic v0.0.13 / commit 63ad5d6 (وجميع المراجعات الأقدم التي تحتوي على src/core/html-sanitize/index.ts)
المكوّن: src/core/html-sanitize/index.ts → isSafeUrl() / safeUrl()
التصنيف: CWE-79 (XSS مخزّن) عبر CWE-184 (قائمة مدخلات محظورة غير مكتملة)
isSafeUrl() هي نقطة الاختناق الوحيدة التي تحجب عناوين URL من نوع javascript: وvbscript: وdata: في كامل الناشر. وهي تُطبّع المدخلات باستخدام .replace(/[\t\n\r]/g, '').trim() قبل اختبار بادئة المخطط.
يقوم محلّل URL وفق معيار WHATWG بإزالة جميع أحرف التحكم C0 البادئة (U+0000–U+001F) والمسافة قبل قراءة المخطط. بينما تُزيل دالة String.prototype.trim() في JavaScript فقط الأحرف U+0009 وU+000A وU+000B وU+000C وU+000D وU+0020 والمسافات اليونيكودية — وتترك U+0000–U+0008 وU+000E–U+001F في مكانها.
لذلك فإن عنوان URL مسبوق مثلاً بـ U+0001 يُبلَّغ عنه كآمن من قِبل الحارس، بينما يقوم كل متصفح بتحليله وتنفيذه كمخطط javascript:.
مقابل src/core/html-sanitize/index.ts غير المعدّل:
payload : "\x01javascript:alert(document.domain)"
isSafeUrl() : true <-- guard reports "safe"
WHATWG URL scheme : javascript: <-- what the browser actually runs
safeUrl() output : "\x01javascript:alert(document.domain)" (NOT collapsed to "#")
27 من أصل 32 حرف تحكم C0 تتجاوز الفحص. يتم تحييد U+0000 بواسطة تحليل سمات HTML (NUL → U+FFFD)، مما يترك 26 بادئة قابلة للاستغلال بشكل موثوق (U+0001–U+0008، U+000E–U+001F). ويتغلب نفس التجاوز على مرشّحات vbscript: وdata:.
تغطي المجموعة الاختبارية الحالية (src/__tests__/publisher/utils.test.ts) تغيير حالة الأحرف والجدولة المضمّنة (java\tscript:) لكنها لا تغطي أبداً حرف تحكم بادئاً، ولهذا لم يُكتشف هذا.
يُعلن base.link عن href: { type: 'url' } ويُصنّفه LinkPropsSchema كـ Type.String({ default: '#' }) غير مقيّد — لا يوجد تحقق من صحة URL وقت الكتابة. لذلك:
escapeProps() خصائص type: 'url' | 'image' | 'media' إلى isSafeUrl(value) ? value : '#' — فيمرّ الحمولة خاماً وبشكل متعمّد دون تهريب HTML.render() الخاص بـ base.link `<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%24%7BsafeUrl%28props.href%29%7D" …>`. ويعيد safeUrl() الفحص بنفس isSafeUrl() المعطوبة، ثم escapeHtml() — التي تهرب فقط & < > " ' ولا تمس أحرف التحكم.href:
<a href="https://github.com/overgrowncarrot1/instatic-stored-xss-cve-2026-103931/blob/main/%5Cx01javascript%3Aalert%28document.domain%29" target="_self">Click me</a>كل هذه تمر عبر نفس isSafeUrl():
| نقطة الوصول | الملف |
|---|---|
كل خاصية وحدة url / image / media (رابط href، زر href، صورة src، فيديو src/poster، نموذج action، نموذج redirectUrl) | src/core/publisher/escapeProps.ts:108 |
| سمات HTML مخصّصة عشوائية يضبطها المستخدم على أي عقدة | src/core/htmlAttributes/attributes.ts:66 |
رابط/صورة Markdown href وsrc | src/core/markdown/renderMarkdown.ts:75 |
faviconUrl للموقع | src/core/publisher/render.ts:322 |
استدعاء safeUrl() في كل وحدة أساسية | src/modules/base/utils/escape.ts |
تصعيد صلاحيات من محرّر منخفض الصلاحيات إلى اختراق كامل لصلاحيات المسؤول.
يمتلك دور Client المدمج صلاحية site.content.edit، وهي كافية لضبط href لرابط أو سمة HTML مخصّصة. ثم يُعرض عنوان URL المحقون في لوحة محرّر المسؤول، وهي إطار srcdoc iframe — من نفس الأصل مع /admin.
يضبط server/securityHeaders.ts:69-72 فقط frame-ancestors 'none'; base-uri 'self'; object-src 'none' على /admin، مع تعليق برمجي صريح بأن سياسة script-src لم تُضبط بعد بشكل متعمّد. وبدون script-src، لا شيء يمنع عنوان URL من نوع javascript: من التنفيذ على أصل المسؤول.
عندما يفتح Owner أو Admin الصفحة المتأثرة في المحرّر ويُنشّط العنصر، تُنفَّذ الحمولة من نفس الأصل مع تطبيق SPA الخاص بالمسؤول. كوكي الجلسة هو HttpOnly، لذا لا يمكن قراءته مباشرة — لكن الحمولة يمكنها تشغيل واجهة برمجة تطبيقات المسؤول بصفة الضحية (إنشاء حساب owner، قراءة الأسرار، أو تثبيت إضافة، ونقطة دخول خادمها مسار لتنفيذ الكود).
على الموقع المنشور يكون التأثير أضيق: يضبط cspPlan.ts القيمة script-src 'none' (أو 'self')، مما يحجب عناوين URL من نوع javascript:. لكن server/publish/frontendInjections.ts:377 يخفّف ذلك إلى 'self' 'unsafe-inline' لأي صفحة تحمل سكربتاً مضمّناً، و'unsafe-inline' تعيد السماح بعناوين URL من نوع javascript: — لذا يمكن الوصول إلى XSS على الموقع المنشور في تلك الصفحات.
لاحظ أن docstring الخاص بـ attributes.ts يحدّد بالفعل هذا النموذج التهديدي بالضبط ("على الموقع المنشور وأيضاً، وبشكل أخطر، داخل لوحة محرّر المسؤول (من نفس الأصل مع /admin)") — لكن الحارس ببساطة لا ينفّذه بشكل كامل.
تم التأكيد في متصفح (Chromium، إطارات srcdoc iframe تعيد إنتاج CSP لكل أصل، وتعرض المخرجات بايت-ببايت من safeUrl() الخاصة بالمستودع نفسه):
| الأصل المُعاد إنتاجه | CSP المطبّق | النتيجة |
|---|---|---|
لوحة محرّر المسؤول (/admin) | لا شيء — كما يُصدره securityHeaders.ts | تم تنفيذ javascript: |
| صفحة منشورة (خط الأساس) | script-src 'none' — كما يُصدره cspPlan.ts | محجوب (script-src-elem) |
| صفحة منشورة مع سكربت مضمّن | script-src 'self' 'unsafe-inline' — كما يُصدره frontendInjections.ts:377 | انظر الملاحظة |
الصفّان الأولان نتائج مرصودة. أبلغ التنفيذ على أصل المسؤول عن document.domain كأصل الخدمة، مما يؤكد التنفيذ من نفس الأصل وليس من سياق معتم.
تحفّظ بشأن الدقة: تُطبّق أداة الاختبار كل سياسة عبر <meta http-equiv>، بينما يرسلها Instatic كترويسة استجابة HTTP. هذان متكافئان لفرض script-src، لكن إعادة الإنتاج على نسخة bun run dev حيّة ستحمل الترويسات الحقيقية.
جرّد النطاق الكامل C0 + المسافة بدلاً من الاعتماد على trim(). هذا بالضبط ما تفعله تعبير isJavaScriptProtocol الخاص بـ React مع بادئته ^[\u0000-\u001F ]* — وهو تحقق متقاطع مفيد بأن هذه فئة تجاوز معروفة وواقعية وليست نظرية.
--- a/src/core/html-sanitize/index.ts
+++ b/src/core/html-sanitize/index.ts
@@ -30,7 +30,11 @@ export function escapeHtml(value: unknown): string {
* normalisation browsers apply during URL parsing.
*/
export function isSafeUrl(url: string): boolean {
- const normalized = url.replace(/[\t\n\r]/g, '').trim().toLowerCase()
+ const normalized = String(url ?? '')
+ .replace(/[\t\n\r]/g, '')
+ .replace(/^[\u0000-\u0020]+/, '')
+ .replace(/[\u0000-\u0020]+$/, '')
+ .toLowerCase()
return (
!normalized.startsWith('javascript:') &&
!normalized.startsWith('vbscript:') &&
تم التحقق مقابل الملف المرقّع: الحمولة محجوبة، وsafeUrl() تُسقطها إلى #، و0 من أصل 99 عنوان URL خطير مسبوق بتحكم/مسافة يبقى مقبولاً، وجميع حالات اختبار isSafeUrl الـ 14 الحالية تتصرف بشكل مطابق.
it('blocks javascript: behind a leading C0 control character', () => {
for (let i = 0x00; i <= 0x1f; i++) {
expect(isSafeUrl(String.fromCharCode(i) + 'javascript:alert(1)')).toBe(false)
}
})
فكّر أيضاً في ضبط script-src حقيقية على /admin (المتابعة المؤجّلة المذكورة في server/securityHeaders.ts)، والتي ستحتوي فئة الأخطاء بأكملها بدلاً من هذه الحالة الواحدة.