• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

IJHack / QtPass / 25731493901

12 May 2026 11:28AM UTC coverage: 28.416% (+0.2%) from 28.263%
25731493901

push

github

web-flow
fix(security): path-traversal hardening in new-file/rename + drag/drop (#1464)

User-typed names in MainWindow's "new file" / "new folder" / "rename"
input dialogs were concatenated with the current store-relative directory
and passed onward without validation. A user could type
"../../etc/passwd" (or paste an absolute path) and QtPass would happily
create / move / rename outside the password store via GPG encryption.

Symmetric weakness in StoreModel::executeDropAction: the encoded mime
payload was trusted, and there was no check that the resolved source +
destination paths stayed inside the store. A crafted drop or a symlink
inside the store pointing outside (e.g. into ~/.ssh) would escape.

Fix:

- Util::isPathInStore(storeRoot, candidate): canonicalises the candidate
  via QFileInfo::canonicalFilePath() for existing targets, or canonicalises
  the nearest existing ancestor and re-appends the leaf for not-yet-created
  paths. Returns true iff the result is equal to or strictly inside the
  canonicalised store root. Catches `..` escapes, absolute escapes, and
  symlink-out from inside the store.

- MainWindow::confirmPathInStore(): wraps Util::isPathInStore and shows a
  non-blocking "Invalid name" warning before bailing. Called from
  addPassword, addFolder, renameFolder, renamePassword before the Insert /
  mkdir / Move call.

- StoreModel::executeDropAction(): rejects (returns false) any drop whose
  source or destination resolves outside the store, logging a warning.
  Both endpoints are validated; final move destination is constructed
  from a canonical-inside-store base plus a leaf segment (QFileInfo's
  fileName() returns just the last path component), so the constructed
  target is always inside the store.

Tests (tst_util): 6 new cases — happy path, `..` escape, absolute path
escape, symlink-out escape (skipped on Windows where link creation needs
elevation), allows-new-child for the create flow, and empty-args edge
cases.

Build clean, 119... (continued)

23 of 38 new or added lines in 3 files covered. (60.53%)

14 existing lines in 3 files now uncovered.

1909 of 6718 relevant lines covered (28.42%)

26.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

57.49
/src/storemodel.cpp
1
// SPDX-FileCopyrightText: 2014 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "storemodel.h"
4
#include "qtpasssettings.h"
5

6
#include "util.h"
7
#include <QApplication>
8
#include <QDebug>
9
#include <QFileSystemModel>
10
#include <QMessageBox>
11
#include <QMimeData>
12
#include <QRegularExpression>
13
#include <QtGlobal>
14

15
auto operator<<(
8✔
16
    QDataStream &out,
17
    const dragAndDropInfoPasswordStore &dragAndDropInfoPasswordStore)
18
    -> QDataStream & {
19
  out << static_cast<quint8>(dragAndDropInfoPasswordStore.kind)
8✔
20
      << dragAndDropInfoPasswordStore.path;
8✔
21
  return out;
8✔
22
}
23

24
auto operator>>(QDataStream &in,
8✔
25
                dragAndDropInfoPasswordStore &dragAndDropInfoPasswordStore)
26
    -> QDataStream & {
27
  quint8 k;
28
  in >> k >> dragAndDropInfoPasswordStore.path;
8✔
29
  switch (k) {
8✔
30
  case static_cast<quint8>(dragAndDropInfoPasswordStore::ItemKind::Directory):
3✔
31
    dragAndDropInfoPasswordStore.kind =
3✔
32
        dragAndDropInfoPasswordStore::ItemKind::Directory;
33
    break;
3✔
34
  case static_cast<quint8>(dragAndDropInfoPasswordStore::ItemKind::File):
4✔
35
    dragAndDropInfoPasswordStore.kind =
4✔
36
        dragAndDropInfoPasswordStore::ItemKind::File;
37
    break;
4✔
38
  default:
1✔
39
    dragAndDropInfoPasswordStore.kind =
1✔
40
        dragAndDropInfoPasswordStore::ItemKind::Unknown;
41
    break;
1✔
42
  }
43
  return in;
8✔
44
}
45

46
/**
47
 * @brief StoreModel::StoreModel
48
 * SubClass of QSortFilterProxyModel via
49
 * http://www.qtcentre.org/threads/46471-QTreeView-Filter
50
 */
51
StoreModel::StoreModel() { fs = nullptr; }
27✔
52

53
/**
54
 * @brief StoreModel::filterAcceptsRow should row be shown, wrapper for
55
 * StoreModel::showThis method.
56
 * @param sourceRow
57
 * @param sourceParent
58
 * @return
59
 */
60
auto StoreModel::filterAcceptsRow(int sourceRow,
59✔
61
                                  const QModelIndex &sourceParent) const
62
    -> bool {
63
  QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
59✔
64
  return showThis(index);
59✔
65
}
66

67
/**
68
 * @brief StoreModel::showThis should a row be shown, based on our search
69
 * criteria.
70
 * @param index
71
 * @return
72
 */
73
auto StoreModel::showThis(const QModelIndex &index) const -> bool {
132✔
74
  bool retVal = false;
75
  if (fs == nullptr) {
132✔
76
    return retVal;
77
  }
78
  // Gives you the info for number of children with a parent
79
  if (sourceModel()->rowCount(index) > 0) {
131✔
80
    for (int nChild = 0; nChild < sourceModel()->rowCount(index); ++nChild) {
72✔
81
      QModelIndex childIndex = sourceModel()->index(nChild, 0, index);
72✔
82
      if (!childIndex.isValid()) {
83
        break;
84
      }
85
      retVal = showThis(childIndex);
72✔
86
      if (retVal) {
72✔
87
        break;
88
      }
89
    }
90
  } else {
91
    QModelIndex useIndex = sourceModel()->index(index.row(), 0, index.parent());
59✔
92
    QString path = fs->filePath(useIndex);
59✔
93
    path = QDir(store).relativeFilePath(path);
118✔
94
    if (path.startsWith(".git")) {
118✔
95
      return false;
96
    }
97
    path.replace(Util::endsWithGpg(), "");
58✔
98
    retVal = path.contains(filterRegularExpression());
58✔
99
  }
100
  return retVal;
101
}
102

103
/**
104
 * @brief StoreModel::setModelAndStore update the source model and store.
105
 * @param sourceModel
106
 * @param passStore
107
 */
108
void StoreModel::setModelAndStore(QFileSystemModel *sourceModel,
22✔
109
                                  const QString &passStore) {
110
  setSourceModel(sourceModel);
22✔
111
  fs = sourceModel;
22✔
112
  store = passStore;
22✔
113
}
22✔
114

115
void StoreModel::setStore(const QString &passStore) {
×
116
#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0)
117
  // beginFilterChange() is available since Qt 6.9, but the Direction-scoped
118
  // endFilterChange(QSortFilterProxyModel::Direction) overload is only
119
  // available since Qt 6.10, which is the preferred API for scoped and more
120
  // efficient filter updates.
121
  beginFilterChange();
122
  store = passStore;
123
  endFilterChange(QSortFilterProxyModel::Direction::Rows);
124
#else
125
  // Direction-scoped filter changes are unavailable before Qt 6.10, so older
126
  // Qt versions must manually invalidate filters. We update the store and
127
  // manually invalidate the filter as a compatibility path.
128
  store = passStore;
×
129
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
130
  QSortFilterProxyModel::invalidateFilter();
×
131
#else
132
  invalidateFilter();
133
#endif
134
#endif
135
}
×
136

137
/**
138
 * @brief StoreModel::data don't show the .gpg at the end of a file.
139
 * @param index
140
 * @param role
141
 * @return
142
 */
143
auto StoreModel::data(const QModelIndex &index, int role) const -> QVariant {
4✔
144
  if (!index.isValid()) {
145
    return {};
146
  }
147

148
  QVariant initial_value;
149
  initial_value = QSortFilterProxyModel::data(index, role);
1✔
150

151
  if (role == Qt::DisplayRole) {
1✔
152
    QString name = initial_value.toString();
1✔
153
    name.replace(Util::endsWithGpg(), "");
1✔
154
    initial_value.setValue(name);
1✔
155
  }
156

157
  return initial_value;
1✔
158
}
1✔
159

160
/**
161
 * @brief StoreModel::supportedDropActions enable drop.
162
 * @return
163
 */
164
auto StoreModel::supportedDropActions() const -> Qt::DropActions {
1✔
165
  return Qt::CopyAction | Qt::MoveAction;
1✔
166
}
167

168
/**
169
 * @brief StoreModel::supportedDragActions enable drag.
170
 * @return
171
 */
172
auto StoreModel::supportedDragActions() const -> Qt::DropActions {
1✔
173
  return Qt::CopyAction | Qt::MoveAction;
1✔
174
}
175

176
/**
177
 * @brief StoreModel::flags
178
 * @param index
179
 * @return
180
 */
181
auto StoreModel::flags(const QModelIndex &index) const -> Qt::ItemFlags {
2✔
182
  Qt::ItemFlags defaultFlags = QSortFilterProxyModel::flags(index);
2✔
183

184
  if (index.isValid()) {
185
    return Qt::ItemIsDragEnabled | Qt::ItemIsDropEnabled | defaultFlags;
186
  }
187
  return Qt::ItemIsDropEnabled | defaultFlags;
1✔
188
}
189

190
/**
191
 * @brief StoreModel::mimeTypes
192
 * @return
193
 */
194
auto StoreModel::mimeTypes() const -> QStringList {
1✔
195
  QStringList types;
1✔
196
  types << "application/vnd+qtpass.dragAndDropInfoPasswordStore";
1✔
197
  return types;
1✔
198
}
199

200
/**
201
 * @brief StoreModel::mimeData
202
 * @param indexes
203
 * @return
204
 */
205
auto StoreModel::mimeData(const QModelIndexList &indexes) const -> QMimeData * {
1✔
206
  dragAndDropInfoPasswordStore info;
1✔
207

208
  QByteArray encodedData;
1✔
209
  // only use the first, otherwise we should enable multiselection
210
  QModelIndex index = indexes.at(0);
1✔
211
  if (index.isValid()) {
212
    QModelIndex useIndex = mapToSource(index);
1✔
213
    const QFileInfo fileInfo = fs->fileInfo(useIndex);
1✔
214

215
    if (fileInfo.isDir()) {
1✔
216
      info.kind = dragAndDropInfoPasswordStore::ItemKind::Directory;
×
217
    } else if (fileInfo.isFile()) {
1✔
218
      info.kind = dragAndDropInfoPasswordStore::ItemKind::File;
1✔
219
    }
220
    info.path = fileInfo.absoluteFilePath();
2✔
221
    QDataStream stream(&encodedData, QIODevice::WriteOnly);
1✔
222
    stream << info;
1✔
223
  }
1✔
224

225
  auto *mimeData = new QMimeData();
1✔
226
  mimeData->setData("application/vnd+qtpass.dragAndDropInfoPasswordStore",
2✔
227
                    encodedData);
228
  return mimeData;
1✔
229
}
230

231
/**
232
 * @brief StoreModel::canDropMimeData
233
 * @param data
234
 * @param action
235
 * @param row
236
 * @param column
237
 * @param parent
238
 * @return
239
 */
240
auto StoreModel::canDropMimeData(const QMimeData *data, Qt::DropAction action,
8✔
241
                                 int row, int column,
242
                                 const QModelIndex &parent) const -> bool {
243
#ifdef QT_DEBUG
244
  qDebug() << action << row;
245
#else
246
  Q_UNUSED(action)
247
  Q_UNUSED(row)
248
#endif
249

250
  if (data == nullptr ||
15✔
251
      !data->hasFormat("application/vnd+qtpass.dragAndDropInfoPasswordStore")) {
15✔
252
    return false;
253
  }
254

255
  QByteArray encodedData =
256
      data->data("application/vnd+qtpass.dragAndDropInfoPasswordStore");
12✔
257
  if (encodedData.isEmpty()) {
6✔
258
    return false;
259
  }
260
  QDataStream stream(&encodedData, QIODevice::ReadOnly);
5✔
261
  dragAndDropInfoPasswordStore info;
5✔
262
  stream >> info;
5✔
263
  if (stream.status() != QDataStream::Ok) {
5✔
264
    return false;
265
  }
266

267
  QModelIndex useIndex =
268
      this->index(parent.row(), parent.column(), parent.parent());
5✔
269

270
  if (column > 0) {
5✔
271
    return false;
272
  }
273

274
  using IK = dragAndDropInfoPasswordStore::ItemKind;
275
  // you can drop a folder on a folder
276
  if (fs->fileInfo(mapToSource(useIndex)).isDir() &&
8✔
277
      info.kind == IK::Directory) {
2✔
278
    return true;
279
  }
280
  // you can drop a file on a folder
281
  if (fs->fileInfo(mapToSource(useIndex)).isDir() && info.kind == IK::File) {
5✔
282
    return true;
283
  }
284
  // you can drop a file on a file
285
  if (fs->fileInfo(mapToSource(useIndex)).isFile() && info.kind == IK::File) {
3✔
286
    return true;
287
  }
288

289
  return false;
290
}
5✔
291

292
/**
293
 * @brief StoreModel::dropMimeData
294
 * @param data
295
 * @param action
296
 * @param row
297
 * @param column
298
 * @param parent
299
 * @return
300
 */
301
auto StoreModel::dropMimeData(const QMimeData *data, Qt::DropAction action,
×
302
                              int row, int column, const QModelIndex &parent)
303
    -> bool {
304
  if (!canDropMimeData(data, action, row, column, parent)) {
×
305
    return false;
306
  }
307

308
  if (action == Qt::IgnoreAction) {
×
309
    return true;
310
  }
311

312
  if (action != Qt::MoveAction && action != Qt::CopyAction) {
×
313
    return false;
314
  }
315

316
  dragAndDropInfoPasswordStore info;
×
317
  if (!parseDropData(data, &info)) {
×
318
    return false;
319
  }
320

321
  return executeDropAction(info, action, parent);
×
322
}
323

324
auto StoreModel::parseDropData(const QMimeData *data,
×
325
                               dragAndDropInfoPasswordStore *outInfo) -> bool {
326
  QByteArray encodedData =
327
      data->data("application/vnd+qtpass.dragAndDropInfoPasswordStore");
×
328
  if (encodedData.isEmpty()) {
×
329
    return false;
330
  }
331

332
  QDataStream stream(&encodedData, QIODevice::ReadOnly);
×
333
  dragAndDropInfoPasswordStore info;
×
334
  stream >> info;
×
335
  if (stream.status() != QDataStream::Ok) {
×
336
    return false;
337
  }
338

339
  *outInfo = info;
340
  return true;
×
341
}
×
342

343
auto StoreModel::executeDropAction(const dragAndDropInfoPasswordStore &info,
×
344
                                   Qt::DropAction action,
345
                                   const QModelIndex &parent) -> bool {
346
  QModelIndex destIndex =
347
      this->index(parent.row(), parent.column(), parent.parent());
×
348
  QFileInfo destFileinfo = fs->fileInfo(mapToSource(destIndex));
×
349
  QFileInfo srcFileInfo = QFileInfo(info.path);
×
350

351
  QString cleanedSrc = QDir::cleanPath(srcFileInfo.absoluteFilePath());
×
352
  QString cleanedDest = QDir::cleanPath(destFileinfo.absoluteFilePath());
×
353

354
  // Both endpoints must resolve inside the password store after symlink
355
  // resolution. Drop data is encoded by the dragged item but could be
356
  // crafted; canonical-path checks stop drops that would move/copy outside
357
  // the store or follow a symlink out (e.g. a symlink within the store
358
  // pointing at /etc).
NEW
359
  const QString storeRoot = QtPassSettings::getPassStore();
×
NEW
360
  if (!Util::isPathInStore(storeRoot, cleanedSrc) ||
×
NEW
361
      !Util::isPathInStore(storeRoot, cleanedDest)) {
×
NEW
362
    qWarning() << "executeDropAction: rejecting drop that escapes the store"
×
NEW
363
               << "(src=" << cleanedSrc << "dest=" << cleanedDest << ")";
×
NEW
364
    return false;
×
365
  }
366

367
  switch (info.kind) {
×
368
  case dragAndDropInfoPasswordStore::ItemKind::Directory:
×
369
    return handleDirDrop(cleanedSrc, destFileinfo, srcFileInfo, action);
×
370
  case dragAndDropInfoPasswordStore::ItemKind::File:
×
371
    return handleFileDrop(cleanedSrc, cleanedDest, destFileinfo, action);
×
372
  default:
373
    qWarning() << "executeDropAction: unexpected ItemKind, ignoring drop";
×
374
    return false;
×
375
  }
376
}
×
377

378
auto StoreModel::handleDirDrop(const QString &cleanedSrc,
×
379
                               const QFileInfo &destFileinfo,
380
                               const QFileInfo &srcFileInfo,
381
                               Qt::DropAction action) -> bool {
382
  if (!destFileinfo.isDir()) {
×
383
    return false;
384
  }
385

386
  QDir destDir = QDir(QDir::cleanPath(destFileinfo.absoluteFilePath()))
×
387
                     .filePath(srcFileInfo.fileName());
×
388
  QString cleanedDestDir = QDir::cleanPath(destDir.absolutePath());
×
389

390
  if (action == Qt::MoveAction) {
×
391
    QtPassSettings::getPass()->Move(cleanedSrc, cleanedDestDir);
×
392
  } else if (action == Qt::CopyAction) {
×
393
    QtPassSettings::getPass()->Copy(cleanedSrc, cleanedDestDir);
×
394
  }
395
  return true;
396
}
×
397

398
auto StoreModel::handleFileDrop(const QString &cleanedSrc,
×
399
                                const QString &cleanedDest,
400
                                const QFileInfo &destFileinfo,
401
                                Qt::DropAction action) -> bool {
402
  if (destFileinfo.isDir()) {
×
403
    return handleFileToDirDrop(cleanedSrc, cleanedDest, action);
×
404
  }
405
  return handleFileToFileDrop(cleanedSrc, cleanedDest, action);
×
406
}
407

408
auto StoreModel::handleFileToDirDrop(const QString &cleanedSrc,
×
409
                                     const QString &cleanedDest,
410
                                     Qt::DropAction action) -> bool {
411
  if (action == Qt::MoveAction) {
×
412
    QtPassSettings::getPass()->Move(cleanedSrc, cleanedDest);
×
413
  } else if (action == Qt::CopyAction) {
×
414
    QtPassSettings::getPass()->Copy(cleanedSrc, cleanedDest);
×
415
  }
416
  return true;
×
417
}
418

419
auto StoreModel::handleFileToFileDrop(const QString &cleanedSrc,
×
420
                                      const QString &cleanedDest,
421
                                      Qt::DropAction action) -> bool {
422
  QWidget *parentWidget = qobject_cast<QWidget *>(parent());
423
  int answer = QMessageBox::question(
×
424
      parentWidget, tr("Force overwrite?"),
×
425
      tr("overwrite %1 with %2?").arg(cleanedDest, cleanedSrc),
×
426
      QMessageBox::Yes | QMessageBox::No);
427
  bool force = answer == QMessageBox::Yes;
×
428

429
  if (action == Qt::MoveAction) {
×
430
    QtPassSettings::getPass()->Move(cleanedSrc, cleanedDest, force);
×
431
  } else if (action == Qt::CopyAction) {
×
432
    QtPassSettings::getPass()->Copy(cleanedSrc, cleanedDest, force);
×
433
  }
434
  return true;
×
435
}
436

437
/**
438
 * @brief StoreModel::lessThan
439
 * @param source_left
440
 * @param source_right
441
 * @return
442
 */
443
auto StoreModel::lessThan(const QModelIndex &source_left,
2✔
444
                          const QModelIndex &source_right) const -> bool {
445
/* matches logic in QFileSystemModelSorter::compareNodes() */
446
#ifndef Q_OS_MAC
447
  if (fs && (source_left.column() == 0 || source_left.column() == 1)) {
2✔
448
    bool leftD = fs->isDir(source_left);
2✔
449
    bool rightD = fs->isDir(source_right);
2✔
450

451
    if (leftD ^ rightD) {
2✔
452
      return leftD;
453
    }
454
  }
455
#endif
456

457
  return QSortFilterProxyModel::lessThan(source_left, source_right);
1✔
458
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc