XSS and Log Injection: The Two Failures AI Code Keeps Making
86% of AI-generated code samples failed to defend against XSS, and 88% were vulnerable to log injection. Both share one root cause: unescaped input treated as safe.
Of all the numbers circulating about AI-generated code security, two sit close enough together to deserve being examined side by side: 86% of samples failed to defend against cross-site scripting, and 88% were vulnerable to log injection. These aren't niche edge cases — they're two of the most common attack classes in web development, and the fact that AI-generated code fails to defend against them at rates approaching nine in ten samples says something specific about how these models handle a particular category of decision, not just that "security is hard."
What connects XSS and log injection, on the surface, is almost nothing. One happens in a browser, when unescaped user input gets rendered as executable markup. The other happens in a log file, when unescaped input gets written into log output in a way that lets an attacker forge log entries or inject content a downstream log parser will misinterpret. Different surface, different consequence, different attacker goal. But underneath, they're the same failure: input flows from an untrusted source to an output context without anyone — human or model — inserting the escaping step in between. Once you see that both numbers are measuring the same missing step in two different places, the fix stops looking like two separate problems and starts looking like one habit to build.
Why XSS specifically trips up generated code
Cross-site scripting happens when user-supplied content gets inserted into a page as raw HTML instead of as text. The browser can't tell the difference between "the string the user typed" and "markup the developer meant to render," so if that string contains <script> tags or event handlers, the browser executes them exactly as if the developer had written them.
The reason generated code fails this so often comes down to what "correct" looks like from the model's vantage point. Ask for a function that displays a user's comment on a page, and the shortest, most obviously-correct-looking answer inserts the comment text directly into the template. It works. It renders the comment. It satisfies the prompt. The escaping step — running that text through a sanitization function, or using a templating approach that escapes by default — is invisible in the demonstration of the feature working, because unescaped and escaped comments render identically for any comment that doesn't happen to contain markup. The vulnerability is only visible when someone deliberately tests with a malicious input, which is exactly the step that gets skipped when code is generated, glanced at, and accepted because it visibly works on the happy path.
Why log injection is even easier to miss
If XSS is subtle, log injection is nearly invisible, which is likely why it scores even higher on the failure rate. Nobody thinks of a log statement as an attack surface, because logs feel like an internal implementation detail rather than user-facing output. logger.info(f"User login attempt: {username}") looks completely unremarkable — it's the kind of line that gets written a hundred times in any codebase, by humans and models alike, without a second thought.
But if username is attacker-controlled and contains newline characters or log-format-specific control sequences, that one line lets an attacker inject fabricated log entries — fake "successful login" records, forged audit trail entries, or content designed to break a downstream log parser or trigger an injection in whatever system consumes those logs next (a SIEM, an alerting pipeline, a log-viewing dashboard that renders log lines as HTML, which loops the problem right back into XSS territory). It's the same missing-escaping pattern as the browser case, just with a much less obvious consequence, which is exactly why it clears an even higher failure rate than XSS despite being conceptually simpler.
Why "the model should just know" isn't a fix
It's tempting to think the fix is a smarter model — one trained more heavily on secure coding practice, one that inserts escaping by default. That will help at the margins, and it's genuinely worth choosing tools and prompting approaches that lean this direction. But it doesn't solve the structural issue: escaping is context-dependent, and the model frequently doesn't have the context to know it's needed. Whether a given output needs HTML escaping, log-safe encoding, SQL parameterization, or nothing at all depends on where that value is going next — a piece of information that's often implicit in the surrounding codebase rather than stated in the prompt. A model asked to "log the username" has no strong signal that this particular log line will later be rendered in an HTML dashboard three services downstream, unless someone tells it.
This is why the fix that actually works isn't "prompt more carefully" — it's structural: use output-context-aware tooling that escapes by default regardless of what generated the input.
What actually closes this gap
A few concrete practices close most of both failure classes, and none of them require reading every line by hand:
- Templating engines that escape by default, where a developer has to deliberately opt out of escaping (via an explicit "raw" or "safe" marker) rather than opt in. This flips the default from "vulnerable unless someone remembers to fix it" to "safe unless someone deliberately overrides it" — and deliberate overrides are far easier to catch in review than silent omissions.
- Structured logging libraries instead of string interpolation into log calls. Passing values as separate fields (
logger.info("login_attempt", user=username)) rather than interpolating them into a message string sidesteps most log injection vectors structurally, because the logging library handles serialization rather than leaving it to ad hoc string formatting. - A specific, named check in code review for exactly these two patterns — not a generic "check for security issues" instruction, which is vague enough to be routinely skipped, but a concrete question: "does any user-controlled value reach an output context (HTML, logs, shell, SQL) without going through an escaping or parameterization step?"
- Automated static analysis tuned specifically to these patterns, since both are mechanically detectable — a linter or SAST tool that flags unescaped interpolation into templates or log calls catches this class of issue without requiring a human to remember to look every single time.
Two failure classes, 86% and 88% respectively, and both come down to the same single missing step happening in two different places. That's not two problems to solve. It's one habit — escape at the output boundary, every time, regardless of how harmless the input looks — that needs to be enforced structurally rather than left to memory.
The consistency of these numbers across such a large sample of generated code is itself the useful signal. This isn't random noise in security testing. It's a systematic gap in how generated code treats the boundary between "data" and "executable output" — which means it's systematically fixable, with the right defaults in place before the code is ever generated.
Part of the "From Vibe Coding to Production" series on aiskill.market.