initial commit

This commit is contained in:
2026-09-21 13:41:40 +09:00
commit 855c7328df
411 changed files with 85352 additions and 0 deletions
+419
View File
@@ -0,0 +1,419 @@
#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"