A minimal Flask + MySQL proof-of-concept demonstrating how user input is stored raw in a database and how the safety of that data depends entirely on how it is rendered — not how it is stored.
Built for educational and demonstration purposes.
- User input is saved to MySQL without any sanitization
- The same raw data can be rendered three different ways:
- JSON — safe by nature (data format, no execution context)
- Safe HTML — escaped at render time using output encoding
- Unsafe HTML — injected directly into the DOM, enabling stored XSS
- Stored XSS — a payload written once executes for anyone who views the unsafe output
- Output encoding is the correct defense, not input sanitization
.
├── app.py # Flask backend
├── requirements.txt # Python dependencies
├── setup.sql # MySQL database + user setup
├── .gitignore
├── screenshots/ # Demo screenshots
└── templates/
├── index.html # Main UI (textarea + all buttons)
└── entries.html # Standalone HTML table view
Debian / Kali / Ubuntu:
sudo apt install mariadb-server -y
sudo service mariadb startsudo mysql -u root < setup.sqlThis creates:
- Database:
poc_db - User:
poc_user/ password:poc_password - Table:
entries(auto-created byapp.pyon first run)
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txtpython app.pyOpen http://localhost:5000 in your browser.
Submit this payload using the text box:
<img src=x onerror="alert('XSS')">This works because when injected into the DOM via innerHTML, the browser loads the broken image, fires the onerror event, and executes the JavaScript. Note that <script> tags injected via innerHTML are not executed by browsers — event handler attributes like onerror bypass this restriction.
- Paste the attack string into the text box and click ↑ SUBMIT
- Click { } JSON — payload is visible as a raw string, safe
- Click </> HTML — payload is escaped and rendered as harmless text
- Click ⚠ UNSAFE — payload executes, alert fires
The quotes inside the payload are escaped (\") because JSON requires it. This is JSON serialization, not security — the underlying value in the database is the unescaped string. You can verify this directly:
mysql -u poc_user -ppoc_password poc_db -e "SELECT content FROM entries\G"The < and > characters are converted to < and > before being inserted into the DOM. The browser renders them as visible text rather than interpreting them as HTML tags. The database still holds the raw payload — it is neutralized at render time only.
The same data is injected directly into innerHTML with no escaping. The browser parses it as real HTML, the broken image triggers onerror, and the JavaScript executes. The alert shows 127.0.0.1:5000 confirming it ran in the page context.
When the payload <img src=x onerror="alert('XSS')"> is serialized to JSON, the double quotes inside become \":
"content": "<img src=x onerror=\"alert('XSS')\">"This is JSON encoding, not security filtering. JSON requires quotes inside strings to be escaped so the format stays valid and parseable. When JavaScript receives and parses this JSON, it reconstructs the original unescaped string perfectly — which is exactly what gets injected into the DOM in the unsafe view.
To see the raw unescaped value as it actually sits in MySQL:
mysql -u poc_user -ppoc_password poc_db -e "SELECT id, content FROM entries\G"Output will show:
*************************** 1. row ***************************
id: 1
content: <img src=x onerror="alert('XSS')">
No escaping. Exactly what was typed.
| Button | Route | Behavior |
|---|---|---|
| ↑ SUBMIT | POST /submit |
Saves raw input to MySQL, no sanitization |
| { } JSON | GET /entries/json |
Returns data as JSON — safe by nature |
| </> HTML | inline | Renders with output encoding — safe by design |
| ⚠ UNSAFE | inline | Renders raw into innerHTML — XSS executes |
| ⌫ CLEAR DB | POST /clear |
Deletes all rows, resets auto-increment |
Override DB credentials without editing code:
| Variable | Default |
|---|---|
DB_HOST |
localhost |
DB_USER |
poc_user |
DB_PASS |
poc_password |
DB_NAME |
poc_db |
Example:
DB_USER=myuser DB_PASS=mypass python app.pyThe vulnerability is not in how data is stored — it is in how data is rendered.
The same dirty string in the database is harmless when JSON-encoded or HTML-escaped, and dangerous when dumped raw into the DOM. Output encoding at render time is the correct defense. Input sanitization alone is insufficient because you cannot always predict every context in which data will be displayed.
This project is intentionally vulnerable. Run it only on localhost or in an isolated environment. Do not deploy it publicly.


