Skip to main content
  1. Posts/

JavaScript: advanced DOM manipulation techniques

··1275 words·6 mins·
Table of Contents
The DOM manipulation examples in this post work on any page you control (your own dev environment, a lab target, an in-scope engagement). Injecting or modifying content on sites you don’t own is out of scope.

The DOM is the tree structure browsers build from HTML documents. Every element, attribute, and text node is a node in that tree, and JavaScript exposes it as a live, mutable object. For defenders, understanding the DOM API surface is what makes DOM-based XSS reviews possible. For testers, it’s how you write proof-of-concept payloads for the same class of bugs. Same knowledge, different framing.

Accessing elements
#

Modern DOM access has settled on querySelector and querySelectorAll, which take CSS selectors and cover the entire surface of the older methods:

// Modern (preferred)
const el = document.querySelector("#target");
const all = document.querySelectorAll(".highlighted");

// Legacy (still works, still shows up in older codebases)
const byId = document.getElementById("target");
const byClass = document.getElementsByClassName("highlighted");
const byTag = document.getElementsByTagName("a");

The legacy getElementsByClassName and getElementsByTagName return live HTMLCollection objects that update as the DOM changes; querySelectorAll returns a static NodeList snapshot. That difference matters when you’re iterating a collection while also modifying it. A live collection can shrink out from under a for loop.

Modifying content
#

Three properties, three security profiles:

// Text content, no HTML parsing (always safe)
el.textContent = userInput;

// Text with legacy rendering quirks (safer than innerHTML, not equivalent)
el.innerText = userInput;

// Parses input as HTML (the DOM-based XSS entry point)
el.innerHTML = userInput;

innerHTML = userInput is the single most common DOM XSS bug in real-world code. If you’re reviewing a codebase, grep for it. Any assignment where the right-hand side is data the user can influence (URL parameters, form input, message events, cookies, storage) needs either sanitization or replacement with textContent.

Modern browsers offer safer alternatives. The DOMPurify library is the accepted community sanitizer. The newer Trusted Types API (available in Chromium and Firefox) lets a site’s CSP block string-to-HTML sinks entirely unless the strings pass through a defined policy:

// With Trusted Types enforced via CSP: require-trusted-types-for 'script'
const policy = trustedTypes.createPolicy("sanitize", {
    createHTML: (input) => DOMPurify.sanitize(input),
});

el.innerHTML = policy.createHTML(userInput);

Adding and removing elements
#

const p = document.createElement("p");
p.textContent = "New paragraph content";
document.body.appendChild(p);

// Modern removal
p.remove();

// Legacy removal (still ubiquitous in older code)
p.parentNode.removeChild(p);

Element.remove() (added in Chrome 23, Firefox 23, all 2013 releases) is cleaner than the parent-chained approach. It’s been safe to use for over a decade.

Traversing the DOM
#

Older traversal methods (parentNode, firstChild, lastChild, nextSibling, previousSibling) treat every node type as walkable, which means text nodes and comment nodes show up as siblings when you probably wanted the next element:

// Includes text/comment nodes (usually not what you want)
const rawSibling = el.nextSibling;

// Element-only traversal (preferred)
const parent = el.parentElement;
const first = el.firstElementChild;
const last = el.lastElementChild;
const next = el.nextElementSibling;
const prev = el.previousElementSibling;

The element-variant properties skip text and comment nodes, which is usually what you want when navigating structured content.

Styles and classes
#

Direct style manipulation works but is usually a code smell in modern development. Preferred: toggle CSS classes and let the stylesheet own the visual state.

const el = document.querySelector("#target");

// Direct inline style (avoid unless justified)
el.style.backgroundColor = "red";

// Class-based (preferred)
el.classList.add("highlighted");
el.classList.remove("faded");
el.classList.toggle("expanded");
el.classList.contains("active");

classList beats the older className string manipulation on every dimension. Use it.

Working with attributes
#

Standard attributes have direct DOM properties (el.id, el.href, el.src). Custom attributes go through getAttribute / setAttribute / removeAttribute:

const el = document.querySelector("#target");

el.getAttribute("data-widget-id");
el.setAttribute("data-widget-id", "42");
el.removeAttribute("data-widget-id");
el.hasAttribute("data-widget-id");

For data-* attributes specifically, the dataset API is cleaner:

// <div id="target" data-widget-id="42" data-user-role="admin"></div>
const el = document.querySelector("#target");

el.dataset.widgetId; // "42"
el.dataset.userRole; // "admin"
el.dataset.widgetId = "99"; // sets data-widget-id="99"
delete el.dataset.userRole; // removes data-user-role

Note the kebab-to-camel conversion: data-widget-id becomes dataset.widgetId.

Event handling
#

addEventListener is the modern API. The one detail that trips up almost every developer eventually: removeEventListener needs a reference to the same function that was added. Anonymous functions cannot be removed:

const el = document.querySelector("#target");

// BROKEN: the anonymous function passed to remove
// is a different object than the one that was added
el.addEventListener("click", () => alert("click"));
el.removeEventListener("click", () => alert("click")); // no-op

// CORRECT: keep a reference
const handler = () => alert("click");
el.addEventListener("click", handler);
el.removeEventListener("click", handler); // actually removes it

// Also useful: AbortController for bulk removal
const controller = new AbortController();
el.addEventListener("click", handler, {
    signal: controller.signal
});
el.addEventListener("mouseover", otherHandler, {
    signal: controller.signal
});
controller.abort(); // removes both listeners at once

The AbortController pattern (available since 2020) is the modern way to clean up multiple listeners at once. Handy for component teardown in single-page apps.

DOM-based XSS: the pattern to recognize
#

DOM-based XSS happens when a client-side script reads data from an attacker-controllable source (a source) and writes it to a dangerous DOM API (a sink) without sanitization. The whole exchange happens in the browser; the server never sees the payload, which is what makes DOM XSS harder to catch with server-side WAFs.

A minimal example:

// The classic: read from URL hash, write to innerHTML
const userInput = document.location.hash.substring(1);
document.getElementById("output").innerHTML = userInput;

Load that page with #<img src=x onerror=alert(1)> in the URL and the payload executes. During a code review, this is the shape you’re looking for:

Common sources include location.hash, location.search, location.pathname, document.URL, document.referrer, window.name, document.cookie, localStorage, sessionStorage, and postMessage event data. Common sinks include innerHTML, outerHTML, document.write, document.writeln, insertAdjacentHTML, eval, setTimeout(string), Function(string), <script>.textContent, and element.setAttribute("src", ...) on script-loading elements.

DOMPurify catches the common cases:

// Defensive rewrite
const userInput = document.location.hash.substring(1);
document.getElementById("output").innerHTML = DOMPurify.sanitize(userInput);

Better: use textContent when you don’t actually need HTML rendering. Best: enforce Trusted Types via CSP so the mistake becomes a compile-time (or at least load-time) error instead of a runtime vulnerability.

The OWASP DOM-based XSS Prevention Cheat Sheet is the reference for defenders. PortSwigger’s DOM XSS labs are the reference for testers.

Practical uses beyond exploit development
#

DOM manipulation is what every browser-based tool builds on. A few common categories:

Client-side scraping. When you need to extract content that only exists after JavaScript renders it, running scraping code in the browser (via a bookmarklet or a browser extension) is often simpler than trying to reproduce the rendering server-side.

// Extract all links from the current page
const links = Array.from(document.querySelectorAll("a")).map((a) => a.href);
console.log(JSON.stringify(links, null, 2));

Automated form interaction. Useful for functional testing, accessibility auditing, and (with the right authorization) security testing of authenticated flows:

// Fill a form, submit it, check for a success indicator
document.querySelector("#username").value = "testuser";
document.querySelector("#password").value = "testpass";
document.querySelector("#submit").click();

setTimeout(() => {
    const success = document.querySelector("#login-success");
    console.log(success ? "logged in" : "login failed");
}, 2000);

For anything more complex than a one-off, reach for Playwright, Cypress, or Puppeteer instead of raw DOM manipulation. They handle timing, waits, and cross-frame navigation correctly.

DOM-based test scaffolding. Debug snippets that instrument the DOM in real time, run in DevTools, and give you a live view of what’s happening without needing to modify the page’s source.

Where to go next
#

The DOM is a large API surface. A few threads worth pulling on:

  • MDN’s DOM reference is exhaustive and accurate.
  • The Trusted Types spec and browser support tracker.
  • MutationObserver for reacting to DOM changes (useful for both defensive monitoring and offensive tools that need to notice new content).
  • Shadow DOM, which changes how encapsulation works and has its own set of security considerations (closed shadow roots can hide content from external scripts but are not a security boundary).

The through-line: understanding these APIs makes you better at building web apps, testing them, and reviewing them for security bugs. Same knowledge, different jobs.

UncleSp1d3r
Author
UncleSp1d3r
As a computer security professional, I’m passionate about building secure systems and exploring new technologies to enhance threat detection and response capabilities. My experience with Rails development has enabled me to create efficient and scalable web applications. At the same time, my passion for learning Rust has allowed me to develop more secure and high-performance software. I’m also interested in Nim and love creating custom security tools.