420 lines
19 KiB
C++
420 lines
19 KiB
C++
#include "common/contracts.h"
|
|
#include "common/ipc.h"
|
|
#include "common/sandbox.h"
|
|
#include "common/session_storage.h"
|
|
#include "web/resource_policy.h"
|
|
#include <QBuffer>
|
|
#include <QDirIterator>
|
|
#include <QFile>
|
|
#include <QLocalServer>
|
|
#include <QLocalSocket>
|
|
#include <QLockFile>
|
|
#include <QSignalSpy>
|
|
#include <QTemporaryDir>
|
|
#include <QtEndian>
|
|
#include <QtTest>
|
|
#ifdef Q_OS_WIN
|
|
#ifndef NOMINMAX
|
|
#define NOMINMAX
|
|
#endif
|
|
#include <windows.h>
|
|
#endif
|
|
#ifdef Q_OS_LINUX
|
|
#include <cerrno>
|
|
#include <fcntl.h>
|
|
#include <sys/resource.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
using namespace docview;
|
|
class SecurityTest : public QObject {
|
|
Q_OBJECT
|
|
private slots:
|
|
void pathRejection_data() {
|
|
QTest::addColumn<QString>("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<ResourceFile *>(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<ResourceFailure>(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<LPCWSTR>(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"
|