Skip to content
KitploitKITPLOIT
ToolsExploitsBlog
Log in
Submit
ToolsExploitsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
CVE-2026-19264 — CVE-2026-19264 - Critical unauthenticated path traversal to full instance takeover in Postiz (< 2.22.1). Technical writeup: decode-order bypass, JWT_SECRET escalation, and analysis of the upstream fix. | Kitploit
Tools/GitHubGitHub/darklycn1976/cve-2026-19264
Vulnerability AnalysisCode AnalysisExploitationWeb Application ExploitationWeb SecurityLearning & Education
GitHubdarklycn1976/cve-2026-19264

CVE-2026-19264

CVE-2026-19264 - Critical unauthenticated path traversal to full instance takeover in Postiz (< 2.22.1). Technical writeup: decode-order bypass, JWT_SECRET escalation, and analysis of the upstream fix.

View Repository
81 month agoNot yet reviewed
Website

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

CVE-2026-19264 - Unauthenticated Path Traversal to Full Instance Takeover in Postiz

CVE-2026-19264 - Unauthenticated Path Traversal to Full Instance Takeover in Postiz

Author: Krithik Babu P (@DarkLycn1976) Published: 2026-08-10 CVE: CVE-2026-19264 Severity: Critical - CVSS 4.0 9.3 / CVSS 3.1 9.8 CWE: CWE-22 - Improper Limitation of a Pathname to a Restricted Directory Affected: gitroomhq/postiz-app < 2.22.1 Fixed in: v2.22.1


TL;DR

Postiz served locally-stored media through a route that joined URL-supplied path segments onto the upload directory and streamed the result back - with no path normalisation, no containment check, and no authentication.

The obvious traversal payload returns 404, because Next.js collapses ../ segments before routing. But URL-encoded separators survive route matching and are decoded exactly once more on the way to the filesystem call, restoring the traversal on the far side of every check.

An unauthenticated attacker could read any file readable by the application process - including its own environment, which holds the JWT signing secret. Because Postiz signs session tokens with that secret and issues them without an expiry claim, recovering it converts a file-read primitive into a permanent, forgeable session as any user, including an administrator.

One unauthenticated GET request to full instance takeover.


1. Background

Postiz is an open-source social media scheduling platform - roughly 34,000 GitHub stars at the time of writing - built as a Next.js frontend with a NestJS backend. It is widely self-hosted by agencies and small teams to manage connected social accounts, scheduled content, and billing.

Self-hosted deployments can store uploaded media locally rather than on object storage. That behaviour is controlled by a single environment variable:

STORAGE_PROVIDER=local

This is the value shipped in .env.example, so it is what most self-hosters run unless they deliberately configure S3 or Cloudflare R2.

2. The attack surface

When local storage is active, next.config.js rewrites the public path /uploads/:path* onto an internal API route:

apps/frontend/src/app/(app)/api/uploads/[[...path]]/route.ts

Two properties make this route interesting before any bug is involved:

  1. It is unauthenticated. No frontend middleware guards it. Serving public media is the intent, so no session is required.
  2. It is a catch-all. The [[...path]] optional catch-all segment means every remaining path component arrives as an array the handler is free to interpret.

When STORAGE_PROVIDER is anything other than local, the rewrite points at /404 and the handler is unreachable. That configuration gate is the only thing standing between a deployment and this bug.

3. The vulnerable code

The handler, prior to v2.22.1:

export const GET = async (request: NextRequest, context) => {
  const { path } = await context.params;
  const filePath =
    process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');

  const response  = createReadStream(filePath);
  const fileStats = statSync(filePath);
  // ... stream the file back to the caller
};

Three defects in four lines:

  • No normalisation. path.normalize(), path.resolve() - neither is called. Whatever segments arrive are concatenated verbatim.
  • No containment check. Nothing verifies that the resulting filePath still lives inside UPLOAD_DIRECTORY.
  • String concatenation, not path joining. + '/' + treats the components as text, not as a path with semantics.

The result goes straight into createReadStream() and the bytes are streamed to the caller with a MIME type inferred from the filename. There is no allow-list of extensions and no content filter.

4. Why the obvious payload fails

The textbook attack is:

GET /uploads/../../../etc/passwd

On Postiz this returns 404, and that 404 is the entire reason this bug survived to be found.

Next.js normalises the request path during routing. Raw ../ segments are collapsed before the router decides which handler to invoke. By the time the request reaches the catch-all, the traversal has already been eliminated - either the path resolves somewhere with no matching route, or it resolves back inside /uploads with the dot-segments gone.

To someone testing quickly, that 404 reads as "the framework handles this". It is a genuine, working defense. The problem is not that it is absent - it is where in the pipeline it runs.

5. The bypass - a decoding-order mismatch

Route matching and the request handler do not perform the same number of percent-decoding passes.

If the separators are percent-encoded, the sequence is not a path separator during route matching. %2e%2e%2f is just an opaque string - inert text that the normaliser has no reason to touch. It sails through routing intact, gets matched by the catch-all, and is decoded on its way into the handler's params, where it becomes ../ again.

At that point it is concatenated onto UPLOAD_DIRECTORY and handed to createReadStream() - past routing, past normalisation, past every control that would have stopped it.

Working forms:

GET /uploads/%2e%2e%2fsecretdir%2fsecret.txt        → 200, file outside the upload directory
GET /uploads/..%2f..%2f..%2fetc%2fpasswd            → 200
GET /uploads/%2e%2e%2f%2e%2e%2f...%2fetc%2fpasswd   → 200, returned the real /etc/passwd

Double-encoding does not work - %252e stays literal through the single decode pass and never becomes a dot. Exactly one layer of encoding is the sweet spot, which is a useful reminder that "encode it harder" is not a strategy.

The invariant to hold onto:

A control that runs before decoding is complete is not protecting the sink.

6. Escalation - from arbitrary read to instance takeover

A file-read primitive is High on its own. What makes this Critical is what it reaches.

Step 1 - read the environment. The Node process's own configuration is on disk in the deployment root. .env yields, among other things:

  • JWT_SECRET - the session token signing key
  • DATABASE_URL - full Postgres credentials
  • Connected provider OAuth secrets and billing keys

Step 2 - forge a session. Postiz signs session tokens with JWT_SECRET using HS256 via jsonwebtoken. Critically, tokens are issued with no expiresIn, so a forged token is valid indefinitely.

Step 3 - become anyone. The authentication middleware re-resolves the user from the database using the id claim. It deliberately does not trust a claim like isSuperAdmin from the token - good design - but that hardening is irrelevant once you can sign an arbitrary id. Signing { id: <victim user id> } produces a session indistinguishable from a legitimate login:

read .env  →  JWT_SECRET  →  sign({ id: victim })  →  authenticated as victim, forever

I validated this against the project's real verification logic with the actual jsonwebtoken dependency: a token signed with the recovered secret was accepted, and the same token signed with a wrong secret was rejected. The control case matters - without it you have an assumption, not a finding.

Step 4 - the parallel path. DATABASE_URL alone is sufficient for direct Postgres access: read every connected account, or flip an administrator flag directly.

No password. No prior access. No user interaction. One unauthenticated HTTP request.

7. Impact and scoring

Download Tool