Skip to content

Commit 4b76e03

Browse files
authored
Cure53 expanding tests (#1310)
* test: expanded test coverage for form clobbering and type confusions * test: removed some outdated comments in test suite
1 parent 9797370 commit 4b76e03

1 file changed

Lines changed: 320 additions & 0 deletions

File tree

test/test-suite.js

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2856,5 +2856,325 @@
28562856
}
28572857
}
28582858
);
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">&lt;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">&lt;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>&lt;/style&gt;&lt;img src=x onerror=alert(1)&gt;<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>&lt;/style&gt;&lt;img src=x onerror=alert(1)&gt;<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>&lt;/style&gt;&lt;img src=x onerror=alert(1)&gt;<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>&lt;/style&gt;&lt;img src=x onerror=alert(1)&gt;<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+
);
28593179
};
28603180
});

0 commit comments

Comments
 (0)