Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ add_subdirectory(marathon-ui)
add_subdirectory(shell)
add_subdirectory(tools/marathon-dev)
add_subdirectory(tools/marathon-app-runner)
add_subdirectory(tools/marathon-sandbox)

message(STATUS "=== Marathon OS ===")
message(STATUS "Qt version: ${Qt6_VERSION}")
Expand Down
2 changes: 1 addition & 1 deletion apps/terminal/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"entryPoint": "TerminalApp.qml",
"icon": "assets/icon.svg",
"author": "Marathon OS",
"permissions": ["system", "storage"],
"permissions": ["system", "storage", "terminal"],
"minShellVersion": "1.0.0",
"protected": false,
"categories": ["System"],
Expand Down
49 changes: 40 additions & 9 deletions marathon-core/src/marathonappinstaller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QDebug>
#include <syslog.h>

MarathonAppInstaller::MarathonAppInstaller(MarathonAppRegistry *registry,
MarathonAppScanner *scanner, QObject *parent)
Expand Down Expand Up @@ -141,21 +142,52 @@ bool MarathonAppInstaller::installFromDirectory(const QString &sourcePath) {
QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();

QString appId = doc.object().value("id").toString();
QString appName = doc.object().value("name").toString();
QString appId = doc.object().value("id").toString();
QString appName = doc.object().value("name").toString();
QString entryPoint = doc.object().value("entryPoint").toString();

emit installStarted(appId);
if (appId.isEmpty() || appId.contains("..") || appId.contains('/') || appId.contains('\\') ||
appId.startsWith('.')) {
qWarning() << "[MarathonAppInstaller] SECURITY: Invalid appId detected:" << appId;
syslog(LOG_AUTH | LOG_ALERT, "[SECURITY] Path traversal blocked: appId=%s",
appId.toUtf8().constData());
emit installFailed("unknown", "Invalid app ID");
return false;
}

if (!entryPoint.isEmpty()) {
if (entryPoint.contains("..") || entryPoint.startsWith('/') ||
entryPoint.startsWith('\\')) {
qWarning() << "[MarathonAppInstaller] SECURITY: Invalid entryPoint detected:"
<< entryPoint;
syslog(LOG_AUTH | LOG_ALERT, "[SECURITY] Path traversal blocked: app=%s entryPoint=%s",
appId.toUtf8().constData(), entryPoint.toUtf8().constData());
emit installFailed(appId, "Invalid entry point");
return false;
}
}

emit installStarted(appId);

// Check if already installed
if (m_registry->hasApp(appId)) {
qDebug() << "[MarathonAppInstaller] App already installed, updating:" << appId;
}

// Determine destination
QString installBase = getTargetInstallPath();
QString destPath = installBase + "/" + appId;
QString installBase = getTargetInstallPath();
QString destPath = installBase + "/" + appId;

QFileInfo destInfo(destPath);
QString canonicalDest = destInfo.absoluteFilePath();
QFileInfo baseInfo(installBase);
QString canonicalBase = baseInfo.canonicalFilePath();

if (!canonicalBase.isEmpty() && !canonicalDest.startsWith(canonicalBase + "/")) {
qWarning() << "[MarathonAppInstaller] SECURITY: Path escape attempt detected:"
<< "dest=" << canonicalDest << "base=" << canonicalBase;
emit installFailed(appId, "Security violation: installation path escape");
return false;
}

// Remove existing installation
if (QDir(destPath).exists()) {
if (!removeDirectory(destPath)) {
emit installFailed(appId, "Failed to remove existing installation");
Expand All @@ -165,7 +197,6 @@ bool MarathonAppInstaller::installFromDirectory(const QString &sourcePath) {

emit installProgress(appId, 50);

// Copy directory
if (!copyDirectory(sourcePath, destPath)) {
emit installFailed(appId, "Failed to copy app files");
return false;
Expand Down
91 changes: 52 additions & 39 deletions marathon-core/src/marathonapppackager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <QJsonObject>
#include <QProcess>
#include <QTemporaryDir>
#include <syslog.h>

static bool copyDirRecursively(const QString &sourceDir, const QString &destDir) {
QDir src(sourceDir);
Expand All @@ -31,7 +32,6 @@ static bool copyDirRecursively(const QString &sourceDir, const QString &destDir)
return false;
}
} else {
// Overwrite if it exists
if (QFile::exists(dstPath)) {
QFile::remove(dstPath);
}
Expand All @@ -46,21 +46,55 @@ static bool copyDirRecursively(const QString &sourceDir, const QString &destDir)
MarathonAppPackager::MarathonAppPackager(QObject *parent)
: QObject(parent) {}

bool MarathonAppPackager::verifyNoSymlinkEscape(const QString &dir, const QString &baseDir) {
QDir directory(dir);
QString canonicalBase = QFileInfo(baseDir).canonicalFilePath();

const QFileInfoList entries =
directory.entryInfoList(QDir::NoDotAndDotDot | QDir::AllEntries | QDir::System);

for (const QFileInfo &entry : entries) {
if (entry.isSymLink()) {
QString target = entry.symLinkTarget();
QFileInfo targetInfo(target);
QString canonicalTarget = targetInfo.canonicalFilePath();

if (canonicalTarget.isEmpty()) {
canonicalTarget =
QFileInfo(entry.absolutePath() + "/" + target).canonicalFilePath();
}

if (!canonicalTarget.startsWith(canonicalBase + "/") &&
canonicalTarget != canonicalBase) {
m_lastError = QString("Symlink escape detected: %1 -> %2")
.arg(entry.absoluteFilePath(), target);
qWarning() << "[MarathonAppPackager] SECURITY:" << m_lastError;
syslog(LOG_AUTH | LOG_ALERT, "[SECURITY] Symlink escape in package: %s -> %s",
entry.absoluteFilePath().toUtf8().constData(), target.toUtf8().constData());
return false;
}
} else if (entry.isDir()) {
if (!verifyNoSymlinkEscape(entry.absoluteFilePath(), baseDir)) {
return false;
}
}
}
return true;
}

bool MarathonAppPackager::validateAppDirectory(const QString &appDir) {
QDir dir(appDir);
if (!dir.exists()) {
m_lastError = "App directory does not exist: " + appDir;
return false;
}

// Check for manifest.json
QString manifestPath = appDir + "/manifest.json";
if (!QFile::exists(manifestPath)) {
m_lastError = "manifest.json not found in app directory";
return false;
}

// Validate manifest
QFile manifestFile(manifestPath);
if (!manifestFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
m_lastError = "Failed to open manifest.json";
Expand All @@ -81,18 +115,16 @@ bool MarathonAppPackager::validateAppDirectory(const QString &appDir) {
return false;
}

QJsonObject obj = doc.object();

// Check required fields
QJsonObject obj = doc.object();
QStringList requiredFields = {"id", "name", "version", "entryPoint", "icon"};

for (const QString &field : requiredFields) {
if (!obj.contains(field) || obj.value(field).toString().isEmpty()) {
m_lastError = "manifest.json missing required field: " + field;
return false;
}
}

// Check if entryPoint exists
QString entryPoint = obj.value("entryPoint").toString();
if (!QFile::exists(appDir + "/" + entryPoint)) {
m_lastError = "Entry point file not found: " + entryPoint;
Expand All @@ -103,10 +135,7 @@ bool MarathonAppPackager::validateAppDirectory(const QString &appDir) {
}

bool MarathonAppPackager::createZipArchive(const QString &sourceDir, const QString &zipPath) {
// Use zip command for creating archive
QProcess zipProcess;

// Change to source directory and create zip
QProcess zipProcess;
QStringList args;
args << "-r" << zipPath << ".";

Expand All @@ -118,7 +147,7 @@ bool MarathonAppPackager::createZipArchive(const QString &sourceDir, const QStri
return false;
}

if (!zipProcess.waitForFinished(30000)) { // 30 second timeout
if (!zipProcess.waitForFinished(30000)) {
m_lastError = "Zip process timed out";
return false;
}
Expand All @@ -132,16 +161,13 @@ bool MarathonAppPackager::createZipArchive(const QString &sourceDir, const QStri
}

bool MarathonAppPackager::extractZipArchive(const QString &zipPath, const QString &destDir) {
// Ensure destination exists
QDir dir;
if (!dir.mkpath(destDir)) {
m_lastError = "Failed to create destination directory: " + destDir;
return false;
}

// Use unzip command for extraction
QProcess unzipProcess;

QStringList args;
args << "-o" << zipPath << "-d" << destDir;

Expand All @@ -152,32 +178,27 @@ bool MarathonAppPackager::extractZipArchive(const QString &zipPath, const QStrin
return false;
}

if (!unzipProcess.waitForFinished(30000)) { // 30 second timeout
if (!unzipProcess.waitForFinished(30000)) {
m_lastError = "Unzip process timed out";
return false;
}

if (unzipProcess.exitCode() != 0) {
QString errorMsg = QString::fromUtf8(unzipProcess.readAllStandardError());
// unzip returns 1 for warnings, which is often ok
if (unzipProcess.exitCode() > 1) {
m_lastError = "Unzip process failed: " + errorMsg;
return false;
}
if (unzipProcess.exitCode() > 1) {
m_lastError =
"Unzip process failed: " + QString::fromUtf8(unzipProcess.readAllStandardError());
return false;
}

return true;
}

bool MarathonAppPackager::verifyPackageStructure(const QString &extractedDir) {
// Verify manifest.json exists
QString manifestPath = extractedDir + "/manifest.json";
if (!QFile::exists(manifestPath)) {
m_lastError = "Extracted package missing manifest.json";
return false;
}

// Validate manifest
QFile manifestFile(manifestPath);
if (!manifestFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
m_lastError = "Failed to read manifest.json from package";
Expand All @@ -193,6 +214,10 @@ bool MarathonAppPackager::verifyPackageStructure(const QString &extractedDir) {
return false;
}

if (!verifyNoSymlinkEscape(extractedDir, extractedDir)) {
return false;
}

return true;
}

Expand All @@ -202,7 +227,6 @@ bool MarathonAppPackager::createPackage(const QString &appDir, const QString &ou

emit packagingProgress(10);

// Validate app directory
if (!validateAppDirectory(appDir)) {
qWarning() << "[MarathonAppPackager] Validation failed:" << m_lastError;
emit error(m_lastError);
Expand All @@ -211,7 +235,6 @@ bool MarathonAppPackager::createPackage(const QString &appDir, const QString &ou

emit packagingProgress(30);

// Remove output file if it exists
if (QFile::exists(outputPath)) {
if (!QFile::remove(outputPath)) {
m_lastError = "Failed to remove existing output file: " + outputPath;
Expand All @@ -222,7 +245,6 @@ bool MarathonAppPackager::createPackage(const QString &appDir, const QString &ou

emit packagingProgress(50);

// Create zip archive
if (!createZipArchive(appDir, outputPath)) {
qWarning() << "[MarathonAppPackager] Failed to create zip:" << m_lastError;
emit error(m_lastError);
Expand All @@ -231,7 +253,6 @@ bool MarathonAppPackager::createPackage(const QString &appDir, const QString &ou

emit packagingProgress(90);

// Verify the created package
if (!QFile::exists(outputPath)) {
m_lastError = "Package file was not created: " + outputPath;
emit error(m_lastError);
Expand All @@ -250,26 +271,23 @@ bool MarathonAppPackager::extractPackage(const QString &packagePath, const QStri

emit extractionProgress(10);

// Check if package exists
if (!QFile::exists(packagePath)) {
m_lastError = "Package file does not exist: " + packagePath;
emit error(m_lastError);
return false;
}

emit extractionProgress(30);
emit extractionProgress(30);

// Create temporary directory for extraction
QTemporaryDir tempDir;
if (!tempDir.isValid()) {
m_lastError = "Failed to create temporary directory";
emit error(m_lastError);
return false;
}

emit extractionProgress(50);
emit extractionProgress(50);

// Extract to temp directory first
QString tempExtractPath = tempDir.path();
if (!extractZipArchive(packagePath, tempExtractPath)) {
qWarning() << "[MarathonAppPackager] Extraction failed:" << m_lastError;
Expand All @@ -279,7 +297,6 @@ bool MarathonAppPackager::extractPackage(const QString &packagePath, const QStri

emit extractionProgress(70);

// Verify package structure
if (!verifyPackageStructure(tempExtractPath)) {
qWarning() << "[MarathonAppPackager] Package structure invalid:" << m_lastError;
emit error(m_lastError);
Expand All @@ -288,7 +305,6 @@ bool MarathonAppPackager::extractPackage(const QString &packagePath, const QStri

emit extractionProgress(85);

// Move to final destination
QDir destDirObj(destDir);
if (destDirObj.exists()) {
if (!destDirObj.removeRecursively()) {
Expand All @@ -298,17 +314,14 @@ bool MarathonAppPackager::extractPackage(const QString &packagePath, const QStri
}
}

// Ensure parent directory exists (but DO NOT create destDir itself; rename needs it absent)
QDir parentDir = QFileInfo(destDir).dir();
if (!parentDir.exists() && !parentDir.mkpath(".")) {
m_lastError = "Failed to create destination parent directory";
emit error(m_lastError);
return false;
}

// Prefer atomic-ish move (fast) when possible.
if (!QDir().rename(tempExtractPath, destDir)) {
// Cross-filesystem or permissions can cause rename to fail. Fall back to recursive copy.
if (!copyDirRecursively(tempExtractPath, destDir)) {
m_lastError = "Failed to move extracted files to destination";
emit error(m_lastError);
Expand Down
1 change: 1 addition & 0 deletions marathon-core/src/marathonapppackager.h
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class MarathonAppPackager : public QObject {
bool createZipArchive(const QString &sourceDir, const QString &zipPath);
bool extractZipArchive(const QString &zipPath, const QString &destDir);
bool verifyPackageStructure(const QString &extractedDir);
bool verifyNoSymlinkEscape(const QString &dir, const QString &baseDir);

QString m_lastError;
};
Loading