#include "util/sandbox.h" #include #include #include #include #include #include "util/mac.h" #ifdef __APPLE__ #include #include #include #include #endif const bool sDebug = false; #if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) QRecursiveMutex Sandbox::s_mutex; #else QMutex Sandbox::s_mutex(QMutex::Recursive); #endif bool Sandbox::s_bInSandbox = false; QSharedPointer> Sandbox::s_pSandboxPermissions; QHash Sandbox::s_activeTokens; // static void Sandbox::checkSandboxed() { #ifdef __APPLE__ SecCodeRef secCodeSelf; if (SecCodeCopySelf(kSecCSDefaultFlags, &secCodeSelf) == errSecSuccess) { SecRequirementRef sandboxReq; CFStringRef entitlement = CFSTR("entitlement [\"com.apple.security.app-sandbox\"]"); if (SecRequirementCreateWithString(entitlement, kSecCSDefaultFlags, &sandboxReq) == errSecSuccess) { if (SecCodeCheckValidity(secCodeSelf, kSecCSDefaultFlags, sandboxReq) == errSecSuccess) { s_bInSandbox = true; } CFRelease(sandboxReq); } CFRelease(secCodeSelf); } #endif } void Sandbox::setPermissionsFilePath(const QString& permissionsFile) { QMutexLocker locker(&s_mutex); s_pSandboxPermissions = QSharedPointer>( new ConfigObject(permissionsFile)); } // static void Sandbox::shutdown() { QMutexLocker locker(&s_mutex); QSharedPointer> pSandboxPermissions = s_pSandboxPermissions; s_pSandboxPermissions.clear(); if (pSandboxPermissions) { pSandboxPermissions->save(); } } //static bool Sandbox::createSecurityToken(mixxx::FileInfo* pFileInfo) { VERIFY_OR_DEBUG_ASSERT(pFileInfo) { return false; } const auto canonicalLocation = pFileInfo->resolveCanonicalLocation(); return createSecurityToken(canonicalLocation, pFileInfo->isDir()); } //static bool Sandbox::canAccess(mixxx::FileInfo* pFileInfo) { VERIFY_OR_DEBUG_ASSERT(pFileInfo) { return false; } openSecurityToken(pFileInfo, true); return pFileInfo->isReadable(); } //static bool Sandbox::canAccessDir(const QDir& dir) { openSecurityTokenForDir(dir, true); return QFileInfo(dir.canonicalPath()).isReadable(); } // static bool Sandbox::askForAccess(mixxx::FileInfo* pFileInfo) { // We always want read/write access because we wouldn't want to have to // re-ask for access in the future if we need to write. if (sDebug) { qDebug() << "Sandbox: Requesting user access to" << pFileInfo; } if (!enabled()) { // Pretend we have access. return true; } if (!pFileInfo->exists()) { // We cannot grant access to a not existing file return false; } if (canAccess(pFileInfo)) { return true; } const QString location = pFileInfo->location(); const QString fileName = pFileInfo->fileName(); const QString title = QObject::tr("Mixxx Needs Access to: %1").arg(fileName); QMessageBox::information(nullptr, title, QObject::tr( "Your permission is required to access " "the following location:" "\n\n%1\n\n" "After clicking OK, you will see a file picker. " "Please select '%2' to proceed or click Cancel if " "you don't want to grant Mixxx access and abort " "this action.") .arg(location, fileName)); mixxx::FileInfo resultInfo; while (true) { QString result; if (pFileInfo->isFile()) { result = QFileDialog::getOpenFileName(nullptr, title, location); } else if (pFileInfo->isDir()) { result = QFileDialog::getExistingDirectory(nullptr, title, location); } if (result.isNull()) { if (sDebug) { qDebug() << "Sandbox: User rejected access to" << location; } return false; } if (sDebug) { qDebug() << "Sandbox: User selected" << result; } resultInfo = mixxx::FileInfo(result); if (resultInfo == *pFileInfo) { break; } if (sDebug) { qDebug() << "User selected the wrong file."; } QMessageBox::information( nullptr, title, QObject::tr("You selected the wrong file. To grant Mixxx access, " "please select the file '%1'. If you do not want to " "continue, press Cancel.") .arg(fileName)); } return createSecurityToken(&resultInfo); } // static ConfigKey Sandbox::keyForCanonicalPath(const QString& canonicalPath) { return ConfigKey("[OSXBookmark]", QString(canonicalPath.toLocal8Bit().toBase64())); } // static bool Sandbox::createSecurityToken(const QString& canonicalPath, bool isDirectory) { if (sDebug) { qDebug() << "createSecurityToken" << canonicalPath << isDirectory; } if (!enabled()) { return false; } QMutexLocker locker(&s_mutex); if (s_pSandboxPermissions == nullptr) { return false; } #ifdef __APPLE__ CFURLRef url = CFURLCreateWithFileSystemPath( kCFAllocatorDefault, QStringToCFString(canonicalPath), kCFURLPOSIXPathStyle, isDirectory); if (url) { CFErrorRef error = NULL; CFDataRef bookmark = CFURLCreateBookmarkData( kCFAllocatorDefault, url, kCFURLBookmarkCreationWithSecurityScope, nil, nil, &error); CFRelease(url); if (bookmark) { QByteArray bookmarkBA = QByteArray( reinterpret_cast(CFDataGetBytePtr(bookmark)), CFDataGetLength(bookmark)); QString bookmarkBase64 = QString(bookmarkBA.toBase64()); s_pSandboxPermissions->set(keyForCanonicalPath(canonicalPath), bookmarkBase64); CFRelease(bookmark); return true; } else { if (sDebug) { qDebug() << "Failed to create security-scoped bookmark for" << canonicalPath; if (error != NULL) { qDebug() << "Error:" << CFStringToQString(CFErrorCopyDescription(error)); } } } } else { if (sDebug) { qDebug() << "Failed to create security-scoped bookmark URL for" << canonicalPath; } } #endif return false; } // static SecurityTokenPointer Sandbox::openSecurityToken(mixxx::FileInfo* pFileInfo, bool create) { VERIFY_OR_DEBUG_ASSERT(pFileInfo) { return nullptr; } const auto canonicalLocation = pFileInfo->resolveCanonicalLocation(); if (canonicalLocation.isEmpty()) { return nullptr; } if (pFileInfo->isDir()) { return openSecurityTokenForDir(QDir(canonicalLocation), create); } if (sDebug) { qDebug() << "openSecurityToken for file" << canonicalLocation << create; } if (!enabled()) { return nullptr; } QMutexLocker locker(&s_mutex); if (!s_pSandboxPermissions) { return nullptr; } QHash::iterator it = s_activeTokens .find(canonicalLocation); if (it != s_activeTokens.end()) { SecurityTokenPointer pToken(it.value()); if (pToken) { if (sDebug) { qDebug() << "openSecurityToken mixxx::FileInfo" << canonicalLocation << "using cached token for" << pToken->m_path; } return pToken; } } // First, check for a bookmark of the key itself. ConfigKey key = keyForCanonicalPath(canonicalLocation); if (s_pSandboxPermissions->exists(key)) { return openTokenFromBookmark( canonicalLocation, s_pSandboxPermissions->getValueString(key)); } // Next, try to open a bookmark for an existing directory but don't create a // bookmark. const auto parentDir = QDir(pFileInfo->canonicalLocationPath()); SecurityTokenPointer pDirToken = openSecurityTokenForDir(parentDir, false); if (!pDirToken.isNull()) { return pDirToken; } if (!create) { return nullptr; } // Otherwise, try to create a token. bool created = createSecurityToken(pFileInfo); if (!created) { return nullptr; } return openTokenFromBookmark( canonicalLocation, s_pSandboxPermissions->getValueString(key)); } // static SecurityTokenPointer Sandbox::openSecurityTokenForDir(const QDir& dir, bool create) { QDir walkDir = dir; QString walkDirCanonicalPath = walkDir.canonicalPath(); if (walkDirCanonicalPath.isEmpty()) { // Canonical path does not exist return SecurityTokenPointer(); } if (sDebug) { qDebug() << "openSecurityToken for dir" << walkDirCanonicalPath << create; } if (!enabled()) { return nullptr; } QMutexLocker locker(&s_mutex); if (!s_pSandboxPermissions) { return nullptr; } while (!walkDirCanonicalPath.isEmpty()) { // Look for a valid token in the cache. QHash::iterator it = s_activeTokens .find(walkDirCanonicalPath); if (it != s_activeTokens.end()) { SecurityTokenPointer pToken(it.value()); if (pToken) { if (sDebug) { qDebug() << "openSecurityToken QDir" << walkDirCanonicalPath << "using cached token for" << pToken->m_path; } return pToken; } } // Next, check if the key exists in the config. ConfigKey key = keyForCanonicalPath(walkDirCanonicalPath); if (s_pSandboxPermissions->exists(key)) { SecurityTokenPointer pToken = openTokenFromBookmark( dir.canonicalPath(), s_pSandboxPermissions->getValueString(key)); if (pToken) { return pToken; } } // Go one step higher and repeat. if (!walkDir.cdUp()) { // There's nothing higher. Bail. break; } walkDirCanonicalPath = walkDir.canonicalPath(); } // Last chance: Try to create a token for this directory. if (create && createSecurityToken(dir.canonicalPath(), true)) { ConfigKey key = keyForCanonicalPath(dir.canonicalPath()); return openTokenFromBookmark( dir.canonicalPath(), s_pSandboxPermissions->getValueString(key)); } return nullptr; } SecurityTokenPointer Sandbox::openTokenFromBookmark(const QString& canonicalPath, const QString& bookmarkBase64) { #ifdef __APPLE__ QByteArray bookmarkBA = QByteArray::fromBase64(bookmarkBase64.toLatin1()); if (!bookmarkBA.isEmpty()) { CFDataRef bookmarkData = CFDataCreate( kCFAllocatorDefault, reinterpret_cast(bookmarkBA.constData()), bookmarkBA.length()); Boolean stale; CFErrorRef error = NULL; CFURLRef url = CFURLCreateByResolvingBookmarkData( kCFAllocatorDefault, bookmarkData, kCFURLBookmarkResolutionWithSecurityScope, NULL, NULL, &stale, &error); if (error != NULL) { if (sDebug) { qDebug() << "Error creating URL from bookmark data:" << CFStringToQString(CFErrorCopyDescription(error)); } } CFRelease(bookmarkData); if (url != NULL) { if (!CFURLStartAccessingSecurityScopedResource(url)) { if (sDebug) { qDebug() << "CFURLStartAccessingSecurityScopedResource failed for" << canonicalPath; } } else { SecurityTokenPointer pToken = SecurityTokenPointer( new SandboxSecurityToken(canonicalPath, url)); s_activeTokens[canonicalPath] = pToken; return pToken; } } else { if (sDebug) { qDebug() << "Cannot resolve security-scoped bookmark for" << canonicalPath; } } } #else Q_UNUSED(canonicalPath); Q_UNUSED(bookmarkBase64); #endif return nullptr; } #ifdef __APPLE__ QString Sandbox::migrateOldSettings() { // QStandardPaths::DataLocation returns a different location depending on whether the build // is signed (and therefore sandboxed with the hardened runtime), so use the absolute path // that the sandbox uses regardless of whether this build is actually sandboxed. // Otherwise, developers would need to run with --settingsPath every time or symlink // to use the same settings directory with signed and unsigned builds. // QDir::homePath returns a path inside the sandbox when running sandboxed QString homePath = QLatin1String("/Users/") + qgetenv("USER"); if (qEnvironmentVariableIsEmpty("USER") || qgetenv("USER").contains("/")) { qCritical() << "Cannot find home directory (USER environment variable invalid)"; return QString(); } QDir homeDir(homePath); if (!homeDir.exists()) { qCritical() << "Home directory does not exist" << homePath; return QString(); } // The parent of the sandboxed path needs to be created before the legacySettingsPath // can be moved there. This is not necessary when running in a sandbox because macOS // automatically creates it. QString sandboxedParentPath = homePath + QLatin1String( "/Library/Containers/org.mixxx.mixxx/Data/Library/" "Application Support"); QString sandboxedPath = sandboxedParentPath + QLatin1String("/Mixxx"); QDir sandboxedDir(sandboxedPath); QString legacySettingsPath = homePath + QLatin1String("/Library/Application Support/Mixxx"); // The user has no settings from Mixxx < 2.3.0, so there is no migration to do. if (!QDir(legacySettingsPath).exists()) { return sandboxedPath; } // The user already has settings in the sandboxed path, so there is no migration to do. if (sandboxedDir.exists() && !sandboxedDir.isEmpty()) { return sandboxedPath; } // Sandbox::askForAccess cannot be used here because it depends on settings being // initialized. There is no need to store the bookmark anyway because this is a // one time process. QString title = QObject::tr("Upgrading old Mixxx settings"); QMessageBox::information(nullptr, title, QObject::tr( "Due to macOS sandboxing, Mixxx needs your permission " "to access your music library and settings from Mixxx " "versions before 2.3.0. After clicking OK, you will see a " "file selection dialog. " "\n\n" "To allow Mixxx to use your old library and settings, " "click the Open button in the file selection dialog. " "Mixxx will then move your old settings into the sandbox. " "This only needs to be done once." "\n\n" "If you do not want to grant Mixxx access, click Cancel " "on the file picker. Mixxx will create a new music library " "and use default settings.")); QString result = QFileDialog::getExistingDirectory( nullptr, title, legacySettingsPath); if (result != legacySettingsPath) { qInfo() << "Sandbox::migrateOldSettings: User declined to migrate old settings from" << legacySettingsPath << "User selected" << result; return sandboxedPath; } CFURLRef url = CFURLCreateWithFileSystemPath( kCFAllocatorDefault, QStringToCFString(legacySettingsPath), kCFURLPOSIXPathStyle, true); if (url) { CFErrorRef error = NULL; if (s_bInSandbox) { // Request permissions to the old unsandboxed sandboxed settings path // and move the directory into the sandbox CFDataRef bookmark = CFURLCreateBookmarkData( kCFAllocatorDefault, url, kCFURLBookmarkCreationWithSecurityScope, nil, nil, &error); CFRelease(url); if (bookmark) { QFile oldSettings(legacySettingsPath); if (oldSettings.rename(sandboxedPath)) { qInfo() << "Sandbox::migrateOldSettings: Successfully " "migrated old settings from" << legacySettingsPath << "to new path" << sandboxedPath; } else { qWarning() << "Sandbox::migrateOldSettings: Failed to migrate " "old settings from" << legacySettingsPath << "to new path" << sandboxedPath; } CFRelease(bookmark); } else { qWarning() << "Sandbox::migrateOldSettings: Failed to access old " "settings path" << legacySettingsPath << "Cannot migrate to new path" << sandboxedPath; } } else { // Move old unsandboxed settings directory into the sandbox // Ensure the parent directory of the destination path exists, otherwise // moving to the new path will fail. QDir sandboxedParentDir(sandboxedParentPath); if (!sandboxedParentDir.exists()) { if (!sandboxedParentDir.mkpath(sandboxedParentPath)) { qWarning() << "Could not create sandboxed application data directory" << sandboxedParentPath; } } QFile oldSettings(legacySettingsPath); if (oldSettings.rename(sandboxedPath)) { qInfo() << "Sandbox::migrateOldSettings: Successfully " "migrated old settings from" << legacySettingsPath << "to new path" << sandboxedPath; } else { qWarning() << "Sandbox::migrateOldSettings: Failed to migrate old settings from" << legacySettingsPath << "to new path" << sandboxedPath; } } } return sandboxedPath; } #endif #ifdef __APPLE__ SandboxSecurityToken::SandboxSecurityToken(const QString& path, CFURLRef url) : m_path(path), m_url(url) { if (m_url) { if (sDebug) { qDebug() << "SandboxSecurityToken successfully opened for" << path; } } } #endif SandboxSecurityToken::~SandboxSecurityToken() { #ifdef __APPLE__ if (sDebug) { qDebug() << "~SandboxSecurityToken" << m_path; } if (m_url) { CFURLStopAccessingSecurityScopedResource(m_url); CFRelease(m_url); m_url = 0; } #endif }