September 26, 2026
It Is Not Prompt Injection. It Is Output Handling.
Developers treat the model as part of their own system. It is a text generator steered by input an attacker may control.

By Nitin yadav
7 min read
Hello, I am Nitin.
Day twenty-six. Today is the newest surface in this entire series, and the reason it exists is a single assumption that quietly stopped being true.
For about twenty-five years, web developers could rely on something comfortable. Content displayed in an application came from a human being, typing into a form the developers built. That human could be hostile, so you escaped their input. But the shape of the problem was understood: one person, one form field, one place to apply encoding.
Then applications started putting a language model in the middle.
Now the text being displayed was written by a machine. And here is the part that took the industry a while to absorb: developers instinctively treat that machine as part of their own system. It is their feature. They built it. It is running on their infrastructure with their prompt. So its output feels internal โ the same mental category as a database row the application wrote itself.
That instinct is the vulnerability.
The model is not part of your system in any security-relevant sense. It is a text generator that will faithfully produce whatever its input steers it toward. And its input frequently includes content an attacker controls.
So model output is exactly as untrusted as a form field. It just does not feel that way, which is why it keeps getting rendered raw.
The shape of the bug
Strip away the novelty and it is something you already know from day eighteen.
A model produces text. That text is almost always formatted as markdown, because markdown is how chat interfaces present structure. The application converts that markdown to HTML and inserts it into the page.
The markdown renderer is the bug surface. Same questions as day eighteen: does it allow raw HTML through, is there a sanitiser, does the sanitiser run before or after rendering, and which escape hatch inserts the final string.
What is new is not the sink. It is who can reach it, and how invisible that path is.
With an ordinary comment box, the attacker types into the box. With an AI feature, the attacker may never touch the interface at all โ they place content somewhere the model will read it later. That is the whole difference, and it is a big one.
Path one: you ask, it renders
The simplest case, and where you start testing.
You are talking to the assistant. You ask it something that causes it to produce markup in its reply. If the renderer passes raw HTML through, your own conversation window executes it.
This is self-XSS in its purest form โ you are attacking yourself โ and on its own it is worth very little. But it is the fastest way to characterise the renderer, which is what you actually need.
Ask the assistant to output formatting, and watch what happens:
- Ask for bold text written as raw HTML rather than markdown. Does it render as bold, or display as literal text?
- Ask for a table using HTML tags. Tables are a common reason teams enable raw HTML in the first place.
- Ask for a link with an unusual scheme. Does the renderer's link handling filter it?
- Ask for an image with a broken source. That is the classic day-eight probe for whether markup becomes live elements.
- Ask for content inside a code fence with an odd language identifier. Day eighteen's syntax-highlighting vector.
- Ask for a diagram or math block, if the product supports them. Those are separate downstream generators.
You are not exploiting anything yet. You are fingerprinting a renderer, exactly as in day fifteen, using the model as the delivery mechanism. Ten minutes of this tells you whether there is anything here at all.
If everything comes back escaped and literal, the renderer is configured safely and you can move on quickly. If raw HTML renders, keep going โ because now you need a path where someone else's content steers the output.
Path two: indirect injection, where it gets serious
This is the real bug class, and it is worth understanding precisely.
Modern AI features rarely operate on your message alone. They read things. A document you uploaded. A web page they fetched. A support ticket history. A code repository. A database record. The result of a tool call. All of that gets assembled into the model's input alongside your question.
Anything in that assembled input can influence what the model writes.
So the attack becomes: put content somewhere the model will read, containing instructions that steer its output toward markup. When a different user later asks the assistant about that content, the model produces your markup, and the application renders it in that user's session.
You never touched their interface. You edited a document, or a profile, or a page, and waited.
Places worth testing for this, in rough order of how commonly they exist:
- Documents and files the assistant summarises
- Web pages the assistant fetches and reads
- Records in the product itself โ tickets, comments, descriptions, profiles โ that a "summarise this" feature reads
- Code and repository content for developer assistants
- Email and message threads the assistant processes
- Search results the assistant is given
- Tool and integration output returned into the conversation
The shape of a test is: place a marker in a source the assistant reads, ask the assistant about that source, and see whether your marker reaches the output. If it does, the path exists. Then the question is whether markup in that position survives the renderer.
Keep it to your own content. Your own document, your own ticket, your own second account. The point is to prove the path, not to affect anyone else.
Path three: the downstream hops
This is where the severity gets ugly, and it is the part I would most want a security team to hear.
Agentic features write things. They file tickets, post comments, update records, draft messages, open pull requests, add notes to accounts. That written output lands in systems built long before anyone imagined a machine would be filling in those fields.
Every one of those destinations renders text. And every one of those renderers was written on the assumption that whatever reached it had passed through the product's normal input handling.
So the chain runs: attacker-controlled source, model reads it, model writes a ticket, ticket renders in an internal queue.
Which means this surface meets day twenty-five. The reader is frequently not the user at all โ it is a support agent, a reviewer, a developer, an administrator looking at a dashboard. The same privilege argument applies, and it is stronger here because nobody in the pipeline thinks of the content as user-supplied.
When you test this, trace the full path and write it down as a sequence. The report needs to show each hop, because the fix is usually at a different hop from where you found it.
The streaming problem
One thing genuinely specific to this surface, and worth understanding because it produces bugs nothing else does.
Chat interfaces stream. Text appears token by token, and the interface re-renders continuously as it arrives. That means the renderer is repeatedly handed incomplete markdown.
Incomplete markdown parses differently from complete markdown. An unterminated code fence, a half-written tag, an unclosed link โ each partial state is its own document, parsed and inserted into the page.
Two consequences worth testing:
A sanitiser may only inspect the final state. If the implementation cleans the finished message but the DOM was being written the whole time, something dangerous may already have executed before the final pass ran.
Partial forms may bypass what complete forms do not. Content whose finished version is caught by a filter may pass in an intermediate state that the filter never anticipated.
To test it, steer the output toward constructs whose partial parses differ from their complete ones, then watch the DOM during streaming rather than after. It is fiddly, and it is exactly the sort of thing nobody checks.
What to look for in the bundle
Day nine's work applies directly. Find the chat component and read it:
grep -rnE "marked|markdown-it|remark|rehype|streamdown|react-markdown" pretty/
grep -rnE "html\s*:\s*true|allowDangerousHtml|skipHtml|rehype-raw|dangerouslySetInnerHTML" pretty/
grep -rnE "mermaid|katex|highlight|prism" pretty/grep -rnE "marked|markdown-it|remark|rehype|streamdown|react-markdown" pretty/
grep -rnE "html\s*:\s*true|allowDangerousHtml|skipHtml|rehype-raw|dangerouslySetInnerHTML" pretty/
grep -rnE "mermaid|katex|highlight|prism" pretty/The pattern you are hoping to find is a markdown renderer with raw HTML enabled, its output going into an inner-HTML sink, with no sanitiser between them. That combination in a chat component is the entire finding, before you have sent a single message.
Also check what the assistant is permitted to do beyond text. If the feature can navigate, fetch, or trigger actions based on its own output, the consequences extend past XSS โ and that is worth reporting separately and carefully.
Impact ladder
- Informational โ model can be steered to emit markup, renderer escapes it. Good implementation. Note it.
- Low โ self-only rendering. You steered your own chat into executing something. Genuine but self-contained.
- Medium โ indirect injection reaching your own session through content you control, with no cross-user path demonstrated.
- High โ indirect injection where the source is controllable by one user and the render happens in another user's session. This is real stored XSS with a novel delivery path.
- Critical โ the same, rendering in a privileged context such as a support console, an admin review screen, or an agent-written record read by staff.
The framing for the report: the vulnerability is not that the model can be influenced. Models can always be influenced; that is what they do. The vulnerability is that its output is rendered as markup without encoding, which is an ordinary output-handling bug in an unusual wrapper.
Say it that way and the fix becomes obvious to the team: treat model output exactly like any other untrusted string. Say it as "prompt injection" and the conversation drifts into unsolvable territory about model behaviour, and your report may sit unfixed because it looks like an AI research problem rather than a rendering bug.
Conclusion โ steal this checklist
- The broken assumption: displayed content used to come from a human through your own form. It does not any more.
- Developers treat model output as internal because the model is their feature. That instinct is the bug.
- The sink is a day-eighteen markdown renderer. What is new is who can reach it and how invisible the path is.
- Start by fingerprinting the renderer: ask the assistant for raw HTML, tables, odd link schemes, broken images, fenced code with strange language identifiers, diagrams, math.
- Self-rendering alone is low value, but it characterises the renderer in ten minutes.
- Indirect injection is the real class. Place content where the model will read it โ documents, pages, records, code, tool output โ and let a different user's question pull it through.
- Test with markers in your own content first. Prove the path before anything else.
- Agentic output lands in systems built for human input. Tickets, comments, records, pull requests. That is where this meets day twenty-five and the reader becomes privileged staff.
- Streaming re-renders partial markdown. Partial parses differ from complete ones, and a final-state sanitiser may inspect something the DOM already rendered.
- Grep the chat component for a markdown library, a raw-HTML option, and an inner-HTML sink. That trio is the finding.
- Report it as output handling, not prompt injection. The fix is encoding, and framing it correctly is what gets it fixed.
Tomorrow: self-XSS escalation โ turning the finding everyone closes as informational into something that fires against someone else.
If you Love reading my blogs. Check my Youtube Channel too.