1 #include "util/sandbox.h"
2 
3 #include <QFileDialog>
4 #include <QFileInfo>
5 #include <QMutexLocker>
6 #include <QObject>
7 #include <QtDebug>
8 
9 #include "util/mac.h"
10 
11 #ifdef __APPLE__
12 #include <CoreFoundation/CoreFoundation.h>
13 #include <CoreServices/CoreServices.h>
14 #include <Security/SecCode.h>
15 #include <Security/SecRequirement.h>
16 #endif
17 
18 const bool sDebug = false;
19 
20 #if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0)
21 QRecursiveMutex Sandbox::s_mutex;
22 #else
23 QMutex Sandbox::s_mutex(QMutex::Recursive);
24 #endif
25 bool Sandbox::s_bInSandbox = false;
26 QSharedPointer<ConfigObject<ConfigValue>> Sandbox::s_pSandboxPermissions;
27 QHash<QString, SecurityTokenWeakPointer> Sandbox::s_activeTokens;
28 
29 // static
checkSandboxed()30 void Sandbox::checkSandboxed() {
31 #ifdef __APPLE__
32     SecCodeRef secCodeSelf;
33     if (SecCodeCopySelf(kSecCSDefaultFlags, &secCodeSelf) == errSecSuccess) {
34         SecRequirementRef sandboxReq;
35         CFStringRef entitlement = CFSTR("entitlement [\"com.apple.security.app-sandbox\"]");
36         if (SecRequirementCreateWithString(entitlement, kSecCSDefaultFlags,
37                                            &sandboxReq) == errSecSuccess) {
38             if (SecCodeCheckValidity(secCodeSelf, kSecCSDefaultFlags,
39                                      sandboxReq) == errSecSuccess) {
40                 s_bInSandbox = true;
41             }
42             CFRelease(sandboxReq);
43         }
44         CFRelease(secCodeSelf);
45     }
46 #endif
47 }
48 
setPermissionsFilePath(const QString & permissionsFile)49 void Sandbox::setPermissionsFilePath(const QString& permissionsFile) {
50     QMutexLocker locker(&s_mutex);
51     s_pSandboxPermissions = QSharedPointer<ConfigObject<ConfigValue>>(
52             new ConfigObject<ConfigValue>(permissionsFile));
53 }
54 
55 // static
shutdown()56 void Sandbox::shutdown() {
57     QMutexLocker locker(&s_mutex);
58     QSharedPointer<ConfigObject<ConfigValue>> pSandboxPermissions = s_pSandboxPermissions;
59     s_pSandboxPermissions.clear();
60     if (pSandboxPermissions) {
61         pSandboxPermissions->save();
62     }
63 }
64 
65 // static
askForAccess(const QString & path)66 bool Sandbox::askForAccess(const QString& path) {
67     if (sDebug) {
68         qDebug() << "Sandbox::askForAccess" << path;
69     }
70     if (!enabled()) {
71         // Pretend we have access.
72         return true;
73     }
74 
75     QFileInfo info(path);
76     if (!info.exists()) {
77         // We cannot grant access to a not existing file
78         return false;
79     }
80 
81     // We always want read/write access because we wouldn't want to have to
82     // re-ask for access in the future if we need to write.
83     if (canAccessFile(info)) {
84         return true;
85     }
86 
87     QString title = QObject::tr("Mixxx Needs Access to: %1")
88             .arg(info.fileName());
89 
90     QMessageBox::information(nullptr,
91             title,
92             QObject::tr(
93                     "Due to Mac Sandboxing, we need your permission to access "
94                     "this file:"
95                     "\n\n%1\n\n"
96                     "After clicking OK, you will see a file picker. "
97                     "To give Mixxx permission, you must select '%2' to "
98                     "proceed. "
99                     "If you do not want to grant Mixxx access click Cancel on "
100                     "the file picker. "
101                     "We're sorry for this inconvenience.\n\n"
102                     "To abort this action, press Cancel on the file dialog.")
103                     .arg(path, info.fileName()));
104 
105     QString result;
106     QFileInfo resultInfo;
107     while (true) {
108         if (info.isFile()) {
109             result = QFileDialog::getOpenFileName(nullptr, title, path);
110         } else if (info.isDir()) {
111             result = QFileDialog::getExistingDirectory(nullptr, title, path);
112         }
113 
114         if (result.isNull()) {
115             if (sDebug) {
116                 qDebug() << "Sandbox: User rejected access to" << path;
117             }
118             return false;
119         }
120 
121         if (sDebug) {
122             qDebug() << "Sandbox: User selected" << result;
123         }
124         resultInfo = QFileInfo(result);
125         if (resultInfo == info) {
126             break;
127         }
128 
129         if (sDebug) {
130             qDebug() << "User selected the wrong file.";
131         }
132         QMessageBox::information(
133                 nullptr, title, QObject::tr("You selected the wrong file. To grant Mixxx access, "
134                                             "please select the file '%1'. If you do not want to "
135                                             "continue, press Cancel.")
136                                         .arg(info.fileName()));
137     }
138 
139     return createSecurityToken(resultInfo);
140 }
141 
142 // static
keyForCanonicalPath(const QString & canonicalPath)143 ConfigKey Sandbox::keyForCanonicalPath(const QString& canonicalPath) {
144     return ConfigKey("[OSXBookmark]",
145                      QString(canonicalPath.toLocal8Bit().toBase64()));
146 }
147 
148 // static
createSecurityToken(const QString & canonicalPath,bool isDirectory)149 bool Sandbox::createSecurityToken(const QString& canonicalPath,
150                                   bool isDirectory) {
151     if (sDebug) {
152         qDebug() << "createSecurityToken" << canonicalPath << isDirectory;
153     }
154     if (!enabled()) {
155         return false;
156     }
157     QMutexLocker locker(&s_mutex);
158     if (s_pSandboxPermissions == nullptr) {
159         return false;
160     }
161 
162 #ifdef __APPLE__
163     CFURLRef url = CFURLCreateWithFileSystemPath(
164             kCFAllocatorDefault, QStringToCFString(canonicalPath),
165             kCFURLPOSIXPathStyle, isDirectory);
166     if (url) {
167         CFErrorRef error = NULL;
168         CFDataRef bookmark = CFURLCreateBookmarkData(
169                 kCFAllocatorDefault, url,
170                 kCFURLBookmarkCreationWithSecurityScope, nil, nil, &error);
171         CFRelease(url);
172         if (bookmark) {
173             QByteArray bookmarkBA = QByteArray(
174                     reinterpret_cast<const char*>(CFDataGetBytePtr(bookmark)),
175                     CFDataGetLength(bookmark));
176 
177             QString bookmarkBase64 = QString(bookmarkBA.toBase64());
178 
179             s_pSandboxPermissions->set(keyForCanonicalPath(canonicalPath),
180                                        bookmarkBase64);
181             CFRelease(bookmark);
182             return true;
183         } else {
184             if (sDebug) {
185                 qDebug() << "Failed to create security-scoped bookmark for" << canonicalPath;
186                 if (error != NULL) {
187                     qDebug() << "Error:" << CFStringToQString(CFErrorCopyDescription(error));
188                 }
189             }
190         }
191     } else {
192         if (sDebug) {
193             qDebug() << "Failed to create security-scoped bookmark URL for" << canonicalPath;
194         }
195     }
196 #endif
197     return false;
198 }
199 
200 // static
openSecurityToken(const QFileInfo & file,bool create)201 SecurityTokenPointer Sandbox::openSecurityToken(const QFileInfo& file, bool create) {
202     const QString& canonicalFilePath = file.canonicalFilePath();
203     if (sDebug) {
204         qDebug() << "openSecurityToken QFileInfo" << canonicalFilePath << create;
205     }
206 
207     if (!enabled()) {
208         return SecurityTokenPointer();
209     }
210 
211     QMutexLocker locker(&s_mutex);
212     if (s_pSandboxPermissions == nullptr) {
213         return SecurityTokenPointer();
214     }
215 
216     QHash<QString, SecurityTokenWeakPointer>::iterator it = s_activeTokens
217             .find(canonicalFilePath);
218     if (it != s_activeTokens.end()) {
219         SecurityTokenPointer pToken(it.value());
220         if (pToken) {
221             if (sDebug) {
222                 qDebug() << "openSecurityToken QFileInfo" << canonicalFilePath
223                          << "using cached token for" << pToken->m_path;
224             }
225             return pToken;
226         }
227     }
228 
229     if (file.isDir()) {
230         return openSecurityToken(QDir(canonicalFilePath), create);
231     }
232 
233     // First, check for a bookmark of the key itself.
234     ConfigKey key = keyForCanonicalPath(canonicalFilePath);
235     if (s_pSandboxPermissions->exists(key)) {
236         return openTokenFromBookmark(
237                 canonicalFilePath,
238                 s_pSandboxPermissions->getValueString(key));
239     }
240 
241     // Next, try to open a bookmark for an existing directory but don't create a
242     // bookmark.
243     SecurityTokenPointer pDirToken = openSecurityToken(file.dir(), false);
244     if (!pDirToken.isNull()) {
245         return pDirToken;
246     }
247 
248     if (!create) {
249         return SecurityTokenPointer();
250     }
251 
252     // Otherwise, try to create a token.
253     bool created = createSecurityToken(file);
254 
255     if (created) {
256         return openTokenFromBookmark(
257                 canonicalFilePath,
258                 s_pSandboxPermissions->getValueString(key));
259     }
260     return SecurityTokenPointer();
261 }
262 
263 // static
openSecurityToken(const QDir & dir,bool create)264 SecurityTokenPointer Sandbox::openSecurityToken(const QDir& dir, bool create) {
265     QDir walkDir = dir;
266     QString walkDirCanonicalPath = walkDir.canonicalPath();
267     if (sDebug) {
268         qDebug() << "openSecurityToken QDir" << walkDirCanonicalPath << create;
269     }
270 
271     if (!enabled()) {
272         return SecurityTokenPointer();
273     }
274 
275     QMutexLocker locker(&s_mutex);
276     if (s_pSandboxPermissions.isNull()) {
277         return SecurityTokenPointer();
278     }
279 
280     while (true) {
281         // Look for a valid token in the cache.
282         QHash<QString, SecurityTokenWeakPointer>::iterator it = s_activeTokens
283                 .find(walkDirCanonicalPath);
284         if (it != s_activeTokens.end()) {
285             SecurityTokenPointer pToken(it.value());
286             if (pToken) {
287                 if (sDebug) {
288                     qDebug() << "openSecurityToken QDir" << walkDirCanonicalPath
289                              << "using cached token for" << pToken->m_path;
290                 }
291                 return pToken;
292             }
293         }
294 
295         // Next, check if the key exists in the config.
296         ConfigKey key = keyForCanonicalPath(walkDirCanonicalPath);
297         if (s_pSandboxPermissions->exists(key)) {
298             SecurityTokenPointer pToken = openTokenFromBookmark(
299                     dir.canonicalPath(),
300                     s_pSandboxPermissions->getValueString(key));
301             if (pToken) {
302                 return pToken;
303             }
304         }
305 
306         // Go one step higher and repeat.
307         if (!walkDir.cdUp()) {
308             // There's nothing higher. Bail.
309             break;
310         }
311         walkDirCanonicalPath = walkDir.canonicalPath();
312     }
313 
314     // Last chance: Try to create a token for this directory.
315     if (create && createSecurityToken(dir.canonicalPath(), true)) {
316         ConfigKey key = keyForCanonicalPath(dir.canonicalPath());
317         return openTokenFromBookmark(
318                 dir.canonicalPath(),
319                 s_pSandboxPermissions->getValueString(key));
320     }
321     return SecurityTokenPointer();
322 }
323 
openTokenFromBookmark(const QString & canonicalPath,const QString & bookmarkBase64)324 SecurityTokenPointer Sandbox::openTokenFromBookmark(const QString& canonicalPath,
325                                                     const QString& bookmarkBase64) {
326 #ifdef __APPLE__
327     QByteArray bookmarkBA = QByteArray::fromBase64(bookmarkBase64.toLatin1());
328     if (!bookmarkBA.isEmpty()) {
329         CFDataRef bookmarkData = CFDataCreate(
330                 kCFAllocatorDefault, reinterpret_cast<const UInt8*>(bookmarkBA.constData()),
331                 bookmarkBA.length());
332         Boolean stale;
333         CFErrorRef error = NULL;
334         CFURLRef url = CFURLCreateByResolvingBookmarkData(
335                 kCFAllocatorDefault, bookmarkData,
336                 kCFURLBookmarkResolutionWithSecurityScope, NULL, NULL,
337                 &stale, &error);
338         if (error != NULL) {
339             if (sDebug) {
340                 qDebug() << "Error creating URL from bookmark data:"
341                          << CFStringToQString(CFErrorCopyDescription(error));
342             }
343         }
344         CFRelease(bookmarkData);
345         if (url != NULL) {
346             if (!CFURLStartAccessingSecurityScopedResource(url)) {
347                 if (sDebug) {
348                     qDebug() << "CFURLStartAccessingSecurityScopedResource failed for"
349                              << canonicalPath;
350                 }
351             } else {
352                 SecurityTokenPointer pToken = SecurityTokenPointer(
353                     new SandboxSecurityToken(canonicalPath, url));
354                 s_activeTokens[canonicalPath] = pToken;
355                 return pToken;
356             }
357         } else {
358             if (sDebug) {
359                 qDebug() << "Cannot resolve security-scoped bookmark for" << canonicalPath;
360             }
361         }
362     }
363 #else
364     Q_UNUSED(canonicalPath);
365     Q_UNUSED(bookmarkBase64);
366 #endif
367 
368     return SecurityTokenPointer();
369 }
370 
371 #ifdef __APPLE__
migrateOldSettings()372 QString Sandbox::migrateOldSettings() {
373     // QStandardPaths::DataLocation returns a different location depending on whether the build
374     // is signed (and therefore sandboxed with the hardened runtime), so use the absolute path
375     // that the sandbox uses regardless of whether this build is actually sandboxed.
376     // Otherwise, developers would need to run with --settingsPath every time or symlink
377     // to use the same settings directory with signed and unsigned builds.
378 
379     // QDir::homePath returns a path inside the sandbox when running sandboxed
380     QString homePath = QLatin1String("/Users/") + qgetenv("USER");
381     if (qEnvironmentVariableIsEmpty("USER") || qgetenv("USER").contains("/")) {
382         qCritical() << "Cannot find home directory (USER environment variable invalid)";
383         return QString();
384     }
385 
386     QDir homeDir(homePath);
387     if (!homeDir.exists()) {
388         qCritical() << "Home directory does not exist" << homePath;
389         return QString();
390     }
391 
392     // The parent of the sandboxed path needs to be created before the legacySettingsPath
393     // can be moved there. This is not necessary when running in a sandbox because macOS
394     // automatically creates it.
395     QString sandboxedParentPath = homePath +
396             QLatin1String(
397                     "/Library/Containers/org.mixxx.mixxx/Data/Library/"
398                     "Application Support");
399     QString sandboxedPath = sandboxedParentPath + QLatin1String("/Mixxx");
400     QDir sandboxedDir(sandboxedPath);
401 
402     QString legacySettingsPath = homePath + QLatin1String("/Library/Application Support/Mixxx");
403     // The user has no settings from Mixxx < 2.3.0, so there is no migration to do.
404     if (!QDir(legacySettingsPath).exists()) {
405         return sandboxedPath;
406     }
407 
408     // The user already has settings in the sandboxed path, so there is no migration to do.
409     if (sandboxedDir.exists() && !sandboxedDir.isEmpty()) {
410         return sandboxedPath;
411     }
412 
413     // Sandbox::askForAccess cannot be used here because it depends on settings being
414     // initialized. There is no need to store the bookmark anyway because this is a
415     // one time process.
416     QString title = QObject::tr("Upgrading old Mixxx settings");
417     QMessageBox::information(nullptr,
418             title,
419             QObject::tr(
420                     "Due to macOS sandboxing, Mixxx needs your permission "
421                     "to access your music library and settings from Mixxx "
422                     "versions before 2.3.0. After clicking OK, you will see a "
423                     "file selection dialog. "
424                     "\n\n"
425                     "To allow Mixxx to use your old library and settings, "
426                     "click the Open button in the file selection dialog. "
427                     "Mixxx will then move your old settings into the sandbox. "
428                     "This only needs to be done once."
429                     "\n\n"
430                     "If you do not want to grant Mixxx access, click Cancel "
431                     "on the file picker. Mixxx will create a new music library "
432                     "and use default settings."));
433 
434     QString result = QFileDialog::getExistingDirectory(
435             nullptr,
436             title,
437             legacySettingsPath);
438     if (result != legacySettingsPath) {
439         qInfo() << "Sandbox::migrateOldSettings: User declined to migrate old settings from"
440                 << legacySettingsPath << "User selected" << result;
441         return sandboxedPath;
442     }
443 
444     CFURLRef url = CFURLCreateWithFileSystemPath(
445             kCFAllocatorDefault, QStringToCFString(legacySettingsPath), kCFURLPOSIXPathStyle, true);
446     if (url) {
447         CFErrorRef error = NULL;
448         if (s_bInSandbox) {
449             // Request permissions to the old unsandboxed sandboxed settings path
450             // and move the directory into the sandbox
451             CFDataRef bookmark = CFURLCreateBookmarkData(
452                     kCFAllocatorDefault,
453                     url,
454                     kCFURLBookmarkCreationWithSecurityScope,
455                     nil,
456                     nil,
457                     &error);
458             CFRelease(url);
459             if (bookmark) {
460                 QFile oldSettings(legacySettingsPath);
461                 if (oldSettings.rename(sandboxedPath)) {
462                     qInfo() << "Sandbox::migrateOldSettings: Successfully "
463                                "migrated old settings from"
464                             << legacySettingsPath << "to new path" << sandboxedPath;
465                 } else {
466                     qWarning() << "Sandbox::migrateOldSettings: Failed to migrate "
467                                   "old settings from"
468                                << legacySettingsPath
469                                << "to new path" << sandboxedPath;
470                 }
471                 CFRelease(bookmark);
472             } else {
473                 qWarning() << "Sandbox::migrateOldSettings: Failed to access old "
474                               "settings path"
475                            << legacySettingsPath
476                            << "Cannot migrate to new path" << sandboxedPath;
477             }
478         } else {
479             // Move old unsandboxed settings directory into the sandbox
480 
481             // Ensure the parent directory of the destination path exists, otherwise
482             // moving to the new path will fail.
483             QDir sandboxedParentDir(sandboxedParentPath);
484             if (!sandboxedParentDir.exists()) {
485                 if (!sandboxedParentDir.mkpath(sandboxedParentPath)) {
486                     qWarning() << "Could not create sandboxed application data directory"
487                                << sandboxedParentPath;
488                 }
489             }
490 
491             QFile oldSettings(legacySettingsPath);
492             if (oldSettings.rename(sandboxedPath)) {
493                 qInfo() << "Sandbox::migrateOldSettings: Successfully "
494                            "migrated old settings from"
495                         << legacySettingsPath << "to new path"
496                         << sandboxedPath;
497             } else {
498                 qWarning() << "Sandbox::migrateOldSettings: Failed to migrate old settings from"
499                            << legacySettingsPath << "to new path" << sandboxedPath;
500             }
501         }
502     }
503     return sandboxedPath;
504 }
505 #endif
506 
507 #ifdef __APPLE__
SandboxSecurityToken(const QString & path,CFURLRef url)508 SandboxSecurityToken::SandboxSecurityToken(const QString& path, CFURLRef url)
509         : m_path(path),
510           m_url(url) {
511     if (m_url) {
512         if (sDebug) {
513             qDebug() << "SandboxSecurityToken successfully opened for" << path;
514         }
515     }
516 }
517 #endif
518 
~SandboxSecurityToken()519 SandboxSecurityToken::~SandboxSecurityToken() {
520 #ifdef __APPLE__
521     if (sDebug) {
522         qDebug() << "~SandboxSecurityToken" << m_path;
523     }
524     if (m_url) {
525         CFURLStopAccessingSecurityScopedResource(m_url);
526         CFRelease(m_url);
527         m_url = 0;
528     }
529 #endif
530 }
531