#include "common/contracts.h" #include "common/ipc.h" #include "common/sandbox.h" #include "common/session_storage.h" #include "web/resource_policy.h" #include #include #include #include #include #include #include #include #include #include #ifdef Q_OS_WIN #ifndef NOMINMAX #define NOMINMAX #endif #include #endif #ifdef Q_OS_LINUX #include #include #include #include #include #include #endif using namespace docview; class SecurityTest : public QObject { Q_OBJECT private slots: void pathRejection_data() { QTest::addColumn("path"); for (const auto &p : QStringList{"../secret", "/etc/passwd", "C:/secret", "foo\\bar", "x/../z", "a//b", "a/./b", "NUL", "CON.txt", "name.", "name ", "foo:bar", QString("a") + QChar(0) + "b"}) QTest::newRow(qPrintable(p)) << p; } void pathRejection() { QFETCH(QString, path); QVERIFY(!safeResourcePath(path)); } void paths() { QVERIFY(safeResourcePath(QString::fromUtf8("書籍/第一章.xhtml"))); QVERIFY(safeResourcePath("assets/100%.png")); QCOMPARE(pathFromDocumentUrl(QUrl("doc://session/assets/a.png?size=2#x"), "session"), QString("assets/a.png")); QVERIFY(pathFromDocumentUrl(QUrl("doc://other/a"), "session").isEmpty()); QVERIFY(pathFromDocumentUrl(QUrl("doc://session/a%2fb"), "session").isEmpty()); QVERIFY(pathFromDocumentUrl(QUrl("doc://session/%252e%252e/a"), "session").isEmpty()); QVERIFY(pathFromDocumentUrl(QUrl("file:///etc/passwd"), "session").isEmpty()); } void noFollow() { QTemporaryDir root, outside; QFile f(outside.filePath("secret")); QVERIFY(f.open(QIODevice::WriteOnly)); f.write("secret"); f.close(); QVERIFY(QFile::link(f.fileName(), root.filePath("link"))); QVERIFY(!openResource(root.path(), "link")); QFile ok(root.filePath("a.txt")); QVERIFY(ok.open(QIODevice::WriteOnly)); ok.write("hello"); ok.close(); auto input = openResource(root.path(), "a.txt"); QVERIFY(input); QCOMPARE(input->readAll(), QByteArray("hello")); } void resourceFailureClasses() { QTemporaryDir root; QVERIFY(root.isValid()); ResourceFailure failure = ResourceFailure::None; QVERIFY(!openResource(root.path(), "absent.css", &failure)); QCOMPARE(failure, ResourceFailure::Missing); QVERIFY(!openResource(root.path(), "../escape", &failure)); QCOMPARE(failure, ResourceFailure::Blocked); QFile file(root.filePath("local.css")); QVERIFY(file.open(QIODevice::WriteOnly)); QCOMPARE(file.write("body{}"), 6); file.close(); auto valid = openResource(root.path(), "local.css", &failure); QVERIFY(valid); QCOMPARE(failure, ResourceFailure::None); valid.reset(); #ifdef Q_OS_LINUX QVERIFY(::symlink("local.css", QFile::encodeName(root.filePath("symbolic.css")).constData()) == 0); QVERIFY(!openResource(root.path(), "symbolic.css", &failure)); QCOMPARE(failure, ResourceFailure::Blocked); QVERIFY(::link(QFile::encodeName(file.fileName()).constData(), QFile::encodeName(root.filePath("hard.css")).constData()) == 0); QVERIFY(!openResource(root.path(), "hard.css", &failure)); QCOMPARE(failure, ResourceFailure::Blocked); QVERIFY(QFile::remove(root.filePath("hard.css"))); QVERIFY(QFile::setPermissions(file.fileName(), {})); if (::geteuid() != 0) { QVERIFY(!openResource(root.path(), "local.css", &failure)); QCOMPARE(failure, ResourceFailure::AccessDenied); ResourceWriter denied(root.path(), "allowed.css"); QVERIFY(denied.isValid()); } QVERIFY(QFile::setPermissions(file.fileName(), QFile::ReadOwner | QFile::WriteOwner)); QVERIFY(QDir().mkdir(root.filePath("private"))); QVERIFY(QFile::setPermissions(root.filePath("private"), QFile::ReadOwner | QFile::ExeOwner)); if (::geteuid() != 0) { ResourceWriter denied(root.path(), "private/denied.css"); QVERIFY(!denied.isValid()); QCOMPARE(denied.failure(), ResourceFailure::AccessDenied); } QVERIFY(QFile::setPermissions(root.filePath("private"), QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); #endif QVERIFY(file.open(QIODevice::WriteOnly)); QVERIFY(file.resize(256ll * 1024 * 1024 + 1)); file.close(); QVERIFY(!openResource(root.path(), "local.css", &failure)); QCOMPARE(failure, ResourceFailure::ResourceLimit); ResourceWriter invalid(root.path(), "../escape"); QCOMPARE(invalid.failure(), ResourceFailure::Blocked); ResourceWriter large(root.path(), "chunk.css"); QVERIFY(large.isValid()); QVERIFY(!large.write(QByteArray(65537, 'x'))); QCOMPARE(large.failure(), ResourceFailure::ResourceLimit); QVERIFY(!large.commit()); // A failed write never publishes a partial resource. ResourceWriter unavailable(root.filePath("not-there"), "asset.css"); QVERIFY(!unavailable.isValid()); QCOMPARE(unavailable.failure(), ResourceFailure::StorageFailed); } #ifdef Q_OS_LINUX void resourceReadErrorIsReportedOnce() { QTemporaryDir root; QFile file(root.filePath("asset")); QVERIFY(file.open(QIODevice::WriteOnly)); file.write("text"); file.close(); auto opened = openResource(root.path(), "asset"); QVERIFY(opened); auto *resource = qobject_cast(opened.get()); QVERIFY(resource); QSignalSpy errors(resource, &ResourceFile::readFailed); const int writeOnly = ::open(QFile::encodeName(file.fileName()).constData(), O_WRONLY | O_CLOEXEC); QVERIFY(writeOnly >= 0); QCOMPARE(::dup2(writeOnly, resource->handle()), resource->handle()); ::close(writeOnly); QVERIFY(resource->read(1).isEmpty()); QCOMPARE(errors.size(), 1); QCOMPARE(qvariant_cast(errors.first().first()), ResourceFailure::ReadFailed); QVERIFY(resource->read(1).isEmpty()); QCOMPARE(errors.size(), 1); } void storageFullIsNotMissingOrCorrupt() { QTemporaryDir root; ResourceWriter writer(root.path(), "asset.css"); QVERIFY(writer.isValid()); // Inject an actual ENOSPC device into only this test writer's owned FD. // No filesystem is filled and no production failure hooks are needed. int target = -1; for (const auto &name : QDir("/proc/self/fd").entryList(QDir::AllEntries | QDir::NoDotAndDotDot)) { const auto path = QFileInfo("/proc/self/fd/" + name).symLinkTarget(); if (path.startsWith(root.path() + "/.docview-") && path.endsWith(".part")) target = name.toInt(); } QVERIFY(target >= 0); const int full = ::open("/dev/full", O_WRONLY | O_CLOEXEC); QVERIFY(full >= 0); QCOMPARE(::dup2(full, target), target); ::close(full); const bool wrote = writer.write("data"); if (wrote) QVERIFY(!writer.commit()); QCOMPARE(writer.failure(), ResourceFailure::StorageFull); QVERIFY(!QFile::exists(root.filePath("asset.css"))); } #endif void envelopes() { QCborMap m{{"protocolVersion", 1}, {"requestId", 1}, {"sessionId", "token"}, {"generation", 1}, {"operation", "open"}, {"payload", QCborMap{}}}; QVERIFY(validateEnvelope(m)); m.insert(QStringLiteral("operation"), "executeShell"); QVERIFY(!validateEnvelope(m)); m.insert(QStringLiteral("operation"), "open"); m.insert(QStringLiteral("protocolVersion"), 2); QVERIFY(!validateEnvelope(m)); m.insert(QStringLiteral("protocolVersion"), 1); m.insert(QStringLiteral("result"), QCborMap{}); QVERIFY(!validateEnvelope(m)); } void resourceWriterPublishesOnlyCompletedData() { QTemporaryDir root; { ResourceWriter writer(root.path(), "nested/book.css"); QVERIFY(writer.isValid()); QVERIFY(writer.write("body {")); QVERIFY(!openResource(root.path(), "nested/book.css")); QVERIFY(writer.write("color: black;}")); QCOMPARE(writer.size(), 20); QVERIFY(writer.commit()); QVERIFY(!writer.write("late")); } auto file = openResource(root.path(), "nested/book.css"); QVERIFY(file); QCOMPARE(file->readAll(), QByteArray("body {color: black;}")); { ResourceWriter cancelled(root.path(), "nested/cancelled.css"); QVERIFY(cancelled.write("incomplete")); } QVERIFY(!openResource(root.path(), "nested/cancelled.css")); QCOMPARE(QDir(root.filePath("nested")).entryList(QDir::Files | QDir::Hidden), QStringList{"book.css"}); } void resourceWriterRejectsEscapesAndOversizedChunks() { QTemporaryDir root, outside; QFile secret(outside.filePath("secret")); QVERIFY(secret.open(QIODevice::WriteOnly)); QCOMPARE(secret.write("original"), 8); secret.close(); QVERIFY(QFile::link(outside.path(), root.filePath("escape"))); ResourceWriter escaped(root.path(), "escape/secret"); QVERIFY(!escaped.isValid()); ResourceWriter traversal(root.path(), "../secret"); QVERIFY(!traversal.isValid()); QVERIFY(QFile::link(secret.fileName(), root.filePath("target"))); { ResourceWriter linked(root.path(), "target"); QVERIFY(linked.isValid()); QVERIFY(linked.write("changed")); QVERIFY(!linked.commit()); } ResourceWriter tooLarge(root.path(), "large"); QVERIFY(tooLarge.isValid()); QVERIFY(!tooLarge.write(QByteArray(65537, 'x'))); QCOMPARE(tooLarge.size(), 0); QVERIFY(secret.open(QIODevice::ReadOnly)); QCOMPARE(secret.readAll(), QByteArray("original")); } void frames() { QLocalServer server; server.setSocketOptions(QLocalServer::UserAccessOption); QVERIFY(server.listen("docview-test-" + QUuid::createUuid().toString(QUuid::Id128))); QLocalSocket client; client.connectToServer(server.fullServerName()); QVERIFY(client.waitForConnected()); QVERIFY(server.waitForNewConnection()); auto peer = server.nextPendingConnection(); IpcChannel sender(&client), receiver(peer); QSignalSpy received(&receiver, &IpcChannel::messageReceived); QCborMap m{{"protocolVersion", 1}, {"requestId", 1}, {"sessionId", "token"}, {"generation", 1}, {"operation", "open"}, {"payload", QCborMap{}}}; QVERIFY(sender.send(m)); QTRY_COMPARE(received.size(), 1); QSignalSpy error(&receiver, &IpcChannel::protocolError); char header[4]; qToBigEndian(quint32(MaxControlFrame + 1), header); client.write(header, 4); client.flush(); QTRY_COMPARE(error.size(), 1); } void metadataBudgets() { QVariantList out; QString error; qint64 used = 0; QVariantList rows{ QVariantMap{{"title", "Chapter"}, {"page", 0}, {"attackerData", QString(65536, 'x')}}}; QVERIFY(sanitizeNavigation(rows, &out, &used, &error)); QVERIFY(!out.first().toMap().contains("attackerData")); QVERIFY(used < 100); used = MaxNavigationBytes - 1; QVERIFY(!sanitizeNavigation(rows, &out, &used, &error)); used = 0; QVERIFY(!sanitizeNavigation({QVariantMap{{"title", QString(4097, 'x')}}}, &out, &used, &error)); QVERIFY(!sanitizeNavigation({QVariantMap{{"page", 4294967296ll}}}, &out, &used, &error)); QVariantMap page{{"pageIndex", 1}, {"width", 200}, {"height", 200}, {"rotation", 0}, {"cropBox", QVariantList{0, 0, 200, 200}}}; QVERIFY(!validatePageMetadata({page}, 2, 0)); QVERIFY(validatePageMetadata({page}, 2, 1)); } void sessionCleanup() { QTemporaryDir base; QString stale = base.filePath("docview-expired"), live = base.filePath("docview-live"), other = base.filePath("docview-unowned"); for (const auto &p : {stale, live, other}) QVERIFY(QDir().mkdir(p)); for (const auto &p : {stale, live}) { QFile f(p + "/.docview-session"); QVERIFY(f.open(QIODevice::WriteOnly)); f.write("docview-session-v1\n"); f.close(); QVERIFY(QDir(p).mkdir("nested")); QFile payload(p + "/nested/.payload"); QVERIFY(payload.open(QIODevice::WriteOnly)); QCOMPARE(payload.write("payload"), 7); } QLockFile lock(live + "/.lock"); QVERIFY(lock.tryLock()); cleanExpiredSessions(base.path()); QVERIFY(!QFileInfo::exists(stale)); QVERIFY(QFileInfo::exists(live)); QVERIFY(QFileInfo::exists(live + "/.docview-session")); QVERIFY(QFileInfo::exists(live + "/nested/.payload")); QVERIFY(QFileInfo::exists(other)); lock.unlock(); cleanExpiredSessions(base.path()); QVERIFY(!QFileInfo::exists(live)); QVERIFY(QFileInfo::exists(other)); } void sessionCleanupRetriesFailedPayloadRemoval() { #if defined(Q_OS_LINUX) || defined(Q_OS_WIN) #ifdef Q_OS_LINUX if (geteuid() == 0) QSKIP("The permission failure fixture requires an unprivileged user"); #endif QTemporaryDir base; const QString stale = base.filePath("docview-retry"); QVERIFY(QDir().mkpath(stale + "/nested")); const QString markerPath = stale + "/.docview-session"; QFile marker(markerPath); QVERIFY(marker.open(QIODevice::WriteOnly)); QCOMPARE(marker.write("docview-session-v1\n"), 19); marker.close(); const QString payloadPath = stale + "/nested/payload"; QFile payload(payloadPath); QVERIFY(payload.open(QIODevice::WriteOnly)); QCOMPARE(payload.write("retry"), 5); payload.close(); #ifdef Q_OS_WIN struct OpenFile { HANDLE handle; ~OpenFile() { if (handle != INVALID_HANDLE_VALUE) CloseHandle(handle); } } held{CreateFileW(reinterpret_cast(payloadPath.utf16()), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr)}; QVERIFY(held.handle != INVALID_HANDLE_VALUE); #else const auto originalPermissions = QFile::permissions(stale + "/nested"); QVERIFY(QFile::setPermissions(stale + "/nested", QFile::ReadOwner | QFile::ExeOwner)); #endif cleanExpiredSessions(base.path()); const bool markerSurvived = QFileInfo::exists(markerPath); const bool payloadSurvived = QFileInfo::exists(payloadPath); const bool leaseRemoved = !QFileInfo::exists(stale + "/.lock"); #ifdef Q_OS_WIN QVERIFY(CloseHandle(held.handle)); held.handle = INVALID_HANDLE_VALUE; #else QVERIFY(QFile::setPermissions(stale + "/nested", originalPermissions)); #endif QVERIFY(markerSurvived); QVERIFY(payloadSurvived); QVERIFY(leaseRemoved); cleanExpiredSessions(base.path()); QVERIFY(!QFileInfo::exists(stale)); #else QSKIP("The deletion failure fixture supports Linux and Windows"); #endif } void osSandbox() { #ifdef Q_OS_LINUX QTemporaryDir allowed, blocked; QFile permitted(allowed.filePath("source")), secret(blocked.filePath("secret")); for (QFile *f : {&permitted, &secret}) { QVERIFY(f->open(QIODevice::WriteOnly)); f->write("test"); f->close(); } const pid_t pid = fork(); QVERIFY(pid >= 0); if (pid == 0) { QString error; if (!enterSandbox({permitted.fileName()}, {allowed.path()}, &error)) _exit(10); int fd = open(QFile::encodeName(permitted.fileName()).constData(), O_RDONLY); if (fd < 0) _exit(11); close(fd); fd = open(QFile::encodeName(secret.fileName()).constData(), O_RDONLY); if (fd >= 0) _exit(12); fd = open(QFile::encodeName(blocked.filePath("created")).constData(), O_CREAT | O_WRONLY, 0600); if (fd >= 0) _exit(13); fd = socket(AF_INET, SOCK_STREAM, 0); if (fd >= 0) _exit(14); if (fork() >= 0) _exit(15); if (kill(getppid(), 0) == 0) _exit(17); rlimit limit{}; if (getrlimit(RLIMIT_AS, &limit) || limit.rlim_cur != 1536ull * 1024 * 1024) _exit(16); _exit(0); } int status = 0; QVERIFY(waitpid(pid, &status, 0) == pid); QVERIFY(WIFEXITED(status)); QCOMPARE(WEXITSTATUS(status), 0); #else QSKIP("Native Linux boundary test"); #endif } void systemFontsNeedExplicitReadGrants() { #ifdef Q_OS_LINUX QString fontPath; QDirIterator fonts("/usr/share/fonts", {"*.ttf", "*.otf", "*.ttc"}, QDir::Files, QDirIterator::Subdirectories); while (fonts.hasNext()) { QFile font(fonts.next()); if (font.open(QIODevice::ReadOnly) && !font.read(1).isEmpty()) { fontPath = QFileInfo(font.fileName()).canonicalFilePath(); break; } } if (fontPath.isEmpty()) QSKIP("No readable system font is installed under /usr/share/fonts"); const QByteArray nativePath = QFile::encodeName(fontPath); const pid_t pid = fork(); QVERIFY(pid >= 0); if (pid == 0) { const int before = open(nativePath.constData(), O_RDONLY); if (before < 0) _exit(20); close(before); QString error; if (!enterSandbox({}, {}, &error)) _exit(21); const int after = open(nativePath.constData(), O_RDONLY); if (after >= 0) { close(after); _exit(22); } if (errno != EACCES && errno != EPERM) _exit(23); _exit(0); } int status = 0; QVERIFY(waitpid(pid, &status, 0) == pid); QVERIFY(WIFEXITED(status)); QCOMPARE(WEXITSTATUS(status), 0); #else QSKIP("Native Linux font read-grant test"); #endif } }; QTEST_GUILESS_MAIN(SecurityTest) #include "test_security.moc"