XSS Prevention skill for Codex

Encode for the sink, not for the input. Codex searches the codebase for every place auto-escaping is bypassed, from v-html and {@html} to html_safe and Go text/template, traces each value back to its source, and applies the encoding that matches the actual output context. User HTML and Markdown get an allowlist sanitiser applied after rendering, URL attributes get scheme checks, and payload tests lock each fix in.

  • Security
  • $xss-prevention
  • 167 lines
  • 2 files
  • ~1,800 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- xss-prevention

Installs to ~/.agents/skills/xss-prevention/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $xss-prevention, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Prevents cross-site scripting by keeping framework auto-escaping intact, encoding for the exact output context (HTML, attribute, URL, JavaScript, CSS), sanitizing user HTML and Markdown with DOMPurify or nh3, and enforcing Trusted Types. Finds escape hatches such as dangerouslySetInnerHTML, v-html, {@html}, |safe, html_safe and template.HTML. Use when rendering user content, reviewing templates or React components for XSS, fixing a reported XSS, or adding rich text or Markdown.

Use it when

  • Rendering user-supplied text, HTML, Markdown or URLs
  • Fixing a reported reflected, stored or DOM XSS
  • Reviewing templates or components that bypass auto-escaping
  • Adding a rich text editor or comment system

Use something else when

How to check the result

Regression tests render each fixed component with img-onerror, svg-onload, javascript: URL and script-breakout payloads and assert no executable element or attribute survives.

The mistake it prevents

Escaping once at input for HTML and then placing the value in a script block or href, where HTML escaping does not stop execution.

~/.agents/skills/xss-prevention/SKILL.md

--- name: xss-prevention description: Prevents cross-site scripting by keeping framework auto-escaping intact, encoding for the exact output context (HTML, attribute, URL, JavaScript,… license: MIT ---

Done well means: untrusted data never reaches an HTML, script or URL sink without the encoding or sanitisation that matches that exact context, every escape hatch in the codebase is justified in a comment, and a CSP or Trusted Types policy would catch the next mistake.

Inspect first

  1. Read AGENTS.md; identify the template engine(s) and frontend framework.
  2. Find every place auto-escaping is bypassed. These are the review list:
rg -n "dangerouslySetInnerHTML|v-html|\{@html|\[innerHTML\]|bypassSecurityTrust|innerHTML\s*=|outerHTML|insertAdjacentHTML|document\.write|\.html\(" -g '!node_modules' -g '!dist'
rg -n "\|\s*safe|mark_safe|Markup\(|autoescape\s+(off|false)|html_safe|raw\(|\{!!|template\.HTML|template\.JS|template\.URL|th:utext|Html\.Raw" -g '!node_modules'
rg -n "text/template" --type go          # text/template does no HTML escaping
rg -n "href=\{|:href=|\[href\]|setAttribute\(.(href|src)" -g '!node_modules'
  1. For each hit, trace the value back to its source. Data from users, URLs, headers, third-party APIs, LLM output and your own database (stored XSS) is all untrusted.

Context decides the encoding

SinkCorrect handling
HTML text nodeFramework auto-escaping ({value}, {{ value }})
Quoted HTML attributeAuto-escaping, attribute always quoted
href, src, action, formactionParse the URL and allow only http:, https:, mailto: (and relative); then escape
Inside <script>Do not interpolate. Serialise to JSON in a data block (Django json_script, JSON.stringify with < escaped to <) and parse it
Event handler attribute (onclick=)Never put data here; attach listeners in JS
style attribute or <style>Avoid; use classes or set element.style.prop from JS
DOM via JStextContent, setAttribute for non-URL attributes, createElement
User-supplied HTML (rich text, Markdown output)Sanitise with an allowlist sanitiser, then insert

Escaping for HTML is wrong inside JavaScript strings and vice versa. The context determines the encoder; “escape everything once at input” fails in at least one sink.

Framework escape hatches

FrameworkHatchSafe alternative
ReactdangerouslySetInnerHTML, href={userUrl}Render as text; sanitise and validate URL scheme. React 19 blocks javascript: URLs in some props but do not rely on it
Vuev-html, dynamic :hrefText interpolation; sanitise first
AngularbypassSecurityTrustHtml/Url/...Let Angular’s sanitiser run; never bypass with user data
Svelte{@html}Sanitise first
Django / Jinja2`safe, mark_safe, Markup, autoescape off`
Rails ERBraw, html_safe, <%==sanitize helper with an explicit tag list, content_tag
Laravel Blade{!! !!}{{ }}
Gotext/template, template.HTML(...)html/template, no type conversions on user data
Thymeleafth:utextth:text

Sanitising user HTML

Browser side, DOMPurify:

import DOMPurify from "dompurify";

const clean = DOMPurify.sanitize(userHtml, {
  ALLOWED_TAGS: ["b", "i", "em", "strong", "a", "p", "ul", "ol", "li", "code", "pre", "blockquote"],
  ALLOWED_ATTR: ["href", "title"],
  ALLOW_DATA_ATTR: false,
});
DOMPurify.addHook("afterSanitizeAttributes", (node) => {
  if (node.tagName === "A") { node.setAttribute("rel", "noopener noreferrer"); node.setAttribute("target", "_blank"); }
});

Server side: Python nh3 (Rust ammonia bindings; bleach is deprecated), Ruby Rails::HTML5::SafeListSanitizer or sanitize gem, Java OWASP HTML Sanitizer, Go bluemonday, PHP HTML Purifier. Node server side: DOMPurify with jsdom, or sanitize-html.

Rules:

  • Sanitise the final HTML, after Markdown rendering, not the Markdown source. Markdown allows raw HTML and [x](javascript:...) links unless configured otherwise.
  • Sanitise at render time (or re-sanitise on read) so a sanitiser upgrade protects old rows.
  • Do not post-process sanitised HTML with string replacement or a different parser; parser differentials reintroduce XSS (mutation XSS).
  • SVG and MathML are where sanitiser bypasses cluster; leave them out of the allowlist unless needed.

Trusted Types

Trusted Types make DOM sinks such as innerHTML reject plain strings, so only values produced by a reviewed policy reach them. Supported in Chromium browsers and being added elsewhere; treat it as enforcement where available, not the only defence.

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types dompurify app-html
const appHtml = window.trustedTypes?.createPolicy("app-html", {
  createHTML: (s) => DOMPurify.sanitize(s, { RETURN_TRUSTED_TYPE: false }),
});
el.innerHTML = appHtml ? appHtml.createHTML(input) : DOMPurify.sanitize(input);

Roll out with Content-Security-Policy-Report-Only first; violation reports list every sink still receiving strings. CSP rollout details live in $content-security-policy.

Workflow

- [ ] 1. Run the escape-hatch searches; record each hit with file:line
- [ ] 2. Trace each to its source; mark trusted constant vs untrusted
- [ ] 3. Fix: remove the hatch, or sanitise / validate scheme, with a comment
- [ ] 4. Check URL-bearing attributes for javascript: and data: schemes
- [ ] 5. Check JSON-in-HTML and server-side rendering of state (__NEXT_DATA__-style blobs)
- [ ] 6. Add regression tests with payloads per context
- [ ] 7. Add or tighten CSP / Trusted Types in report-only mode

Verification

Add tests that render each fixed component or template with hostile input and assert the output is inert:

const payloads = [
  `<img src=x onerror=alert(1)>`,
  `"><svg onload=alert(1)>`,
  `javascript:alert(1)`,
  `</script><script>alert(1)</script>`,
  `<a href="jav&#x61;script:alert(1)">x</a>`,
];
for (const p of payloads) {
  render(<Comment body={p} website={p} />);
  expect(document.querySelector("img[onerror], svg[onload], script")).toBeNull();
  expect(document.querySelector('a[href^="javascript"]')).toBeNull();
}

Then load the page in a local browser with each payload stored and confirm no dialog appears and the browser console shows no CSP violations from your own code. Never test payloads against systems the user does not own or is not authorised to test.

Output template

## XSS review: <scope>
| # | Location | Sink / context | Source | Status | Fix |
|---|---|---|---|---|---|
| 1 | web/Comment.tsx:22 | dangerouslySetInnerHTML | comment.body (DB, user) | Fixed | DOMPurify, tag allowlist |
Escape hatches remaining (justified): <list with reason>
Defence in depth: CSP <state>, Trusted Types <state>

Gotchas

  • encodeURIComponent does not make a URL safe for href; javascript: survives it.
  • Stored data rendered in an admin panel is the most common high-impact XSS: admins see every user’s input.
  • LLM output rendered as Markdown or HTML is untrusted input; prompt injection can make it emit payloads.
  • Error pages and 404s that echo the path or query string are classic reflected XSS.
  • X-XSS-Protection does nothing useful in current browsers; do not count it.

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "XSS Prevention"
  short_description: "Encode for the sink, not for the input"
  default_prompt: "$xss-prevention Find every dangerouslySetInnerHTML in this React app and make it safe"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc