Blind Stored XSS Through a Text File Upload

/

Text file upload flowing through storage into an unsafe HTML preview and internal review dashboard

A plain text upload should remain plain text. In this case, an accepted .txt file was stored and later rendered as active HTML. That small content-handling mistake created stored cross-site scripting in the document viewer and, after a delay, blind execution inside an internal review workflow.

This research was performed through an authorized, invite-only bug bounty program. The company’s identity, endpoints, callback infrastructure, raw payloads, and captured values are intentionally omitted.

Case study at a glance

  • Entry point: an authorized file-upload feature that accepted text, PDF, and CSV documents
  • Primary flaw: text-file contents were interpreted as HTML instead of displayed as inert text
  • Execution path: immediate execution in a file viewer followed by delayed execution in a separate internal workflow
  • Impact: persistent JavaScript execution in another user’s browser with exposure of authenticated session context
  • Outcome: the report was escalated to P1 after the downstream impact was validated

The upload was the injection point

The vulnerable input was not a filename, caption, or metadata field. It was the content of the uploaded file itself.

The application allowed several business-document formats, including .txt, PDF, and CSV. I identified separate concerns while looking at the other formats, but they are outside the scope of this case study. The important path here began with a text file containing markup in its body.

When the text document was opened, the viewer did not preserve the file as plain text. It delivered the content through an HTML rendering path. The interface appeared to place the document inside a preformatted-text container, but the file body had not been safely encoded first. As a result, markup in the file could break out of that presentation layer and become part of the page.

That distinction matters. The upload validation worked in the narrow sense that it accepted an allowed extension. The security failure happened later, when a downstream component assigned executable meaning to data that should have remained inert.

From a text preview to stored XSS

I started with harmless visual markers to determine how the viewer handled the document body. Plain formatting changes appeared in the rendered page, which showed that the viewer was parsing markup rather than displaying literal characters. Benign script-execution markers then confirmed that the browser was executing content stored inside the uploaded file.

This was stored XSS because the payload persisted with the uploaded document and ran again whenever the document was viewed. It did not depend on persuading someone to click a crafted external link. Opening a document through the normal workflow was enough.

// Unsafe concept: uploaded text becomes markup
preview.innerHTML = uploadedFileContents;

// Safer concept: uploaded text remains text
preview.textContent = uploadedFileContents;

The exact implementation was more complex than those two lines, but the trust-boundary failure was the same: untrusted document content crossed into an HTML execution context.

Why the blind callback took time

The first execution happened in the document delivery and preview layer. That proved the rendering flaw, but it did not yet show the most important business impact. The upload also traveled through a larger operational workflow, and I did not have access to every interface that consumed it.

Later, an out-of-band callback arrived from a different application context. The timing and request context showed that the same stored document had been opened inside a backend dashboard used by the company. That was the blind portion of the finding: the payload executed in a browser and interface I could not see directly.

The delay was not noise. It revealed that the document moved asynchronously between storage, preview, and internal review systems. Each consumer handled the same untrusted file, but not necessarily with the same origin, browser controls, or authenticated state.

Demonstrating impact without crossing the line

The internal callback contained enough browser and session context to demonstrate that execution had reached an authenticated workflow. That changed the risk from a self-contained preview issue to a credible session-hijack and sensitive-data exposure path.

I did not replay session material, impersonate the internal user, or attempt to access additional records. Once the evidence showed where the code executed and what browser context was exposed, further exploitation was unnecessary.

There was also an important coordination lesson. When the program asked to continue validation in a test environment, the right move was to stop sending new live payloads and give the security team a controlled reproduction package. Blind findings can tempt a researcher to keep testing because feedback is delayed. Authorization and restraint still define the stopping point.

Why the report reached P1

A stored XSS label alone does not explain severity. The complete chain did:

  • The attacker-controlled content was stored in a normal business document workflow.
  • Viewing the uploaded file triggered execution without an extra phishing step.
  • The document was consumed by more than one application and security context.
  • The delayed execution reached an authenticated internal interface.
  • The resulting browser context created a credible path to session compromise and exposure of sensitive workflow data.

The program initially assessed the report below critical severity. Once the internal execution path was reproduced and its session impact was understood, the report was escalated to P1. The stronger rating came from the proven trust-boundary chain, not from a more dramatic payload.

The root cause was content handling

File security cannot stop at extension and size checks. A safe upload pipeline has to preserve the intended meaning of a file every time it is stored, transformed, previewed, and reviewed.

  • Type confusion: a file accepted as plain text was later handled as an HTML document.
  • Missing output encoding: document characters were allowed to become DOM markup.
  • Unsafe inline preview: active content was displayed instead of downloaded or rendered in an inert viewer.
  • Shared downstream trust: the same stored object reached internal consumers with more valuable browser context.
  • Insufficient browser containment: defense-in-depth controls did not prevent the document from executing script.

How to remediate this class of issue

The durable fix is to make uploaded content inert by design, then add browser controls in case one layer fails:

  1. Serve text documents with the correct Content-Type: text/plain and X-Content-Type-Options: nosniff.
  2. Use Content-Disposition: attachment when an inline preview is not required.
  3. When previewing text, write it with a text-only DOM API or escape HTML metacharacters before display.
  4. Do not trust a client-supplied extension or MIME value. Determine type server-side and maintain a safe renderer allowlist.
  5. Isolate untrusted documents on a separate origin that has no application cookies or access to authenticated storage.
  6. Sandbox preview frames and apply a restrictive Content Security Policy that does not permit inline or remote scripts.
  7. Test every accepted format across every downstream consumer, not only the upload confirmation page.

What testers should take from it

The most useful question was not simply, “Can I upload this file?” It was, “Where will this file be rendered next?” Upload systems often feed preview services, case-management tools, support queues, moderation panels, and data-processing jobs. Those downstream paths may be more exposed than the upload form itself.

  • Test the body of each accepted document type, not only filenames and metadata.
  • Separate proof of execution from proof of business impact.
  • Use unique, non-sensitive callback markers so delayed events can be attributed without collecting unnecessary data.
  • Expect asynchronous systems to produce callbacks hours later, but coordinate before repeating production tests.
  • Stop once the impact is clear. A report becomes stronger through precise evidence, not avoidable access.

This finding became my first P1 because a harmless-looking text upload crossed several trust boundaries and eventually executed where the business risk was highest. The lesson is straightforward: uploaded files are not passive just because their extensions look familiar. Their security depends on every component that later decides how to interpret them.

GK Data LLC performs authorized web and API security testing with manual validation, evidence-focused reporting, and practical remediation guidance.


Need this kind of issue found before release?

GK Data LLC provides web, API, mobile, cloud, and network testing with manual verification and remediation-focused reporting.

Next article