Branch data Line data Source code
1 : : #include "AttachmentFileSecurity.h"
2 : :
3 : : #include <QFile>
4 : : #include <QFileInfo>
5 : : #include <QTemporaryFile>
6 : :
7 : : #include <cerrno>
8 : : #include <cstdio>
9 : : #include <cstring>
10 : :
11 : : #ifdef Q_OS_UNIX
12 : : #include <unistd.h>
13 : : #endif
14 : : #ifdef Q_OS_WIN
15 : : #include <windows.h>
16 : : #endif
17 : :
18 : : namespace AttachmentFileSecurity {
19 : :
20 : : namespace {
21 : : constexpr int kMaxFileNameBytes = 255;
22 : :
23 : : // SEC-2026-07-27-01: Unicode bidirectional formatting characters reorder the
24 : : // glyphs around them without changing the bytes. An attachment named
25 : : // "report<U+202E>fdp.exe" therefore renders as "reportexe.pdf" in the
26 : : // attachment bar and in any file manager, which is the classic extension
27 : : // spoofing trick. These characters carry no meaning in a file name, so they
28 : : // are neutralized like any other control character.
29 : 113602 : bool isBidiControl(ushort value) {
30 [ + + + + ]: 113602 : return (value >= 0x202A && value <= 0x202E) || // LRE, RLE, PDF, LRO, RLO
31 [ + + + + ]: 113596 : (value >= 0x2066 && value <= 0x2069) || // LRI, RLI, FSI, PDI
32 [ + + + + ]: 227204 : value == 0x200E || // LRM
33 : 113602 : value == 0x200F; // RLM
34 : : }
35 : :
36 : 16 : QString truncateUtf8(QString value, int maxBytes) {
37 [ - + ]: 16 : if (maxBytes <= 0)
38 : 0 : return {};
39 [ + - ]: 16 : const QByteArray encoded = value.toUtf8();
40 [ + + ]: 16 : if (encoded.size() <= maxBytes)
41 : 9 : return value;
42 : 7 : qsizetype cut = maxBytes;
43 [ + - + + ]: 24 : while (cut > 0 &&
44 [ + + ]: 12 : (static_cast<unsigned char>(encoded.at(cut)) & 0xC0) == 0x80) {
45 : 5 : --cut;
46 : : }
47 [ + - ]: 7 : return QString::fromUtf8(encoded.constData(), cut);
48 : 16 : }
49 : :
50 : 1059 : QString cappedFileName(const QString &name, int maxBytes) {
51 [ + - + + ]: 1059 : if (name.toUtf8().size() <= maxBytes)
52 : 1053 : return name;
53 : :
54 : 6 : const qsizetype dot = name.lastIndexOf(QLatin1Char('.'));
55 [ - + ]: 6 : if (dot <= 0)
56 [ # # ]: 0 : return truncateUtf8(name, maxBytes);
57 : :
58 [ + - ]: 6 : const QString extension = name.mid(dot);
59 [ + - ]: 6 : const int extensionBytes = extension.toUtf8().size();
60 [ + + ]: 6 : if (extensionBytes >= maxBytes)
61 [ + - ]: 4 : return truncateUtf8(name, maxBytes);
62 : :
63 [ + - + - ]: 2 : QString base = truncateUtf8(name.left(dot), maxBytes - extensionBytes);
64 [ - + ]: 2 : if (base.isEmpty())
65 [ # # ]: 0 : return truncateUtf8(name, maxBytes);
66 [ + - ]: 2 : return base + extension;
67 : 6 : }
68 : : } // namespace
69 : :
70 : 1059 : QString normalizedFileName(const QString &requestedName) {
71 : : // Treat attachment names as display data, never as paths. Apply the same
72 : : // policy on every platform so a synchronized name cannot become a path,
73 : : // hidden file, device name, or alternate data stream on another OS.
74 : 1059 : QString name = requestedName;
75 [ + - ]: 1059 : name.replace(QLatin1Char('\\'), QLatin1Char('/'));
76 [ + - + - ]: 1059 : name = name.section(QLatin1Char('/'), -1).trimmed();
77 [ + - ]: 1059 : name.remove(QChar(0));
78 : :
79 [ + + + - ]: 1065 : static const QString forbidden = QStringLiteral("<>:\"/\\|?*");
80 [ + + ]: 114663 : for (qsizetype i = 0; i < name.size(); ++i) {
81 : 113604 : const ushort value = name.at(i).unicode();
82 [ + - + - : 227204 : if (value < 0x20 || value == 0x7F || value == 0x2028 || value == 0x2029 ||
+ - + + ]
83 [ + + + - : 340808 : isBidiControl(value) || forbidden.contains(name.at(i)))
+ + + + ]
84 [ + - ]: 15 : name[i] = QLatin1Char('_');
85 : : }
86 : :
87 [ + - + + ]: 1066 : while (name.startsWith(QLatin1Char('.')))
88 [ + - ]: 7 : name.remove(0, 1);
89 [ + - + - ]: 2118 : while (name.endsWith(QLatin1Char('.')) ||
90 [ + - - + : 2118 : name.endsWith(QLatin1Char(' ')))
- + ]
91 [ # # ]: 0 : name.chop(1);
92 : :
93 [ - + ]: 1059 : if (name.isEmpty())
94 : 0 : name = QStringLiteral("attachment");
95 : :
96 [ + - + - ]: 1059 : const QString deviceStem = name.section(QLatin1Char('.'), 0, 0).toUpper();
97 : : static const QSet<QString> windowsDeviceNames = {
98 : 6 : QStringLiteral("CON"), QStringLiteral("PRN"), QStringLiteral("AUX"),
99 : 6 : QStringLiteral("NUL"), QStringLiteral("COM1"), QStringLiteral("COM2"),
100 : 6 : QStringLiteral("COM3"), QStringLiteral("COM4"), QStringLiteral("COM5"),
101 : 6 : QStringLiteral("COM6"), QStringLiteral("COM7"), QStringLiteral("COM8"),
102 : 6 : QStringLiteral("COM9"), QStringLiteral("LPT1"), QStringLiteral("LPT2"),
103 : 6 : QStringLiteral("LPT3"), QStringLiteral("LPT4"), QStringLiteral("LPT5"),
104 : 6 : QStringLiteral("LPT6"), QStringLiteral("LPT7"), QStringLiteral("LPT8"),
105 [ + + + - : 1203 : QStringLiteral("LPT9")};
+ + - - -
- ]
106 [ + + ]: 1059 : if (windowsDeviceNames.contains(deviceStem))
107 [ + - ]: 1 : name.prepend(QLatin1Char('_'));
108 : :
109 [ + - ]: 2118 : return cappedFileName(name, kMaxFileNameBytes);
110 [ + - - - : 1197 : }
- - ]
111 : :
112 : 6 : QString uniqueSavePath(const QDir &targetDir, const QString &requestedName,
113 : : QSet<QString> *reservedNames) {
114 : : // This existence check is a naming convenience only. Atomic installation
115 : : // and no-clobber behavior are enforced by writeAtomically() below.
116 [ + - ]: 6 : const QString fileName = normalizedFileName(requestedName);
117 [ + - ]: 6 : const QFileInfo info(fileName);
118 [ + - ]: 6 : QString baseName = info.completeBaseName();
119 [ + - ]: 6 : QString suffix = info.suffix();
120 [ - + ]: 6 : if (baseName.isEmpty()) {
121 : 0 : baseName = fileName;
122 : 0 : suffix.clear();
123 : : }
124 : :
125 : 6 : QString candidate = fileName;
126 : 6 : int counter = 2;
127 [ + - + + ]: 19 : while ((reservedNames && reservedNames->contains(candidate)) ||
128 [ + - + - : 19 : QFileInfo::exists(targetDir.filePath(candidate))) {
+ + + + +
+ - - ]
129 [ + - ]: 10 : const QString marker = QStringLiteral(" (%1)").arg(counter);
130 : : QString extension =
131 [ + + + - ]: 5 : suffix.isEmpty() ? QString() : QLatin1Char('.') + suffix;
132 [ + - ]: 5 : const int markerBytes = marker.toUtf8().size();
133 [ + - ]: 10 : extension = truncateUtf8(extension,
134 : 5 : kMaxFileNameBytes - markerBytes - 1);
135 : 5 : const int baseBudget = kMaxFileNameBytes - markerBytes -
136 [ + - ]: 5 : extension.toUtf8().size();
137 [ + - ]: 5 : QString cappedBase = truncateUtf8(baseName, baseBudget);
138 [ - + ]: 5 : if (cappedBase.isEmpty())
139 : 0 : cappedBase = QStringLiteral("a");
140 [ + - + - ]: 5 : candidate = cappedBase + marker + extension;
141 : 5 : ++counter;
142 : 5 : }
143 [ + - ]: 6 : if (reservedNames)
144 [ + - ]: 6 : reservedNames->insert(candidate);
145 [ + - ]: 12 : return targetDir.filePath(candidate);
146 : 6 : }
147 : :
148 : 8 : bool writeExactly(QIODevice &device, const QByteArray &data) {
149 : 8 : return device.write(data) == static_cast<qint64>(data.size());
150 : : }
151 : :
152 : : namespace {
153 : 2 : void setNativeError(QString *errorMessage, const QString &operation) {
154 [ - + ]: 2 : if (!errorMessage)
155 : 0 : return;
156 : : #ifdef Q_OS_WIN
157 : : *errorMessage = QStringLiteral("%1 failed with error %2")
158 : : .arg(operation)
159 : : .arg(GetLastError());
160 : : #else
161 : 2 : const int error = errno;
162 : 4 : *errorMessage = QStringLiteral("%1: %2")
163 [ + - ]: 6 : .arg(operation,
164 [ + - ]: 6 : QString::fromLocal8Bit(std::strerror(error)));
165 : : #endif
166 : : }
167 : :
168 : 7 : bool flushToDisk(QTemporaryFile &file, QString *errorMessage) {
169 [ - + ]: 7 : if (!file.flush()) {
170 [ # # ]: 0 : if (errorMessage)
171 [ # # ]: 0 : *errorMessage = file.errorString();
172 : 0 : return false;
173 : : }
174 : : #ifdef Q_OS_UNIX
175 [ - + ]: 7 : if (::fsync(file.handle()) != 0) {
176 [ # # ]: 0 : setNativeError(errorMessage, QStringLiteral("fsync"));
177 : 0 : return false;
178 : : }
179 : : #endif
180 : 7 : return true;
181 : : }
182 : :
183 : 7 : bool commitTemporaryFile(QTemporaryFile &file, const QString &path,
184 : : ExistingFilePolicy policy, QString *errorMessage) {
185 [ + - ]: 7 : const QString temporaryPath = file.fileName();
186 [ + - ]: 7 : file.close();
187 : :
188 : : #ifdef Q_OS_UNIX
189 [ + - ]: 7 : const QByteArray encodedTemporary = QFile::encodeName(temporaryPath);
190 [ + - ]: 7 : const QByteArray encodedTarget = QFile::encodeName(path);
191 [ + + ]: 7 : if (policy == ExistingFilePolicy::FailIfExists) {
192 : : // link(2) is an atomic no-clobber installation on the same filesystem.
193 [ + + ]: 3 : if (::link(encodedTemporary.constData(), encodedTarget.constData()) != 0) {
194 [ + - ]: 1 : setNativeError(errorMessage, QStringLiteral("link"));
195 : 1 : return false;
196 : : }
197 : : // Keep autoRemove enabled so destruction removes only the temporary link.
198 : 2 : return true;
199 : : }
200 : :
201 : : // rename(2) atomically replaces the directory entry. Unlike QSaveFile it
202 : : // does not resolve a pre-existing symlink and cannot write its target.
203 [ + + ]: 4 : if (::rename(encodedTemporary.constData(), encodedTarget.constData()) != 0) {
204 [ + - ]: 1 : setNativeError(errorMessage, QStringLiteral("rename"));
205 : 1 : return false;
206 : : }
207 [ + - ]: 3 : file.setAutoRemove(false);
208 : 3 : return true;
209 : : #elif defined(Q_OS_WIN)
210 : : const DWORD flags = MOVEFILE_WRITE_THROUGH |
211 : : (policy == ExistingFilePolicy::Replace
212 : : ? MOVEFILE_REPLACE_EXISTING
213 : : : DWORD(0));
214 : : if (!MoveFileExW(reinterpret_cast<LPCWSTR>(temporaryPath.utf16()),
215 : : reinterpret_cast<LPCWSTR>(path.utf16()), flags)) {
216 : : setNativeError(errorMessage, QStringLiteral("MoveFileExW"));
217 : : return false;
218 : : }
219 : : file.setAutoRemove(false);
220 : : return true;
221 : : #else
222 : : if (policy == ExistingFilePolicy::FailIfExists && QFileInfo::exists(path)) {
223 : : if (errorMessage)
224 : : *errorMessage = QStringLiteral("destination already exists");
225 : : return false;
226 : : }
227 : : if (policy == ExistingFilePolicy::Replace && QFileInfo::exists(path) &&
228 : : !QFile::remove(path)) {
229 : : if (errorMessage)
230 : : *errorMessage = QStringLiteral("failed to replace destination");
231 : : return false;
232 : : }
233 : : if (!QFile::rename(temporaryPath, path)) {
234 : : if (errorMessage)
235 : : *errorMessage = QStringLiteral("failed to install temporary file");
236 : : return false;
237 : : }
238 : : file.setAutoRemove(false);
239 : : return true;
240 : : #endif
241 : 7 : }
242 : :
243 : 8 : bool writeAtomicallyImpl(const QString &path, const QByteArray &data,
244 : : QString *errorMessage, ExistingFilePolicy policy,
245 : : const std::function<void()> &beforeCommit) {
246 [ + - + - ]: 8 : const QString parentPath = QFileInfo(path).absolutePath();
247 [ + - ]: 8 : const QFileInfo parentInfo(parentPath);
248 [ + - + + : 8 : if (!parentInfo.exists() || !parentInfo.isDir()) {
+ - - + +
+ ]
249 [ + - ]: 1 : if (errorMessage)
250 : 1 : *errorMessage = QStringLiteral("destination directory does not exist");
251 : 1 : return false;
252 : : }
253 : :
254 : : QTemporaryFile file(
255 [ + - + - : 14 : QDir(parentPath).filePath(QStringLiteral(".mailjd-attachment-XXXXXX")));
+ - ]
256 [ + - ]: 7 : file.setAutoRemove(true);
257 [ + - - + ]: 7 : if (!file.open()) {
258 [ # # ]: 0 : if (errorMessage)
259 [ # # ]: 0 : *errorMessage = file.errorString();
260 : 0 : return false;
261 : : }
262 [ + - - + ]: 7 : if (!file.setPermissions(QFileDevice::ReadOwner |
263 : : QFileDevice::WriteOwner)) {
264 [ # # ]: 0 : if (errorMessage)
265 [ # # ]: 0 : *errorMessage = file.errorString();
266 : 0 : return false;
267 : : }
268 : :
269 [ + - - + ]: 7 : if (!writeExactly(file, data)) {
270 [ # # ]: 0 : if (errorMessage)
271 [ # # ]: 0 : *errorMessage = file.errorString().isEmpty()
272 [ # # # # : 0 : ? QStringLiteral("short attachment write")
# # # # ]
273 : 0 : : file.errorString();
274 : 0 : return false;
275 : : }
276 [ + - - + ]: 7 : if (!flushToDisk(file, errorMessage))
277 : 0 : return false;
278 : :
279 [ + + ]: 7 : if (beforeCommit)
280 [ + - ]: 1 : beforeCommit();
281 [ + - ]: 7 : return commitTemporaryFile(file, path, policy, errorMessage);
282 : 8 : }
283 : : } // namespace
284 : :
285 : 7 : bool writeAtomically(const QString &path, const QByteArray &data,
286 : : QString *errorMessage, ExistingFilePolicy policy) {
287 [ + - ]: 7 : return writeAtomicallyImpl(path, data, errorMessage, policy, {});
288 : : }
289 : :
290 : : // Compiled into the shared app-test library; declaration is visible only to
291 : : // unit-test translation units through MAILJD_UNIT_TEST.
292 : 1 : bool writeAtomicallyForTest(const QString &path, const QByteArray &data,
293 : : const std::function<void()> &beforeCommit,
294 : : QString *errorMessage,
295 : : ExistingFilePolicy policy) {
296 : 1 : return writeAtomicallyImpl(path, data, errorMessage, policy, beforeCommit);
297 : : }
298 : :
299 : : } // namespace AttachmentFileSecurity
|