CVE-2026-41180
Description
PsiTransfer is an open source, self-hosted file sharing solution. Prior to version 2.4.3, the upload PATCH flow under /files/:uploadId validates the mounted request path using the still-encoded req.path, but the downstream tus handler later writes using the decoded req.params.uploadId. In deployments that use a supported custom PSITRANSFER_UPLOAD_DIR whose basename prefixes a startup-loaded JavaScript path, such as conf, an unauthenticated attacker can create config.<NODE_ENV>.js in the application root. The attacker-controlled file is then executed on the next process restart. Version 2.4.3 contains a patch.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
psitransfernpm | < 2.4.3 | 2.4.3 |
Affected products
1Patches
18b547bf3e097fix: Harden file uploads
11 files changed · +377 −24
.github/workflows/ci.yaml+28 −0 added@@ -0,0 +1,28 @@ +name: CI + +on: + push: + branches: + - master + - main + pull_request: + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test
lib/db.js+1 −1 modified@@ -33,7 +33,7 @@ module.exports = class DB { } } }; - setInterval(gc, 60*1000); + setInterval(gc, 60 * 1000).unref(); }
lib/endpoints.js+51 −18 modified@@ -44,6 +44,16 @@ function sha256Hex(input) { return createHash('sha256').update(input).digest('hex'); } +/** Decoded path segment under the /files mount (must match req.params used by tusboy). */ +function decodedUploadPathSegment(req) { + const raw = req.path.startsWith('/') ? req.path.slice(1) : req.path; + try { + return decodeURIComponent(raw); + } catch { + return null; + } +} + const pugVars = { baseUrl: config.baseUrl }; @@ -364,6 +374,14 @@ app.get(`${ config.baseUrl }files/:fid`, async (req, res, next) => { // Download single file debug(`Download ${ req.params.fid }`); try { + if (req.params.fid.includes('++') && !utils.isSafeTusUploadId(req.params.fid)) { + return res.status(404).send(errorPage({ + ...pugVars, + error: 'Invalid link', + lang: req.translations, + uploadAppPath: config.uploadAppPath || config.baseUrl, + })); + } const info = await store.info(req.params.fid); // throws on 404 const safeName = utils.toSafeBasename(info.metadata.name, info.key); res.set('Content-Disposition', contentDispositionUtf8Filename(safeName, info.key)); @@ -414,32 +432,44 @@ app.use(`${ config.uploadAppPath }files`, if (req.method === 'GET') return res.status(405).end(); + const fid = decodedUploadPathSegment(req); + if (fid === null) { + return res.status(400).end('Invalid path encoding'); + } + // Lock bucket by PATCH /files/:sid?lock=yes - const fid = req.path.substring(1); - if(!fid.includes('++') && req.method === 'PATCH' && req.query.lock) { + if (fid && !fid.includes('++') && req.method === 'PATCH' && req.query.lock) { + if (!utils.isSafeBucketFid(fid)) { + return res.status(400).end('Invalid bucket id'); + } await db.lock(fid); return res.status(204).end('Bucket locked'); } - if(['POST', 'PATCH'].includes(req.method)) { - // Restrict upload to the bucket if it is locked - if(!fid.includes('++') && db.isLocked(fid)) { + if (['POST', 'PATCH'].includes(req.method)) { + if (fid && !fid.includes('++') && !utils.isSafeBucketFid(fid)) { + return res.status(400).end('Invalid bucket id'); + } + if (fid && !fid.includes('++') && db.isLocked(fid)) { return res.status(400).end('Bucket locked'); } - try { - const info = await store.info(fid); - // Restrict upload to the bucket if it is locked - if(info.metadata.locked) { - return res.status(400).end('Bucket locked'); - } - // Restrict upload to a file which upload completed already - if(!info.isPartial) { - return res.status(400).end('Upload already completed'); + if (fid) { + if (fid.includes('++') && !utils.isSafeTusUploadId(fid)) { + return res.status(400).end('Invalid upload id'); } - } catch(e) { - if(! e instanceof httpErrors.NotFound) { - console.error(e); - return; + try { + const info = await store.info(fid); + if (info.metadata.locked) { + return res.status(400).end('Bucket locked'); + } + if (!info.isPartial) { + return res.status(400).end('Upload already completed'); + } + } catch (e) { + if (!(e instanceof httpErrors.NotFound)) { + console.error(e); + return next(e); + } } } } @@ -452,6 +482,9 @@ app.use(`${ config.uploadAppPath }files`, try { assert(meta.name, 'tus meta prop missing: name'); assert(meta.sid, 'tus meta prop missing: sid'); + if (!utils.isSafeBasename(meta.sid)) { + return res.status(400).end('Invalid bucket id'); + } assert(meta.retention, 'tus meta prop missing: retention'); assert(Object.keys(config.retentions).indexOf(meta.retention) >= 0, `invalid tus meta prop retention. Value ${ meta.retention } not in [${ Object.keys(config.retentions).join(',') }]`);
lib/store.js+9 −4 modified@@ -22,16 +22,21 @@ class StreamLen extends Transform { class Store { constructor(targetDir) { - this.dir = path.normalize(targetDir); + this.dir = path.resolve(targetDir); } getFilename(fid) { - let p = path.resolve(this.dir, fid.replace('++', '/')); - if(!p.startsWith(this.dir)) { + if (typeof fid !== 'string' || fid.includes('\0')) { throw new Error('file name not in jail path. aborting'); } - return p; + const base = this.dir; + const resolved = path.resolve(base, fid.replace(/\+\+/g, '/')); + const rel = path.relative(base, resolved); + if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) { + throw new Error('file name not in jail path. aborting'); + } + return resolved; }
lib/tusboy/handlers/head.js+5 −0 modified@@ -14,11 +14,16 @@ // const encodeMetadata = require('../tus-metadata').encode; +const utils = require('../../utils'); +const errors = require('../errors'); module.exports = (store) => ( async (req, res) => { res.set('Cache-Control', 'no-store') const { uploadId } = req.params + if (!utils.isSafeTusUploadId(uploadId)) { + throw errors.unknownResource(uploadId) + } const upload = await store.info(uploadId) // The Server MUST always include the Upload-Offset header in the // response for a HEAD request, even if the offset is 0, or the upload
lib/tusboy/handlers/patch.js+5 −0 modified@@ -31,6 +31,7 @@ // data as possible. const storeErrors = require('../store/errors'); const errors = require('../errors'); +const utils = require('../../utils'); module.exports = (store, { onComplete, @@ -56,6 +57,10 @@ module.exports = (store, { const uploadId = req.params.uploadId + if (!utils.isSafeTusUploadId(uploadId)) { + throw errors.unknownResource(uploadId) + } + try { const { offset,
lib/utils.js+27 −0 modified@@ -40,9 +40,36 @@ function isSafeBasename(name, opts = {}) { return toSafeBasename(name, '', opts) === name; } +// Node randomUUID() v4 shape (case-insensitive). +const UUID_V4_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +/** + * Tus resume URL id: exactly one "++", safe bucket sid, UUID key. + */ +function isSafeTusUploadId(uploadId) { + if (typeof uploadId !== 'string' || uploadId.includes('\0')) return false; + const parts = uploadId.split('++'); + if (parts.length !== 2) return false; + const [sid, key] = parts; + if (!sid || !key) return false; + return isSafeBasename(sid) && UUID_V4_RE.test(key); +} + +/** + * Bucket-only id (lock PATCH, middleware checks without "++"). + */ +function isSafeBucketFid(fid) { + if (typeof fid !== 'string' || fid.includes('++') || fid.includes('\0')) return false; + return isSafeBasename(fid); +} + module.exports = { toSafeBasename, isSafeBasename, + isSafeTusUploadId, + isSafeBucketFid, + UUID_V4_RE, };
package.json+4 −1 modified@@ -35,7 +35,10 @@ "scripts": { "start": "NODE_ENV=production node app.js", "dev": "NODE_ENV=dev DEBUG=psitransfer:* nodemon -i app -i dist -i data app.js", - "debug": "node --inspect app.js" + "debug": "node --inspect app.js", + "test": "node --test 'tests/**/*.test.js'", + "test:unit": "node --test tests/unit/*.test.js", + "test:integration": "node --test tests/integration/*.test.js" }, "engines": { "node": ">= 24"
tests/integration/middleware.test.js+136 −0 added@@ -0,0 +1,136 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const http = require('node:http'); + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'psitransfer-int-')); +process.env.PSITRANSFER_UPLOAD_DIR = tmpDir; + +const { test, after } = require('node:test'); +const assert = require('node:assert'); + +const tusMeta = require('../../lib/tusboy/tus-metadata'); +const app = require('../../lib/endpoints'); + +const TUS = { 'Tus-Resumable': '1.0.0' }; +const UUID = '00000000-0000-4000-8000-000000000001'; + +function request(port, method, pathname, headers = {}, body = null) { + return new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + method, + path: pathname, + headers, + }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + resolve({ + status: res.statusCode, + body: Buffer.concat(chunks).toString(), + }); + }); + } + ); + req.on('error', reject); + if (body != null) req.write(body); + req.end(); + }); +} + +after(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +test('store.info error that is not NotFound yields 500 (instanceof guard)', async (t) => { + const sid = 'corruptsid'; + const bucketDir = path.join(tmpDir, sid); + const fileBase = path.join(bucketDir, UUID); + fs.mkdirSync(bucketDir, { recursive: true }); + fs.writeFileSync(`${fileBase}.json`, '{ not json', 'utf8'); + fs.writeFileSync(fileBase, '', 'utf8'); + + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + try { + const fid = `${sid}++${UUID}`; + const res = await request(port, 'PATCH', `/files/${encodeURIComponent(fid)}`, { + ...TUS, + 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + }); + assert.strictEqual(res.status, 500); + } finally { + await new Promise((resolve) => server.close(resolve)); + fs.rmSync(bucketDir, { recursive: true, force: true }); + } +}); + +test('PATCH with encoded traversal in upload id is rejected before Tus handler', async (t) => { + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + try { + const pathname = + '/files/foo%2B%2B' + encodeURIComponent('../..') + '%2Fetc%2Fpasswd'; + const res = await request(port, 'PATCH', pathname, { + ...TUS, + 'Upload-Offset': '0', + 'Content-Type': 'application/offset+octet-stream', + }); + assert.ok(res.status === 400 || res.status === 404); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + +test('POST with malicious meta.sid is rejected', async (t) => { + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + try { + const meta = { + name: 'x', + sid: '../../etc', + retention: '3600', + }; + const res = await request(port, 'POST', '/files/', { + ...TUS, + 'Upload-Length': '10', + 'Upload-Metadata': tusMeta.encode(meta), + 'Content-Type': 'application/offset+octet-stream', + }); + assert.strictEqual(res.status, 400); + assert.match(res.body, /bucket|Invalid/i); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +}); + +test('lock PATCH with traversal bucket id is rejected', async (t) => { + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + try { + const res = await request( + port, + 'PATCH', + '/files/' + encodeURIComponent('..') + '%2F' + encodeURIComponent('..') + '%2Fetc?lock=yes', + TUS + ); + assert.strictEqual(res.status, 400); + } finally { + await new Promise((resolve) => server.close(resolve)); + } +});
tests/unit/store-containment.test.js+70 −0 added@@ -0,0 +1,70 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const Store = require('../../lib/store'); + +const SAMPLE_UUID = '00000000-0000-4000-8000-000000000001'; + +test('getFilename rejects prefix false-positive (startsWith bypass)', () => { + const jailParent = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-jail-')); + try { + const base = path.join(jailParent, 'conf'); + fs.mkdirSync(base, { recursive: true }); + fs.writeFileSync(path.join(jailParent, 'config.production.js'), 'x'); + const store = new Store(base); + assert.throws(() => store.getFilename('..++config.production.js'), /jail/); + } finally { + fs.rmSync(jailParent, { recursive: true, force: true }); + } +}); + +test('getFilename rejects traversal via multiple ++ segments', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-store-')); + try { + const store = new Store(base); + assert.throws(() => store.getFilename('legit++..++..++outside'), /jail/); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +}); + +test('getFilename rejects NUL in fid', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-store-')); + try { + const store = new Store(base); + assert.throws( + () => store.getFilename(`legit++${SAMPLE_UUID}\0/../../../etc/passwd`), + /jail/ + ); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +}); + +test('getFilename rejects resolved path equal to jail root (rel empty)', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-store-')); + try { + const store = new Store(base); + assert.throws(() => store.getFilename('bucket++..'), /jail/); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +}); + +test('getFilename accepts valid sid++uuid path under jail', () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'psi-store-')); + try { + const store = new Store(base); + const fid = `mybucket++${SAMPLE_UUID}`; + const got = store.getFilename(fid); + const expected = path.join(base, 'mybucket', SAMPLE_UUID); + assert.strictEqual(got, expected); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + } +});
tests/unit/upload-id.test.js+41 −0 added@@ -0,0 +1,41 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const utils = require('../../lib/utils'); + +const UUID = '00000000-0000-4000-8000-000000000001'; + +test('isSafeTusUploadId rejects sid with path traversal', () => { + assert.strictEqual(utils.isSafeTusUploadId(`../../evil++${UUID}`), false); +}); + +test('isSafeTusUploadId rejects non-UUID key', () => { + assert.strictEqual(utils.isSafeTusUploadId('legit++not-a-uuid'), false); +}); + +test('isSafeTusUploadId rejects extra ++ separators', () => { + assert.strictEqual(utils.isSafeTusUploadId('a++b++c'), false); +}); + +test('isSafeTusUploadId rejects empty sid or key', () => { + assert.strictEqual(utils.isSafeTusUploadId(`++${UUID}`), false); + assert.strictEqual(utils.isSafeTusUploadId('sid++'), false); +}); + +test('isSafeTusUploadId accepts valid compound id', () => { + assert.strictEqual(utils.isSafeTusUploadId(`abc12++${UUID}`), true); +}); + +test('isSafeBucketFid rejects traversal-shaped id', () => { + assert.strictEqual(utils.isSafeBucketFid('../etc'), false); +}); + +test('isSafeBucketFid rejects compound id', () => { + assert.strictEqual(utils.isSafeBucketFid(`x++${UUID}`), false); +}); + +test('isSafeBucketFid accepts plain bucket id', () => { + assert.strictEqual(utils.isSafeBucketFid('abc123'), true); +});
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-533q-w4g6-5586ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-41180ghsaADVISORY
- github.com/psi-4ward/psitransfer/commit/8b547bf3e09757122efa00aab90281e3915aa0c6nvdWEB
- github.com/psi-4ward/psitransfer/releases/tag/v2.4.3nvdWEB
- github.com/psi-4ward/psitransfer/security/advisories/GHSA-533q-w4g6-5586nvdWEB
News mentions
0No linked articles in our index yet.