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

IJHack / QtPass / 40416601267

26 Oct 2026 10:59PM UTC coverage: 92.554% (-0.3%) from 92.861%
40416601267

push

github

web-flow
Security scan follow-ups: WSL signing key, decompression limit, templates, OTP seed, copy hints (#2051)

* Security scan follow-ups: WSL signing key, decompression limit, templates

From a CodeRabbit security scan of e5ea4ec16, each finding checked
against the code:

- WSL (Medium): the signing key never reached pass running in WSL, so
  pass there did not check .gpg-id.sig. WSLENV now forwards
  PASSWORD_STORE_SIGNING_KEY, and the length and character set get /u:
  their /w meant "from WSL out", so pass in WSL never saw them either.
- Decompression bombs (Low, 2 findings): every gpg decryption QtPass
  runs (show, re-encrypt and its check, content search) passes
  --max-output 64 MiB, and the pass backend gets the same through
  PASSWORD_STORE_GPG_OPTS after the user's own options. A small
  compressed entry in a shared store could expand to gigabytes.
- Template files (Low): .templates and .default_template are opened
  with Util::openRegularFile (no links, devices or FIFOs) and refused
  above 64 KiB; a link to /dev/zero made QtPass read forever on the GUI
  thread.
- otpauth seed (Low): a bare otpauth:// line is never split into a
  field, also not when the template lists "otpauth"; the scheme-less
  value was shown seed and all.
- Copy out of a shown entry (Low): the field values and the rest of the
  entry are SecretTextBrowsers, which put the same secret hints on
  whatever leaves them (Ctrl+C, context menu, drag, X11 selection) as
  ClipboardManager does, and report it; ClipboardManager::trackCopied()
  clears it with the rest, beside the copied password, not instead of
  it.

The sixth finding (a signed list replayed from another store that uses
the same signing key) is a design limitation: SECURITY.md documents it
with the advice of one signing key per store, and #2050 has the design
options. CHANGELOG: security entries, and an upgrade note that WSL users
with a signing key need a signed .gpg-id and the key inside WSL.

A test per fix; the o... (continued)

113 of 129 new or added lines in 10 files covered. (87.6%)

37 existing lines in 3 files now uncovered.

9298 of 10046 relevant lines covered (92.55%)

200.43 hits per line

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

86.9
/src/nativegrep.cpp
1
// SPDX-FileCopyrightText: 2026 Anne Jan Brouwer
2
// SPDX-License-Identifier: GPL-3.0-or-later
3
#include "nativegrep.h"
4
#include "executor.h"
5
#include "pass.h"
6
#include "util.h"
7
#include <QDir>
8
#include <QElapsedTimer>
9
#include <QPointer>
10
#include <QProcess>
11
#include <QRegularExpression>
12
#include <QThread>
13
#include <utility>
14

15
NativeGrep::NativeGrep(QObject *parent) : QObject(parent) {}
191 ✔
16

17
NativeGrep::~NativeGrep() {
191 ✔
18
  static constexpr int kThreadTimeoutMs = 5000;
19
  cancel();
191 ✔
20
  QElapsedTimer elapsed;
191 ✔
21
  elapsed.start();
191 ✔
22
  for (const Worker &w : std::as_const(m_workers)) {
191 ✔
UNCOV
23
    if (w.thread && w.thread->isRunning()) {
×
24
      const int remaining =
25
          kThreadTimeoutMs - static_cast<int>(elapsed.elapsed());
×
26
      if (remaining > 0)
×
27
        w.thread->wait(remaining);
×
28
    }
29
  }
30
}
191 ✔
31

32
/**
33
 * @brief Decrypt one .gpg file and return lines matching rx.
34
 */
35
auto NativeGrep::matchFile(const QProcessEnvironment &env,
65 ✔
36
                           const QString &gpgExe, const QString &filePath,
37
                           const QRegularExpression &rx,
38
                           const std::atomic_bool *cancel) -> QStringList {
39
  const QString translatedPath =
40
      Executor::translatePathForWsl(filePath, gpgExe);
65 ✔
41
  QString plaintext;
65 ✔
42
  // The QProcess overload is the one that takes the cancel flag; it polls
43
  // the flag while waiting and terminates (then kills) gpg from this thread,
44
  // the one that owns the process.
45
  QProcess gpg;
65 ✔
46
  gpg.setProcessEnvironment(env);
65 ✔
47
  const int rc = Executor::executeBlocking(
65 ✔
48
      gpg, gpgExe,
49
      QStringList{"-d", "--quiet", "--yes", "--no-encrypt-to", "--batch",
520 ✔
50
                  "--use-agent"} +
390 ✔
51
          Pass::maxOutputArgs() + QStringList{translatedPath},
260 ✔
52
      QString(), &plaintext, nullptr, cancel);
65 ✔
53
  if (rc != 0 || plaintext.isEmpty())
65 ✔
54
    return {};
4 ✔
55
  QStringList matches;
61 ✔
56
  for (const QString &line : plaintext.split('\n')) {
196 ✔
57
    QString candidate = line;
58
    if (candidate.endsWith('\r'))
74 ✔
59
      candidate.chop(1);
×
60
    const QString t = candidate.trimmed();
61
    if (!t.isEmpty() && candidate.contains(rx))
74 ✔
62
      matches << t;
63
  }
64
  return matches;
65
}
130 ✔
66

67
/**
68
 * @brief Walk the store, decrypt every .gpg file, collect matches.
69
 */
70
auto NativeGrep::scanStore(const QProcessEnvironment &env,
10 ✔
71
                           const QString &gpgExe, const QString &storeDir,
72
                           const QRegularExpression &rx,
73
                           const std::atomic_bool *cancel)
74
    -> QList<QPair<QString, QStringList>> {
75
  QList<QPair<QString, QStringList>> results;
10 ✔
76
  // Regular files only: a link or junction is not an entry, and searching
77
  // through one would decrypt files outside the store.
78
  const QStringList files =
79
      Util::regularFilesUnder(storeDir, QStringList() << "*.gpg");
30 ✔
80
  for (const QString &filePath : files) {
74 ✔
81
    if (QThread::currentThread()->isInterruptionRequested() ||
64 ✔
82
        (cancel != nullptr && cancel->load()))
64 ✔
83
      return {};
×
84
    const QStringList matches = matchFile(env, gpgExe, filePath, rx, cancel);
64 ✔
85
    if (!matches.isEmpty()) {
64 ✔
86
      QString entry = QDir(storeDir).relativeFilePath(filePath);
61 ✔
87
      if (entry.endsWith(QLatin1String(".gpg")))
61 ✔
88
        entry.chop(4);
61 ✔
89
      results.append({entry, matches});
61 ✔
90
    }
91
  }
92
  return results;
93
}
94

95
/**
96
 * @brief Start a search on a worker thread.
97
 *
98
 * Results are emitted on the owner's thread via QMetaObject::invokeMethod. A
99
 * sequence counter discards results from superseded searches; the previous
100
 * search is asked to stop but not waited for, since blocking the UI thread
101
 * while gpg decrypts would freeze the interface.
102
 */
103
void NativeGrep::search(const QString &pattern, bool caseInsensitive,
10 ✔
104
                        const QString &gpgExe, const QString &storeDir,
105
                        const QProcessEnvironment &env) {
106
  cancel();
10 ✔
107

108
  // Advance the sequence before any early return so in-flight workers from the
109
  // previous query fail the seq check and cannot publish stale results.
110
  const int seq = ++m_seq;
10 ✔
111

112
  // Use trimmed() rather than isEmpty(): a whitespace-only string is a valid
113
  // regex that matches every non-empty line, which is almost never intentional
114
  // and would decrypt the entire store.
115
  //
116
  // Both early returns post finished() via Qt::QueuedConnection so that the
117
  // signal is always delivered asynchronously after search() returns, matching
118
  // the contract of the threaded path.
119
  if (pattern.trimmed().isEmpty()) {
10 ✔
120
    QMetaObject::invokeMethod(
×
121
        this,
122
        [this, seq]() {
×
123
          if (m_seq == seq)
×
124
            emit finished({});
×
125
        },
×
126
        Qt::QueuedConnection);
127
    return;
1 ✔
128
  }
129

130
  const QRegularExpression rx(
131
      pattern, caseInsensitive ? QRegularExpression::CaseInsensitiveOption
132
                               : QRegularExpression::PatternOptions{});
20 ✔
133
  if (!rx.isValid()) {
10 ✔
134
    QMetaObject::invokeMethod(
1 ✔
135
        this,
136
        [this, seq]() {
1 ✔
137
          if (m_seq == seq)
1 ✔
138
            emit finished({});
2 ✔
139
        },
1 ✔
140
        Qt::QueuedConnection);
141
    return;
142
  }
143
  QPointer<NativeGrep> self(this);
144

145
  auto emitResults = [self, seq](QList<QPair<QString, QStringList>> results) {
18 ✔
146
    if (!self)
9 ✔
147
      return;
148
    QMetaObject::invokeMethod(
9 ✔
149
        self,
150
        [self, seq, results = std::move(results)]() {
27 ✔
151
          if (self && self->m_seq == seq)
18 ✔
152
            emit self->finished(results);
9 ✔
153
        },
9 ✔
154
        Qt::QueuedConnection);
155
  };
9 ✔
156

157
  auto cancelFlag = std::make_shared<std::atomic_bool>(false);
9 ✔
158
  QThread *thread = QThread::create([gpgExe, storeDir, env, rx, cancelFlag,
18 ✔
159
                                     emitResults = std::move(emitResults)]() {
160
    std::move(emitResults)(
9 ✔
161
        scanStore(env, gpgExe, storeDir, rx, cancelFlag.get()));
9 ✔
162
  });
9 ✔
163

164
  m_workers.append({thread, cancelFlag});
18 ✔
165
  connect(thread, &QThread::finished, thread, &QObject::deleteLater);
9 ✔
166
  connect(thread, &QThread::finished, this, [this, thread]() {
9 ✔
167
    m_workers.removeIf(
9 ✔
168
        [thread](const Worker &w) { return w.thread == thread; });
9 ✔
169
  });
170
  thread->start();
9 ✔
171
}
19 ✔
172

173
void NativeGrep::cancel() {
392 ✔
174
  for (const Worker &w : std::as_const(m_workers)) {
393 ✔
175
    w.cancel->store(true);
176
    if (w.thread && w.thread->isRunning())
1 ✔
177
      w.thread->requestInterruption();
1 ✔
178
  }
179
}
392 ✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc