VYPR
High severity7.5NVD Advisory· Published Apr 23, 2026· Updated Apr 29, 2026

CVE-2026-41180

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.

PackageAffected versionsPatched versions
psitransfernpm
< 2.4.32.4.3

Affected products

1

Patches

1
8b547bf3e097

fix: Harden file uploads

https://github.com/psi-4ward/psitransferChristoph WiechertApr 14, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.