Cross-site scripting in agent-written code

Modern frameworks escape text for you. The cross-site scripting that ships now comes through the escape hatch an agent opens when asked to render formatting.

Aevral,

In plain words

Think of a shared noticeboard in an office. Anyone can pin a note, and everyone reads the notes. Now imagine one note that, instead of being read, acts: whoever looks at it hands over their building badge without noticing. Cross-site scripting is that note. A user's text is placed into a web page in a way the browser runs as code, and the code then acts as whoever is viewing the page, with their session.

The browser cannot tell the site's own scripts from a script that arrived inside a comment, a profile name or a search term. Whatever runs in the page can call the site's API as the viewer, read what the viewer sees, and change what they do next.

Why agents write it: the feature request is for formatting

React, Vue, Svelte and most server template engines escape text by default, so a plain {comment.body} is safe. Cross-site scripting in modern code comes from the places that turn escaping off, and those places are exactly where formatting features live. Asked to support markdown in comments, highlight search matches or render a rich-text field, an agent produces HTML and reaches for dangerouslySetInnerHTML, v-html, |safe or <%- %> to display it.

The common trap is the markdown library. Many markdown renderers pass raw HTML in the source straight through to their output by default, and do not sanitize it. The agent's test renders **bold** correctly, so the feature looks done; the <img onerror=...> case is never in the test.

The shapes it takes in a pull request

  • Raw HTML from a renderer

    Markdown, a rich-text field or a syntax highlighter's output placed into the page with dangerouslySetInnerHTML or v-html without a sanitizer in between.

  • A template's no-escape switch

    A server template that marks user content as safe (|safe, {!! !!}, <%- %>) so that formatting survives, which also lets script survive.

  • Data inlined into a script tag

    Server data written into an inline <script> with JSON.stringify for hydration. A value containing </script> closes the tag early and whatever follows runs as page script.

  • A link the user controls

    An href or src taken from a profile field or query parameter with no check that it starts with https:. Depending on the framework and its version, a javascript: URL can run script when clicked.

  • HTML built by string concatenation

    An error page or email preview assembled as "<p>" + name + "</p>" on the server, outside the framework's escaping.

Worked example, an illustration

Markdown comments rendered without a sanitizer.

The request: support markdown in comments. The agent added a markdown renderer and displayed its HTML. Bold and links work, so the change reads as finished.

components/Comment.tsx+8 -1
import { marked } from "marked";export function Comment({ comment }: { comment: CommentRow }) {  return <p className="comment-body">{comment.body}</p>;  const html = marked.parse(comment.body) as string;  return (    <div      className="comment-body"      dangerouslySetInnerHTML={{ __html: html }}    />  );}
Illustration written for this page, not from a customer repository. A comment containing <img src=x onerror="..."> passes through the markdown renderer as HTML. Every teammate who opens the thread runs the attacker's script inside the app, which can call the API with that teammate's session: change their email, create an API key, or read the pages they can read.

What an Aevral finding on it looks like

On a reviewed pull request, a finding is an inline comment pinned to the added line, next to an advisory Check that never blocks the pull request. This is an illustration of that shape for the diff above.

AevralIllustration

components/Comment.tsx, added line: `dangerouslySetInnerHTML={{ __html: html }}`

comment.body is user-written. marked.parse passes raw HTML in the markdown through to its output and does not sanitize it, and the result is inserted into the page without escaping, so an event handler in a comment runs for every reader.

Missing control: An HTML sanitizer with an allowlist between the markdown output and the page.

Fix with your agent
In components/Comment.tsx, the markdown output of comment.body is rendered with dangerouslySetInnerHTML without sanitizing. Pass it through DOMPurify (isomorphic-dompurify if this renders on the server) before rendering. Add a test that renders a comment containing <img src=x onerror=alert(1)> and expects no onerror attribute in the output.

Check this finding against the code before changing anything. Make the smallest change that restores the control, add a test for it, and stop before commit or push.

A finding is a lead with evidence, not a confirmation. You or your agent check it against the code; Aevral never applies or merges a change. See handing a finding to your coding agent.

How to fix it

Keep the feature and put a sanitizer between the renderer and the page. A sanitizer such as DOMPurify parses the HTML and keeps only elements and attributes on an allowlist, so bold, lists and links survive and event handlers and script do not. Escaping is the right tool for plain text; sanitizing is the right tool once the feature requires HTML.

components/Comment.tsx (safe version)
import { marked } from "marked";
import DOMPurify from "isomorphic-dompurify";

export function Comment({ comment }: { comment: CommentRow }) {
  const html = DOMPurify.sanitize(marked.parse(comment.body) as string);
  return (
    <div
      className="comment-body"
      dangerouslySetInnerHTML={{ __html: html }}
    />
  );
}

What this page does not claim

  • Aevral's PR security review, live on install, looks for cross-site scripting on the diff of the pull requests it reviews, as one class among several. Looking for a class does not mean finding each instance of it: a review can miss one, and a quiet review is not proof that the code is clean.
  • The whole-repo scan reads authorization, IDOR, and business-logic access control only. It does not read cross-site scripting; on this class, the pull request is where Aevral looks.
  • No class-specific performance evidence for cross-site scripting is published. The receipts page reports named runs of the whole PR review eval suite, not a result for this class.
  • The diff, the finding and the fix on this page are illustrations written for this page. They are not output from a customer repository.
  • This page covers script injected through the application's own rendering. It does not cover a compromised third-party script, a missing Content Security Policy as a standalone finding, or clickjacking.

Sources

CWE-79: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting'); OWASP Cross Site Scripting Prevention Cheat Sheet; marked documentation (output is not sanitized); DOMPurify.

Read next

Reviewing a pull request that wires in an LLM; Token and session flaws in agent-written code; PR security review.

Other classes


Security review, handled.

One GitHub App. Reviews start when the App is installed. A Check on each pull request it reviews, with inline comments when there is a grounded finding. Free tier live: public repos free, 500/org/month, 25 private reviews a month. Paid plans are live in the console.

Install the GitHub AppLog in

For professional use. By installing, you confirm you can act for the account or organization that owns it, and you accept the Terms and DPA on its behalf.