DEV Community

Cover image for Securing AI Response Rendering in Flask — Replacing innerHTML After a Codex Review
tosane932
tosane932

Posted on • Originally published at qiita.com

Securing AI Response Rendering in Flask — Replacing innerHTML After a Codex Review

Update

This article mainly records the first fix I implemented, which used createTextNode() together with <br> elements.

After publication, a reader suggested using innerText instead.

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:

  • innerHTML was 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>');
Enter fullscreen mode Exit fullscreen mode

The AI greeting on the input page used a similar pattern:

greetingText.innerHTML = data.message.replace(/\n/g, '<br>');
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

The important part is:

document.createTextNode(line)
Enter fullscreen mode Exit fullscreen mode

With this approach, even if the input contains:

<b>AI Test</b>
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

between lines.

The DOM therefore looked conceptually like this:

Text node
BR
Text node
BR
Text node
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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...';
Enter fullscreen mode Exit fullscreen mode

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 }}
Enter fullscreen mode Exit fullscreen mode

Line breaks were handled in CSS instead:

white-space: pre-line;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The verification result was:

lineBreakPreserved=true
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The AI response displayed correctly in Japanese.

Multiple lines were preserved.

Markdown-like characters such as:

*
**
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The counts were:

Text nodes: 5
BR elements: 4
Other child elements: 0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The response displayed normally as two lines of Japanese text.

The initial DOM structure was:

#text
BR
#text
Enter fullscreen mode Exit fullscreen mode

The counts were:

Text nodes: 2
BR elements: 1
Other child elements: 0
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

The result was the same on both the dashboard and input page.

b/div/em elements: 0
BR elements: 2
Enter fullscreen mode Exit fullscreen mode

The DOM was:

#text
BR
#text
BR
#text
Enter fullscreen mode Exit fullscreen mode

The text nodes literally contained:

<b>AI Test</b>
<div>Test</div>
<em>Safety Check</em>
Enter fullscreen mode Exit fullscreen mode

Represented as HTML, the browser state was equivalent to:

&lt;b&gt;AI Test&lt;/b&gt;<br>
&lt;div&gt;Test&lt;/div&gt;<br>
&lt;em&gt;Safety Check&lt;/em&gt;
Enter fullscreen mode Exit fullscreen mode

In other words:

<b>
<div>
<em>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The result was:

JavaScript errors: 0
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The result was:

collected 3 items
test_prompts.py ... [100%]

3 passed in 0.40s
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

No whitespace errors were reported.

The only changed files were:

M templates/dashboard.html
M templates/input.html
Enter fullscreen mode Exit fullscreen mode

Commit and Push

After completing the initial verification, I created this commit:

f354d5d fix: sanitize AI response rendering
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

together with:

white-space: pre-line;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

https://qiita.com/tosane932

Top comments (0)