|
2856 | 2856 | } |
2857 | 2857 | } |
2858 | 2858 | ); |
| 2859 | + |
| 2860 | + /* ==================================================================== |
| 2861 | + * Claim-validation tests for the 5 findings in Dompurify_finding.md |
| 2862 | + * and the 8 DoS payloads in Dompurify_exceptions.md. |
| 2863 | + * |
| 2864 | + * Pass/fail semantics: |
| 2865 | + * - PASS = claim does NOT reproduce (DOMPurify is safe or already fixed) |
| 2866 | + * - FAIL = claim reproduces (real issue present, needs a patch) |
| 2867 | + * |
| 2868 | + * Drop this block into test/test-suite.js just before the closing |
| 2869 | + * }; |
| 2870 | + * }); |
| 2871 | + * at the end of the file. Then `npm test` (or npm run test:jsdom) will |
| 2872 | + * include it. |
| 2873 | + * |
| 2874 | + * If the browser and jsdom disagree, that itself is useful info. |
| 2875 | + * ==================================================================== */ |
| 2876 | + |
| 2877 | + QUnit.module('Finding #1: _forceRemove DoS (Dompurify_exceptions.md)'); |
| 2878 | + |
| 2879 | + /* Eight payloads from Dompurify_exceptions.md. Each claim is that |
| 2880 | + * sanitize() throws — a real exception escaping the call site, which |
| 2881 | + * a caller would see as a DoS. If any of these throws, finding #1 |
| 2882 | + * reproduces and needs a patch (wrap the final remove() call at |
| 2883 | + * purify.ts:932 in a second try/catch, or use remove() as the primary |
| 2884 | + * path instead of parent.removeChild). |
| 2885 | + * |
| 2886 | + * Implementation note: we compare against a sentinel object so we |
| 2887 | + * distinguish "threw" from "returned undefined/empty string". */ |
| 2888 | + const DOS_PAYLOADS = [ |
| 2889 | + { |
| 2890 | + title: |
| 2891 | + 'HTML: form with clobbered firstElementChild + innerHTML + textContent', |
| 2892 | + payload: |
| 2893 | + '<form><input name=firstElementChild><input name=innerHTML><input name=textContent></form>', |
| 2894 | + config: {}, |
| 2895 | + }, |
| 2896 | + { |
| 2897 | + title: 'HTML: form with clobbered attributes (single input)', |
| 2898 | + payload: '<form><input name=attributes></form>', |
| 2899 | + config: {}, |
| 2900 | + }, |
| 2901 | + { |
| 2902 | + title: 'HTML: form with clobbered attributes (two inputs)', |
| 2903 | + payload: '<form><input name=attributes><input name=attributes></form>', |
| 2904 | + config: {}, |
| 2905 | + }, |
| 2906 | + { |
| 2907 | + title: 'HTML: form onclick + clobbered attributes (two inputs)', |
| 2908 | + payload: |
| 2909 | + '<form onclick=alert(1)><input name=attributes><input name=attributes></form>', |
| 2910 | + config: {}, |
| 2911 | + }, |
| 2912 | + { |
| 2913 | + title: 'XHTML: form with CDATA (mXSS regexes both match)', |
| 2914 | + payload: '<form id="f"><![CDATA[<img src=x onerror=alert(1)>]]></form>', |
| 2915 | + config: { PARSER_MEDIA_TYPE: 'application/xhtml+xml' }, |
| 2916 | + }, |
| 2917 | + { |
| 2918 | + title: |
| 2919 | + 'XHTML: form with text + PI (textContent + innerHTML regexes match)', |
| 2920 | + payload: '<form id="f"><a<?x <img src=x onerror=alert(1)?></form>', |
| 2921 | + config: { PARSER_MEDIA_TYPE: 'application/xhtml+xml' }, |
| 2922 | + }, |
| 2923 | + { |
| 2924 | + title: 'XHTML: form with text + CDATA', |
| 2925 | + payload: |
| 2926 | + '<form id="f"><a<![CDATA[<img src=x onerror=alert(1)>]]></form>', |
| 2927 | + config: { PARSER_MEDIA_TYPE: 'application/xhtml+xml' }, |
| 2928 | + }, |
| 2929 | + { |
| 2930 | + title: 'XHTML: form with PI + CDATA', |
| 2931 | + payload: |
| 2932 | + '<form id="f"><?a <x?><![CDATA[<img onerror=alert(1)>]]></form>', |
| 2933 | + config: { PARSER_MEDIA_TYPE: 'application/xhtml+xml' }, |
| 2934 | + }, |
| 2935 | + ]; |
| 2936 | + |
| 2937 | + DOS_PAYLOADS.forEach((tc) => { |
| 2938 | + QUnit.test(`#1 DoS: ${tc.title}`, (assert) => { |
| 2939 | + let threw = false; |
| 2940 | + let errorMsg = ''; |
| 2941 | + let result; |
| 2942 | + try { |
| 2943 | + result = DOMPurify.sanitize(tc.payload, tc.config); |
| 2944 | + } catch (e) { |
| 2945 | + threw = true; |
| 2946 | + errorMsg = (e && (e.stack || e.message)) || String(e); |
| 2947 | + } |
| 2948 | + assert.notOk( |
| 2949 | + threw, |
| 2950 | + threw |
| 2951 | + ? `REPRODUCES — sanitize() threw:\n ${errorMsg}` |
| 2952 | + : `safe — sanitize() returned: ${JSON.stringify(result)}` |
| 2953 | + ); |
| 2954 | + }); |
| 2955 | + }); |
| 2956 | + |
| 2957 | + QUnit.module('Finding #1 bonus: sanitize-for is robust under clobbering'); |
| 2958 | + |
| 2959 | + /* Even if sanitize() returns safely, confirm the clobbering payload |
| 2960 | + * doesn't leave dangerous residue. These assertions are advisory; |
| 2961 | + * the DoS test above is the primary signal. */ |
| 2962 | + QUnit.test( |
| 2963 | + '#1: HTML form-clobber output contains no event handler', |
| 2964 | + (assert) => { |
| 2965 | + const out = DOMPurify.sanitize( |
| 2966 | + '<form onclick=alert(1)><input name=attributes><input name=attributes></form>' |
| 2967 | + ); |
| 2968 | + assert.notOk(/onclick/i.test(out), `output retains onclick: ${out}`); |
| 2969 | + } |
| 2970 | + ); |
| 2971 | + |
| 2972 | + QUnit.module( |
| 2973 | + 'Finding #2: attribute-breakout regex coverage (listing/plaintext)' |
| 2974 | + ); |
| 2975 | + |
| 2976 | + function attrValueAfterSanitize(input, attrName) { |
| 2977 | + const clean = DOMPurify.sanitize(input); |
| 2978 | + const probe = document.createElement('div'); |
| 2979 | + probe.innerHTML = clean; |
| 2980 | + const el = probe.querySelector('[' + attrName + ']'); |
| 2981 | + return el ? el.getAttribute(attrName) : null; |
| 2982 | + } |
| 2983 | + |
| 2984 | + QUnit.test( |
| 2985 | + '#2 control: </style> IS stripped (regex works for listed tags)', |
| 2986 | + (assert) => { |
| 2987 | + const value = attrValueAfterSanitize( |
| 2988 | + '<a title="x</style>y">z</a>', |
| 2989 | + 'title' |
| 2990 | + ); |
| 2991 | + assert.notOk( |
| 2992 | + value && /<\/style>/i.test(value), |
| 2993 | + value |
| 2994 | + ? `control failed: title retained </style>: "${value}"` |
| 2995 | + : 'control passed: </style> stripped' |
| 2996 | + ); |
| 2997 | + } |
| 2998 | + ); |
| 2999 | + |
| 3000 | + QUnit.module('Finding #3: _isClobbered coverage (informational)'); |
| 3001 | + |
| 3002 | + /* Hardening: extend _isClobbered to cover firstElementChild, innerHTML, |
| 3003 | + * nodeType, tagName. No direct observable bug; the values feed the |
| 3004 | + * mXSS check at line 1138–1146, currently contained by defense in |
| 3005 | + * depth. Nothing to test beyond #1's DoS suite — those payloads cover |
| 3006 | + * whether clobbering these properties produces an observable crash. */ |
| 3007 | + QUnit.test('#3: informational — covered by #1 DoS suite', (assert) => { |
| 3008 | + assert.ok(true, 'no additional test — see Finding #1 DoS results above'); |
| 3009 | + }); |
| 3010 | + |
| 3011 | + QUnit.module('Finding #4: external form= association'); |
| 3012 | + |
| 3013 | + /* Claim: <form id=f></form><input form=f name=X> clobbers from outside |
| 3014 | + * the form's subtree. _isClobbered still catches the form itself (it |
| 3015 | + * checks property types, not child presence), but the clobbering |
| 3016 | + * input survives as a sibling. |
| 3017 | + * |
| 3018 | + * Test: after sanitize, does the clobbering NAME survive? SANITIZE_DOM |
| 3019 | + * should strip it (default is true). If it survives, the primitive is |
| 3020 | + * open. If stripped, only the form= attribute itself is left — which |
| 3021 | + * is the hardening recommendation #4a. */ |
| 3022 | + QUnit.test( |
| 3023 | + '#4: HTML — name="firstElementChild" on stray input is stripped', |
| 3024 | + (assert) => { |
| 3025 | + const out = DOMPurify.sanitize( |
| 3026 | + '<form id="f"></form><input form="f" name="firstElementChild">' |
| 3027 | + ); |
| 3028 | + const nameSurvived = /name\s*=\s*["']?firstElementChild/i.test(out); |
| 3029 | + assert.notOk( |
| 3030 | + nameSurvived, |
| 3031 | + nameSurvived |
| 3032 | + ? `REPRODUCES — name="firstElementChild" survived SANITIZE_DOM. Output: ${out}` |
| 3033 | + : `safe — SANITIZE_DOM stripped the clobbering name. Output: ${out}` |
| 3034 | + ); |
| 3035 | + } |
| 3036 | + ); |
| 3037 | + |
| 3038 | + QUnit.test( |
| 3039 | + '#4: XHTML — name="firstElementChild" on stray input is stripped', |
| 3040 | + (assert) => { |
| 3041 | + const out = DOMPurify.sanitize( |
| 3042 | + '<form id="f"><![CDATA[<x>]]></form><input form="f" name="firstElementChild"/>', |
| 3043 | + { PARSER_MEDIA_TYPE: 'application/xhtml+xml' } |
| 3044 | + ); |
| 3045 | + const nameSurvived = /name\s*=\s*["']?firstElementChild/i.test(out); |
| 3046 | + assert.notOk( |
| 3047 | + nameSurvived, |
| 3048 | + nameSurvived |
| 3049 | + ? `REPRODUCES — name="firstElementChild" survived in XHTML. Output: ${out}` |
| 3050 | + : `safe — name stripped in XHTML. Output: ${out}` |
| 3051 | + ); |
| 3052 | + } |
| 3053 | + ); |
| 3054 | + |
| 3055 | + QUnit.test( |
| 3056 | + '#4 informational: form= attribute presence in output', |
| 3057 | + (assert) => { |
| 3058 | + const out = DOMPurify.sanitize( |
| 3059 | + '<form id="f"></form><input form="f" name="firstElementChild">' |
| 3060 | + ); |
| 3061 | + const formAttrSurvived = /\sform\s*=/i.test(out); |
| 3062 | + // This is purely informational. #4a recommends stripping form= |
| 3063 | + // attributes unconditionally via _isValidAttribute. This assertion |
| 3064 | + // passes either way — it just records which state the code is in. |
| 3065 | + assert.ok( |
| 3066 | + true, |
| 3067 | + `INFO — form= attribute ${formAttrSurvived ? 'survived' : 'was stripped'}. Output: ${out}` |
| 3068 | + ); |
| 3069 | + } |
| 3070 | + ); |
| 3071 | + |
| 3072 | + QUnit.module( |
| 3073 | + 'Bypass claim: style + XHTML + RETURN_DOM_FRAGMENT round-trip' |
| 3074 | + ); |
| 3075 | + |
| 3076 | + /* User-provided snippet: |
| 3077 | + * |
| 3078 | + * const clean = DOMPurify.sanitize( |
| 3079 | + * '<style></style><img src=x onerror=alert(1)><a></a></style>', |
| 3080 | + * { PARSER_MEDIA_TYPE: 'application/xhtml+xml', RETURN_DOM_FRAGMENT: true } |
| 3081 | + * ); |
| 3082 | + */ |
| 3083 | + |
| 3084 | + QUnit.test( |
| 3085 | + 'bypass: reparsed fragment contains no <img onerror> after round-trip', |
| 3086 | + (assert) => { |
| 3087 | + const payload = |
| 3088 | + '<style></style><img src=x onerror=alert(1)><a></a></style>'; |
| 3089 | + |
| 3090 | + let clean; |
| 3091 | + try { |
| 3092 | + clean = DOMPurify.sanitize(payload, { |
| 3093 | + PARSER_MEDIA_TYPE: 'application/xhtml+xml', |
| 3094 | + RETURN_DOM_FRAGMENT: true, |
| 3095 | + }); |
| 3096 | + } catch (e) { |
| 3097 | + assert.ok(false, `sanitize() threw: ${e && e.message}`); |
| 3098 | + return; |
| 3099 | + } |
| 3100 | + |
| 3101 | + const div = document.createElement('div'); |
| 3102 | + div.appendChild(clean); |
| 3103 | + const roundTripped = div.innerHTML; |
| 3104 | + |
| 3105 | + // Reparse the serialized HTML in a detached div to see what a |
| 3106 | + // downstream consumer assigning it via innerHTML would get. |
| 3107 | + const probe = document.createElement('div'); |
| 3108 | + probe.innerHTML = roundTripped; |
| 3109 | + |
| 3110 | + const imgs = probe.querySelectorAll('img'); |
| 3111 | + const dangerous = Array.prototype.some.call(imgs, (img) => |
| 3112 | + img.hasAttribute('onerror') |
| 3113 | + ); |
| 3114 | + |
| 3115 | + assert.notOk( |
| 3116 | + dangerous, |
| 3117 | + dangerous |
| 3118 | + ? `REPRODUCES — reparsed DOM has <img onerror>. Round-trip HTML: ${roundTripped}` |
| 3119 | + : `safe — round-trip HTML: ${roundTripped}` |
| 3120 | + ); |
| 3121 | + } |
| 3122 | + ); |
| 3123 | + |
| 3124 | + QUnit.test( |
| 3125 | + 'bypass: string sanitize (no FRAGMENT) of the same payload is safe', |
| 3126 | + (assert) => { |
| 3127 | + const payload = |
| 3128 | + '<style></style><img src=x onerror=alert(1)><a></a></style>'; |
| 3129 | + const out = DOMPurify.sanitize(payload, { |
| 3130 | + PARSER_MEDIA_TYPE: 'application/xhtml+xml', |
| 3131 | + }); |
| 3132 | + // We should see no <img> and no onerror in the output. The <style> |
| 3133 | + // element should be stripped entirely by the #1150-1158 check. |
| 3134 | + assert.notOk( |
| 3135 | + /<img/i.test(out) || /onerror/i.test(out), |
| 3136 | + `string sanitize output: ${out}` |
| 3137 | + ); |
| 3138 | + } |
| 3139 | + ); |
| 3140 | + |
| 3141 | + QUnit.test( |
| 3142 | + 'bypass: XSS native — alert() must not fire after round-trip', |
| 3143 | + (assert) => { |
| 3144 | + const done = assert.async(); |
| 3145 | + const payload = |
| 3146 | + '<style></style><img src=x onerror=alert(1)><a></a></style>'; |
| 3147 | + |
| 3148 | + let clean; |
| 3149 | + try { |
| 3150 | + clean = DOMPurify.sanitize(payload, { |
| 3151 | + PARSER_MEDIA_TYPE: 'application/xhtml+xml', |
| 3152 | + RETURN_DOM_FRAGMENT: true, |
| 3153 | + }); |
| 3154 | + } catch (e) { |
| 3155 | + assert.ok(false, `sanitize() threw: ${e && e.message}`); |
| 3156 | + done(); |
| 3157 | + return; |
| 3158 | + } |
| 3159 | + |
| 3160 | + const div = document.createElement('div'); |
| 3161 | + div.appendChild(clean); |
| 3162 | + |
| 3163 | + // Scoped container — don't taint document.body if XSS were to fire. |
| 3164 | + const container = document.getElementById('qunit-fixture'); |
| 3165 | + container.innerHTML = div.innerHTML; |
| 3166 | + |
| 3167 | + setTimeout(() => { |
| 3168 | + assert.notEqual( |
| 3169 | + window.xssed, |
| 3170 | + true, |
| 3171 | + 'alert() fired via XHTML-style round-trip (bypass reproduces)' |
| 3172 | + ); |
| 3173 | + container.innerHTML = ''; |
| 3174 | + window.xssed = false; |
| 3175 | + done(); |
| 3176 | + }, 100); |
| 3177 | + } |
| 3178 | + ); |
2859 | 3179 | }; |
2860 | 3180 | }); |
0 commit comments