Sploitus

Exploit for CVE-2026-19264

githubexploit Β· 2026-08-10

Exploit Code

README182 lines
## https://sploitus.com/exploit?id=966ACC03-34B3-5709-9857-B930EA4D07A0
![CVE-2026-19264 - Unauthenticated Path Traversal to Full Instance Takeover in Postiz](cover.png)

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

**Author:** Krithik Babu P ([@DarkLycn1976](https://github.com/DarkLycn1976))
**Published:** 2026-08-10
**CVE:** [CVE-2026-19264](https://vulners.com/cve/CVE-2026-19264)
**Severity:** Critical - CVSS 4.0 **9.3** / CVSS 3.1 **9.8**
**CWE:** [CWE-22](https://cwe.mitre.org/data/definitions/22.html) - Improper Limitation of a Pathname to a Restricted Directory
**Affected:** `gitroomhq/postiz-app` ` {
  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:

```http
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:

```http
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:  }` 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

```
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.

## 8. The fix

The maintainers' patch ([`7936062`](https://github.com/gitroomhq/postiz-app/commit/7936062)) is eight lines and worth reading, because it is correct in a way these fixes often are not:

```diff
+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:

1. **It normalises at the sink.** `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.
2. **It compares against `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.

## 9. Disclosure timeline

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 |
| 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 |

**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.

## 10. Takeaways

**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.

## 11. References

- CVE record - https://vulners.com/cve/CVE-2026-19264
- GitHub Security Advisory - https://github.com/gitroomhq/postiz-app/security/advisories/GHSA-4hgh-5rhf-4qpm
- Postiz CNA advisory (PSA-2026-TH12B7) - https://gadvisory.org/advisories/PSA-2026-TH12B7
- Fix commit - https://github.com/gitroomhq/postiz-app/commit/7936062
- Patched release v2.22.1 - https://github.com/gitroomhq/postiz-app/releases/tag/v2.22.1
- CWE-22 - https://cwe.mitre.org/data/definitions/22.html

---

## Remediation guidance

If you self-host Postiz:

1. **Upgrade to v2.22.1 or later.** This is the fix.
2. **Assume `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.
3. **Rotate `DATABASE_URL` credentials and any connected-provider OAuth secrets** held in the same environment.
4. Check access logs for `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.*

---

## License

This writeup is licensed under [CC BY 4.0](https://creativecommons.org/licenses/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](https://github.com/DarkLycn1976)