Update
This article mainly records the first fix I implemented, which used
createTextNode()together with<br>elements.After publication, a reader suggested using
innerTextinstead.I tested that approach in my own environment and confirmed that, for this particular use case, it preserved both line breaks and the important security property that HTML-like strings are not interpreted as HTML.
The current implementation therefore uses
innerText, which is simpler.For that reason, the sections below describing
createTextNode(),<br>generation, and the resulting DOM structure should be understood as a record of the initial fix.
Introduction
Hello from Japan! 🇯🇵
This article was originally published in Japanese on Qiita and has been translated and adapted for DEV Community.
I have been reviewing the security of a Flask application I am developing, with help from Codex.
This time, I found a place where text returned by an AI model was being displayed in the browser using JavaScript innerHTML.
The application appeared to work normally.
However, innerHTML does not treat a string as plain text.
It interprets that string as HTML.
That means if an HTML-like string reaches the page through an AI response or another external input, the browser may create unintended elements.
In the initial fix, I changed the rendering logic so that:
-
innerHTMLwas no longer used - AI responses were treated as text nodes
- Only line breaks were created as
<br>elements
I then verified:
- Real Gemini responses
- DOM structure
- HTML-like test strings
- JavaScript errors
After publishing the article, a comment led me to test an implementation using innerText.
That approach turned out to be simpler while preserving the behavior I needed, so the current implementation has since been refactored to use innerText.
GitHub:
https://github.com/tosane932/sales_data_app
The Application
The target is a sales management and analysis application I am developing for bakery stores.
The main stack includes:
- Python
- Flask
- PostgreSQL
- SQLAlchemy
- Alembic
- JavaScript
- Gemini API
- Docker
- pytest
- GitHub Actions
One of the application features sends sales data to the Gemini API and displays business-improvement advice returned by the model.
The Problem Codex Found
During a Codex-assisted security review, I discovered that the AI-response rendering logic used innerHTML.
For example, the dashboard contained code like this:
aiText.innerHTML = data.ai_advice.replace(/\n/g, '<br>');
The AI greeting on the input page used a similar pattern:
greetingText.innerHTML = data.message.replace(/\n/g, '<br>');
The original reason was simple:
I wanted to convert line breaks into <br> elements.
The problem was not the line breaks.
The problem was innerHTML.
For example, if this string were passed in:
<b>AI Test</b>
innerHTML would not display <b> as ordinary characters.
It would interpret the string as HTML markup.
Because this feature displays AI-generated text, it is tempting to assume:
The AI will probably return only normal text.
And in normal operation, that may often appear to work.
But I think a safer security principle is:
Do not depend on dangerous input never arriving.
Design the rendering path so that even dangerous-looking input does not become dangerous.
The Initial Jinja Rendering Also Used safe
The initial dashboard rendering also contained this Jinja expression:
{{ ai_advice | replace('\n', '<br>') | safe }}
Using safe disables Jinja's automatic escaping for that value.
At the time, the initial message was fixed text, so this was not immediately exploitable in the same way.
However, if that value later became dynamic, the same pattern could become dangerous.
I therefore reviewed this path as part of the same fix.
The Initial Fix
This feature did not actually need to allow HTML.
The only requirements were:
- Japanese text
- Multiple lines
- Bullet-like symbols
- Line breaks
So instead of adding a sanitization library such as DOMPurify, I chose a simpler rule:
Do not interpret the AI response as HTML at all.
The initial helper looked like this:
function setTextWithLineBreaks(element, value) {
const fragment = document.createDocumentFragment();
const lines = String(value ?? '').split('\n');
lines.forEach((line, index) => {
if (index > 0) {
fragment.append(document.createElement('br'));
}
fragment.append(document.createTextNode(line));
});
element.replaceChildren(fragment);
}
The important part is:
document.createTextNode(line)
With this approach, even if the input contains:
<b>AI Test</b>
the browser does not create a b element.
It remains plain text.
This was the implementation used in the first fix.
After additional testing following publication, I later refactored the code to use innerText.
The Initial Fix Created Only Line Breaks as HTML Elements
AI responses often contain multiple lines, so I wanted to preserve line breaks.
In the first implementation, I split the response on \n and inserted only:
document.createElement('br')
between lines.
The DOM therefore looked conceptually like this:
Text node
BR
Text node
BR
Text node
The AI response itself remained entirely inside text nodes.
The only HTML elements created from this formatting process were the <br> elements.
The current implementation achieves the same goal more simply by combining innerText with:
white-space: pre-line;
Loading and Error Messages Were Changed to textContent
Fixed messages did not need innerHTML either.
I changed them to use textContent.
For example:
aiText.textContent = '🌀 The AI assistant is thinking about detailed improvement ideas...';
Even for fixed text, using textContent makes the intent clearer when HTML rendering is unnecessary.
Removing Jinja safe
I also restored normal Jinja auto-escaping for the initial render.
{{ ai_advice }}
Line breaks were handled in CSS instead:
white-space: pre-line;
This preserved line breaks without generating HTML from the template.
The same CSS remains useful in the current innerText implementation.
Confirming That Unnecessary innerHTML Usage Was Gone
Using Codex, I searched the target templates and confirmed that the unnecessary innerHTML usage had been removed from:
templates/dashboard.html
templates/input.html
Those were the only two files changed in the initial fix.
Browser Verification of the Initial Fix
I did not stop after changing the code.
For the initial implementation, I also used headless Chrome to verify the actual browser behavior.
1. Dashboard Initial Render
I checked the dashboard's initial state.
The following remained normal:
- AI business-advice area
- Buttons
- Charts
- Layout
- Horizontal overflow
The original initial message did not contain a real line break, so I manually assigned this value in the browser DOM:
Initial line 1
Initial line 2
The verification result was:
lineBreakPreserved=true
So the line break was preserved.
2. Real Gemini Response
Next, I made one real request to the Gemini API.
HTTP 200
Gemini AI advice generated successfully
The AI response displayed correctly in Japanese.
Multiple lines were preserved.
Markdown-like characters such as:
*
**
were displayed as text rather than converted into HTML.
For the initial implementation, the resulting DOM looked like:
#text
BR
#text
BR
#text
BR
#text
BR
#text
The counts were:
Text nodes: 5
BR elements: 4
Other child elements: 0
That confirmed that the AI response itself was not producing HTML elements.
3. AI Greeting on the Input Page
I also tested the AI greeting on the input page.
HTTP 200
AI daily greeting generated successfully
The response displayed normally as two lines of Japanese text.
The initial DOM structure was:
#text
BR
#text
The counts were:
Text nodes: 2
BR elements: 1
Other child elements: 0
The form and button layout also remained intact.
Testing with HTML-Like Strings
Next, without sending anything to the database or Gemini API, I injected the following strings directly in the browser:
<b>AI Test</b>
<div>Test</div>
<em>Safety Check</em>
The result was the same on both the dashboard and input page.
b/div/em elements: 0
BR elements: 2
The DOM was:
#text
BR
#text
BR
#text
The text nodes literally contained:
<b>AI Test</b>
<div>Test</div>
<em>Safety Check</em>
Represented as HTML, the browser state was equivalent to:
<b>AI Test</b><br>
<div>Test</div><br>
<em>Safety Check</em>
In other words:
<b>
<div>
<em>
were not interpreted as real HTML elements.
They remained ordinary text.
Again, this DOM result describes the first implementation using createTextNode() and <br>.
Checking for JavaScript Errors
During the initial fix, I monitored Chrome DevTools Protocol events including:
Runtime.exceptionThrown
console.error
The result was:
JavaScript errors: 0
Chrome itself produced some GPU and inotify warnings, but those were unrelated to the application's JavaScript.
pytest
I also ran the existing pytest suite at the time of writing.
PYTHONDONTWRITEBYTECODE=1 pytest -p no:cacheprovider
The result was:
collected 3 items
test_prompts.py ... [100%]
3 passed in 0.40s
All existing tests passed.
However, this revealed another problem:
At the time, there were only three pytest tests, and none of them could automatically detect this XSS rendering issue.
That meant the bug could potentially return later without the existing tests noticing.
This experience became one of the reasons I decided to strengthen pytest using actual bugs and vulnerabilities as regression tests.
Checking the Git Diff
Before committing the initial fix, I ran:
git diff --check
No whitespace errors were reported.
The only changed files were:
M templates/dashboard.html
M templates/input.html
Commit and Push
After completing the initial verification, I created this commit:
f354d5d fix: sanitize AI response rendering
I pushed it to origin/main.
After the article was published, a reader suggested testing innerText.
I did so and later refactored the implementation to the simpler version that is currently in use.
What I Learned
1. Do Not Design Around “The AI Probably Won't Return Anything Dangerous”
Even if an AI normally returns ordinary prose, safety should not depend on that assumption.
Whether the input comes from:
- An AI model
- A user
- An external API
I think the safer principle is:
Treat external strings as strings.
2. If You Do Not Need HTML, Do Not Use innerHTML
For this feature, I only needed:
- Text
- Line breaks
There was no real need for:
innerHTML
The initial fix used text nodes and <br> elements.
After the article was published, I tested another approach and confirmed that, in this use case:
innerText
together with:
white-space: pre-line;
could achieve the same goal with less code.
The important lesson was not that one specific API is always correct.
It was:
If a value does not need to be interpreted as HTML, do not put it through an HTML-rendering API in the first place.
3. Jinja safe Is Useful, but It Disables Auto-Escaping
Jinja's safe filter is convenient.
But if I cannot clearly explain why a value needs to bypass escaping, it is safer not to use it.
In this case, the initial display could preserve line breaks using:
white-space: pre-line;
without disabling auto-escaping.
4. Inspecting the DOM Gives More Confidence Than Looking at the Screen Alone
A page can look completely normal while still creating unintended HTML elements internally.
During the first fix, checking the actual DOM structure:
Text node
BR
Text node
made it much easier to verify that HTML-like strings were not being turned into elements.
The implementation has since changed to innerText, but the principle remains useful:
Do not verify only how the page looks. Verify how the browser is actually treating the data.
5. Security Is About Preventing the Dangerous State, Not Just Avoiding the Same Mistake
This was probably the strongest lesson for me.
Instead of:
Be careful not to enter dangerous HTML.
I prefer:
Build the system so that even if HTML-like text arrives, it is not executed as HTML.
This reminds me of safety management in my day job.
When an incident or near miss happens, saying:
I'll be more careful next time.
is not enough.
If the same conditions occur again, the same incident can happen again.
A stronger approach is:
Change the process so that the same dangerous state cannot be recreated.
I found that the same idea applies to software security.
What I Want to Improve Next
At the time this article was written, I had fixed the XSS risk in the AI response rendering and verified the behavior through:
- Browser testing
- DOM inspection
- pytest
However, the pytest suite itself still had very narrow coverage.
It could not automatically detect the vulnerability that had just been fixed.
My next improvement is therefore to take real bugs and vulnerabilities that I have encountered and preserve them as:
Regression tests that prevent the same dangerous state from returning.
Instead of relying on:
Remember not to make the same mistake again.
I want the system to fail a test as soon as the same unsafe state is reintroduced.
In the next article, I plan to strengthen pytest so that it becomes more than a general functional test suite.
I want to treat it almost like:
An incident-prevention ledger that records previous bugs, near misses, and vulnerabilities.
Conclusion
This issue was not an obvious failure that caused the application to stop working.
The application looked normal.
But a security weakness still existed underneath the visible behavior.
In personal development, it is easy to focus mainly on adding features.
However, I think systems become stronger when we repeatedly find and fix:
- Small bugs
- Small design weaknesses
- Small near misses
and preserve those lessons in tests.
After this article was published, a reader suggested using innerText.
I did not adopt the suggestion immediately.
Instead, I replaced the implementation in my own environment and verified:
- HTML-like strings were still not interpreted as HTML
- Line breaks remained visible
- Existing rendering behavior did not break
- Tests were unaffected
The result was that innerText could preserve the security properties and formatting I needed while making the implementation simpler.
So the createTextNode() and <br> implementation described earlier in this article is now a historical record of the first fix, not the final implementation.
This experience also reinforced another principle for me:
Whether a suggestion comes from AI or from another developer, I should test it in my own environment before deciding to adopt it.
I plan to continue using Codex not only for code generation, but as part of a wider cycle:
Review → Fix → Verify → Prevent Regression
GitHub:
https://github.com/tosane932/sales_data_app
Qiita:
Top comments (0)