
Instatic के isSafeUrl() URL फ़िल्टर में एक stored XSS का proof-of-concept और विश्लेषण, जहाँ leading C0 control characters javascript: scheme blocking को bypass कर देते हैं।
isSafeUrl() में अग्रणी C0 नियंत्रण वर्णों के माध्यम से URL स्कीम फ़िल्टर बायपासप्रभावित: Instatic v0.0.13 / commit 63ad5d6 (और src/core/html-sanitize/index.ts युक्त सभी पूर्व संशोधन)
घटक: src/core/html-sanitize/index.ts → isSafeUrl() / safeUrl()
वर्ग: CWE-79 (Stored XSS) via CWE-184 (Incomplete List of Disallowed Input)
isSafeUrl() वह एकल चोकपॉइंट है जो पूरे पब्लिशर में javascript:, vbscript:
और data: URL को ब्लॉक करता है। यह स्कीम प्रीफ़िक्स का परीक्षण करने से पहले
इनपुट को .replace(/[\t\n\r]/g, '').trim() से सामान्यीकृत करता है।
WHATWG URL पार्सर स्कीम पढ़ने से पहले सभी अग्रणी C0 नियंत्रण वर्णों
(U+0000–U+001F) और स्पेस को हटा देता है। JavaScript का
String.prototype.trim() केवल U+0009, U+000A, U+000B, U+000C, U+000D,
U+0020 और Unicode स्पेस को हटाता है — यह U+0000–U+0008 और U+000E–U+001F
को यथावत छोड़ देता है।
इसलिए U+0001 जैसे वर्ण से प्रीफ़िक्स किया गया URL गार्ड द्वारा सुरक्षित बताया
जाता है, जबकि हर ब्राउज़र इसे 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 "#")
32 C0 नियंत्रण वर्णों में से 27 इस जाँच को बायपास करते हैं। 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-एस्केप्ड पास हो जाता है।base.link का render() `<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 मॉड्यूल प्रॉप (link href, button href, image src, video src/poster, form action, form 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 /admin पर केवल
frame-ancestors 'none'; base-uri 'self'; object-src 'none' सेट करता है, साथ
में एक स्पष्ट कोड टिप्पणी है कि script-src नीति जानबूझकर अभी तक सेट नहीं
की गई है। script-src के बिना, कोई भी चीज़ javascript: URL को एडमिन
ओरिजिन पर निष्पादित होने से नहीं रोकती।
जब कोई Owner या Admin प्रभावित पृष्ठ को एडिटर में खोलता है और तत्व को सक्रिय
करता है, तो पेलोड एडमिन SPA के साथ समान-ओरिजिन पर चलता है। सेशन कुकी
HttpOnly है, इसलिए इसे सीधे पढ़ा नहीं जा सकता — लेकिन पेलोड पीड़ित के रूप में
एडमिन API को चला सकता है (एक owner खाता बनाएँ, रहस्य पढ़ें, या एक प्लगइन
इंस्टॉल करें, जिसका सर्वर एंट्रीपॉइंट कोड निष्पादन का मार्ग है)।
प्रकाशित साइट पर प्रभाव संकीर्ण है: cspPlan.ts script-src 'none' (या
'self') सेट करता है, जो javascript: URL को ब्लॉक करता है। हालाँकि
server/publish/frontendInjections.ts:377 इसे किसी भी इनलाइन स्क्रिप्ट वाले
पृष्ठ के लिए 'self' 'unsafe-inline' तक ढीला कर देता है, और 'unsafe-inline'
javascript: URL को पुनः अनुमति देता है — इसलिए उन पृष्ठों पर प्रकाशित-साइट
XSS पहुँच योग्य है।
ध्यान दें कि attributes.ts डॉकस्ट्रिंग पहले से ही इस सटीक खतरे के मॉडल की
पहचान करती है ("प्रकाशित साइट पर और, अधिक गंभीर रूप से, एडमिन एडिटर कैनवास के
अंदर (/admin के साथ समान-ओरिजिन)") — गार्ड बस इसे पूरी तरह लागू नहीं करता।
ब्राउज़र में पुष्टि की गई (Chromium, प्रत्येक ओरिजिन के CSP को पुनरुत्पादित
करने वाले srcdoc iframes, रेपो के स्वयं के 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 इंस्टेंस
पर पुनरुत्पादन असली हेडर ले जाएगा।
trim() पर निर्भर रहने के बजाय पूरी C0 + स्पेस रेंज को हटाएँ। यह ठीक वही है
जो React का स्वयं का isJavaScriptProtocol रेगेक्स अपने ^[\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() इसे # में
संकुचित कर देता है, 99 नियंत्रण/स्पेस-प्रीफ़िक्स्ड खतरनाक URL में से 0 स्वीकार
रह जाते हैं, और सभी 14 मौजूदा isSafeUrl टेस्ट केस समान व्यवहार करते हैं।
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)
}
})
/admin पर एक वास्तविक script-src सेट करने पर भी विचार करें (जो
server/securityHeaders.ts में उल्लिखित स्थगित फ़ॉलो-अप है), जो इस एक
उदाहरण के बजाय पूरे बग वर्ग को सीमित कर देगा।