June 3, 2026
The Payload That Bypassed the Filter
The payload I used during testing was:
EL_Cazad0r
1 min read
<a href=javascript:alert("cookie="+document.cookie+"|JWT="+localStorage.AccessToken)>!!! Click here for Details !!!</a><a href=javascript:alert("cookie="+document.cookie+"|JWT="+localStorage.AccessToken)>!!! Click here for Details !!!</a>At first glance, this may look like a normal javascript: URI payload, but there is one important difference.
Instead of using:
javascript:javascript:I used:
javascript:javascript:The sequence j is an HTML numeric character reference that represents the lowercase letter j.
When the payload was submitted, the backend received:
javascript:javascript:rather than:
javascript:javascript:The application's filter relied on simple string matching and explicitly blocked the literal text:
javascript:javascript:Because the string javascript: never appeared in the raw request, the filter allowed the payload to be stored.
Why the Bypass Worked
The filter and the browser were processing the content differently.
During storage:
<a href=javascript:alert(...)><a href=javascript:alert(...)>was treated as plain text.
The denylist searched for:
javascript:javascript:and found nothing.
The payload was therefore saved to the database unchanged.
Later, when the announcement was rendered to users, the browser parsed the HTML and decoded:
jjinto:
jjThe browser therefore reconstructed:
javascript:alert(...)javascript:alert(...)at runtime.
The application had blocked one representation of the value but failed to account for alternative representations that browsers automatically normalize.
The flow looked like this:
Payload Submitted
↓
javascript:
↓
Backend String Filter
↓
No Match Found
↓
Stored In Database
↓
Rendered To User
↓
Browser Decodes j
↓
javascript:
↓
JavaScript ExecutionPayload Submitted
↓
javascript:
↓
Backend String Filter
↓
No Match Found
↓
Stored In Database
↓
Rendered To User
↓
Browser Decodes j
↓
javascript:
↓
JavaScript ExecutionWhy I Included Both document.cookie and localStorage
The original proof-of-concept payload was:
<a href=javascript:alert("cookie="+document.cookie+"|JWT="+localStorage.AccessToken)>!!! Click here for Details !!!</a><a href=javascript:alert("cookie="+document.cookie+"|JWT="+localStorage.AccessToken)>!!! Click here for Details !!!</a>I intentionally included both document.cookie and localStorage.AccessToken to determine where the application stored its authentication credentials.
When the payload executed, the resulting dialog displayed:
cookie=
JWT=eyJ...cookie=
JWT=eyJ...The cookie value was empty.
This immediately indicated that the application was not using a traditional session cookie for authentication.
However, the JWT value stored inside localStorage.AccessToken was successfully retrieved and displayed.
This revealed that the application's authentication token was accessible to client-side JavaScript.
That observation significantly changed the impact assessment.
Without access to authentication material, the issue would primarily be a Stored XSS vulnerability.
Because the application stored bearer tokens in localStorage, successful JavaScript execution provided direct access to an active authenticated session.
The Real Root Cause
Many people focus on the entity encoding trick, but that was not the actual vulnerability.
The real issues were:
- A denylist-based filter instead of parser-based sanitization.
- User-controlled HTML being stored and returned unchanged.
- Angular rendering the content through
bypassSecurityTrustHtml(). - Browser entity decoding reconstructing blocked content.
- Authentication tokens being accessible through JavaScript.
The j bypass was simply the technique that exposed these underlying design weaknesses.
The most important lesson from this finding is that security controls should be designed around how browsers actually parse and execute HTML, not around matching dangerous strings in raw input.