fix(security): path-traversal hardening in new-file/rename + drag/drop#1464
Conversation
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/119 util tests pass, 30/30 storemodel tests pass, doxygen zero warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Caution Review failedFailed to post review comments 📝 WalkthroughWalkthroughAll locale .ts files update MainWindow/StoreModel message locations, add new validation (“Invalid name” and outside-store warning), extend sharing/export GPG texts, adjust create/delete/rename prompts, and remap the overwrite confirmation to “Force overwrite?”. ChangesGlobal Localization Update
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~30 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
Code Review: PR #1464 — Path-traversal hardeningOverall: Well-structured security fix addressing two real vulnerabilities. Sound approach with canonical-path resolution. ~210 real lines of code, well-tested. Implementation Assessment
Correctly handles three cases:
Clean wrapper;
Would be slightly more robust to use
Validates both source and destination endpoints, logged with Security Analysis
No TOCTOU race beyond inherent filesystem model. Acceptable for this threat model. Tests (
|
| Test | Covers |
|---|---|
isPathInStoreHappyPath |
Root, child, grandchild (existing + non-existing) |
isPathInStoreRejectsDotDotEscape |
.., sub/../../.., sibling-directory .. |
isPathInStoreRejectsAbsoluteOutside |
/etc/passwd, /tmp/elsewhere.gpg |
isPathInStoreRejectsSymlinkEscape |
Symlink inside pointing out (skipped on Windows) |
isPathInStoreAllowsNewChild |
New file + new deep path |
isPathInStoreRejectsEmptyArgs |
Empty root, empty candidate, non-existent root |
All tests verify setup operations (no tautology assertions). Good coverage.
Minor Observations
-
isPathInStorereturn on non-existent root —QDir(nonExistentRoot).canonicalPath()returns emptyQString, causing early returnfalse. This is correct but worth noting if store path is transiently unavailable. -
Trailing separator assumption —
confirmPathInStore(QtPassSettings::getPassStore() + file)inaddPasswordconcatenates directly rather than usingQDir(store).absoluteFilePath(file). In practicenormalizeFolderPathensures trailing/, but it's the one caller that doesn't go throughQDirfor path construction. -
No
&mnemonic on "Invalid name" — Fine forQMessageBox::warningtitles. Existing dialog titles like "Error" also lack mnemonics, so this is in line.
Conclusion
LGTM. The fix is correct, tests are thorough, and the approach (canonical path resolution for both existing and non-existing paths) covers all identified attack vectors. The ~210 substantive lines are clean, follow existing code conventions, and are properly documented with Doxygen comments.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #1464 +/- ##
==========================================
+ Coverage 33.31% 33.47% +0.16%
==========================================
Files 44 44
Lines 4341 4377 +36
==========================================
+ Hits 1446 1465 +19
- Misses 2895 2912 +17
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. |
… in dropMimeData (#1466) #1464 added a canonical-path check in StoreModel::executeDropAction that refuses any drop whose source or destination resolves outside the password store. The patch shipped with unit tests on the Util::isPathInStore helper, but the integration of that helper into the drop flow was only manually verified. These three tests close that gap by crafting mime data with out-of-store source paths and asserting dropMimeData() returns false without ever reaching Pass::Move/Copy. - dropMimeDataRejectsSourceOutsideStore: mime carries /etc/passwd as the source. canDropMimeData() lets it through (it's a UI policy layer, not a fs check); executeDropAction's Util::isPathInStore call must reject. - dropMimeDataRejectsAbsoluteOutsideSource: source is a real filesystem path inside a sibling QTemporaryDir, so the check runs through canonical resolution on an existing path rather than a non-existent one. - dropMimeDataRejectsSymlinkEscape: creates a symlink physically inside the store that resolves to a sibling QTemporaryDir, then drops it onto a store-internal folder. canonicalFilePath() must follow the link and reject. Unix-only — symlink creation on Windows needs developer-mode / elevation. Validated by temporarily defanging the guard (replacing the if condition with `false && (...)`) — all three tests fail without the check, confirming they actually exercise the security boundary. With the guard in place, the qWarning output names each rejection. Build clean. 33/33 storemodel tests pass (was 30, +3 new). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Two same-class security findings in the same PR:
1.
MainWindowinput dialogs — HIGH severityUser-typed names in New file, New folder, Rename folder, Rename file dialogs were concatenated with the current store-relative directory and passed straight to
Pass::Insert/mkdir/Pass::Move. Typing../../etc/passwd(or pasting an absolute path) would have QtPass create / move / rename outside the password store via GPG encryption.2.
StoreModel::executeDropAction— MEDIUM severityThe encoded mime payload was trusted as-is, and there was no canonical-path check. A crafted drop or a symlink inside the store pointing outside (e.g. into
~/.ssh) would escape.Implementation
Util::isPathInStore(storeRoot, candidate)— canonicalises the candidate viaQFileInfo::canonicalFilePath()for existing targets, or canonicalises the nearest existing ancestor and re-appends the leaf for not-yet-created paths. Returnstrueiff 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()— wrapsUtil::isPathInStoreand shows a non-blocking "Invalid name" warning before bailing. Called fromaddPassword,addFolder,renameFolder,renamePasswordbefore theInsert/mkdir/Movecall.StoreModel::executeDropAction()— rejects (returnsfalse) 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::fileName()returns just the last path component), so the constructed target is always inside the store.Tests
6 new cases in
tst_util:isPathInStoreHappyPath— root, child, grandchildisPathInStoreRejectsDotDotEscape—..andsub/../..isPathInStoreRejectsAbsoluteOutside—/etc/passwd,/tmp/elsewhereisPathInStoreRejectsSymlinkEscape— symlink inside store pointing outisPathInStoreAllowsNewChild— typical create flowisPathInStoreRejectsEmptyArgs— defensiveTest plan
qmake6 -r && make -j4cleantests/auto/util/tst_util— 119 passed (was 113, +6 new)tests/auto/model/tst_storemodel— 30 passed (no regressions)doxygen Doxyfile— zero warningsclang-formatapplied../escapein New file — warning shown, store unchangedNote on the diff size
+3805/-2955looks alarming but ~3600 of those lines arelupdatereflowing<location filename="..." line="N"/>references in 63.tsfiles — the new code inmainwindow.cppshifted line numbers downstream. Zero translated strings change. The actual code change is +210 lines across 6 source/test files.🤖 Generated with Claude Code
Summary by CodeRabbit