
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.

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
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.
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.
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:
[[...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.
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:
path.normalize(), path.resolve() - neither is called. Whatever segments arrive are concatenated verbatim.filePath still lives inside UPLOAD_DIRECTORY.+ '/' + 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.
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.
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.
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 keyDATABASE_URL - full Postgres credentialsStep 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.
CVSS 4.0 9.3 AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N
CVSS 3.1 9.8 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
PR:N and UI:N are the two metrics doing the work. The route requires no session and no victim interaction - the attacker acts alone, over the network, against a default configuration.
The one honest limiter is the configuration gate: deployments on S3 or R2 are not exposed, because the route rewrites to /404. That reduces the affected population but not the severity for anyone inside it - and local is the shipped default.
The maintainers' patch (7936062) is eight lines and worth reading, because it is correct in a way these fixes often are not:
+import { resolve, sep } from 'path';
...
- const filePath =
- process.env.UPLOAD_DIRECTORY + '/' + (path ?? []).join('/');
+ const base = resolve(process.env.UPLOAD_DIRECTORY!);
+ const filePath = resolve(base, (path ?? []).join('/'));
+ // Confine reads to UPLOAD_DIRECTORY. resolve() collapses any `..` segments
+ // (including URL-decoded ones), so this blocks every path-traversal variant.
+ if (filePath !== base && !filePath.startsWith(base + sep)) {
+ return new NextResponse('Not found', { status: 404 });
+ }
Two things it gets right:
resolve() collapses .. after decoding is complete, so it does not matter how the traversal was smuggled through routing. The check now sits where the danger is.base + sep, not base. A naive filePath.startsWith(base) would accept /app/uploads-evil/x as being inside /app/uploads - a classic prefix-match bypass. Appending the separator closes it, and the filePath !== base clause keeps the directory itself valid.That is the right shape for a containment check: resolve, then compare against the base with a trailing separator.
All times UTC, 2026-07-20 unless noted.
| Time | Event |
|---|---|
| 05:55 | Advisory reported to the Postiz team |
| 07:44 | Acknowledged and verified by the maintainers |
Six hours and twenty-three minutes from report to shipped patch, on an open-source project with no bug bounty attached. I have had reports sit untouched for months at organisations with dedicated security teams. Credit to Enno Gelhaus for coordinating and Nevo David for the remediation.
A control passing your test does not mean the control is in the right place. The 404 was real. Next.js genuinely does collapse ../. The defense simply ran before the input finished being decoded, which meant it was guarding the router rather than the filesystem call. When you find a mitigation, ask when it executes relative to the sink - not just whether it exists.
Encoding is a layer, and layers get peeled at different rates. Any time two components in a request pipeline disagree about how many times to decode, the gap between them is exploitable. Route matchers, middleware, and handlers frequently disagree.
Rank a file-read primitive by what the process can reach, not by the primitive. "Arbitrary file read" sounds like information disclosure. It became Critical because the environment was readable, the secret in it signed sessions, and those sessions never expired. Follow the chain before you score it.
Non-expiring tokens turn a leak into a permanent compromise. A signing key disclosure with short-lived tokens is a bad day. With no expiresIn, it is unrecoverable without rotating the secret - and most operators will never know they needed to.
Run the control case. Verifying that a token signed with the wrong secret is rejected is what separates a demonstrated finding from an assumed one.
If you self-host Postiz:
JWT_SECRET is compromised if you ran an affected version on a publicly reachable host with STORAGE_PROVIDER=local. Rotate it. Because tokens carry no expiry, rotation is the only way to invalidate any that were forged.DATABASE_URL credentials and any connected-provider OAuth secrets held in the same environment.GET requests to /uploads/ containing %2e or %2f.Research conducted independently and disclosed to the vendor under coordinated disclosure. Published after the fix shipped and the advisory went public. No third-party systems were accessed - all validation was performed against a local instance built from the project's own source.
This writeup is licensed under CC BY 4.0 - share and adapt freely with attribution. Code excerpts from gitroomhq/postiz-app are quoted for security analysis and remain under that project's license.
Krithik Babu P - @DarkLycn1976
| 12:18 | Fix committed, verified, and published |
| 2026-08-07 14:13 | CVE-2026-19264 assigned by Postiz (CNA) |
| 2026-08-07 14:15 | GitHub Security Advisory published |