diff --git a/CMakeLists.txt b/CMakeLists.txt index 84de141764..0fd2514425 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -55,12 +55,14 @@ option(KPXC_MINIMAL "Build KeePassXC with the minimal feature set required for b option(KPXC_FEATURE_BROWSER "Browser integration and passkeys support" ON) option(KPXC_FEATURE_SSHAGENT "SSH Agent integration" ON) option(KPXC_FEATURE_FDOSECRETS "freedesktop.org Secret Service integration; replace system keyring" ON) +option(KPXC_FEATURE_URLOVERRIDE "Global URL scheme override support" ON) if(KPXC_MINIMAL) # Disable advanced features in minimal mode set(KPXC_FEATURE_BROWSER OFF) set(KPXC_FEATURE_SSHAGENT OFF) set(KPXC_FEATURE_FDOSECRETS OFF) + set(KPXC_FEATURE_URLOVERRIDE OFF) endif() # Minor Feature Flags @@ -82,6 +84,7 @@ endif() # Define feature summaries add_feature_info("Browser" KPXC_FEATURE_BROWSER "Browser integration and passkeys support") add_feature_info("SSH Agent" KPXC_FEATURE_SSHAGENT "SSH Agent integration") +add_feature_info("URL Override" KPXC_FEATURE_URLOVERRIDE "Global URL scheme override support") if(UNIX AND NOT APPLE) add_feature_info("Secret Service" KPXC_FEATURE_FDOSECRETS "Replace system keyring with freedesktop.org Secret Service integration") endif() diff --git a/INSTALL.md b/INSTALL.md index 4eb2665a4c..adcd0d5922 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -82,6 +82,7 @@ KeePassXC comes with a variety of build options that can turn on/off features. E -DKPXC_FEATURE_BROWSER=[ON|OFF] Browser integration and passkeys support (default: ON) -DKPXC_FEATURE_SSHAGENT=[ON|OFF] SSH Agent integration (default: ON) -DKPXC_FEATURE_FDOSECRETS=[ON|OFF] (Linux Only) freedesktop.org Secret Service integration; replace system keyring (default:ON) +-DKPXC_FEATURE_URLOVERRIDE=[ON|OFF] Global URL scheme override support (default: ON) -DKPXC_FEATURE_NETWORK=[ON|OFF] Include code that reaches out to external networks (e.g. downloading icons) (default: ON) -DKPXC_FEATURE_UPDATES=[ON|OFF] Include automatic update checks; disable for managed distributions (requires networking) (default: ON) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 30a061ed9e..f6df99f894 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -342,6 +342,11 @@ if(KPXC_FEATURE_FDOSECRETS) set(fdosecrets_LIB fdosecrets) endif() +add_subdirectory(urloverride) +if(KPXC_FEATURE_URLOVERRIDE) + set(urloverride_LIB urloverride) +endif() + configure_file(config-keepassx.h.in ${CMAKE_CURRENT_BINARY_DIR}/config-keepassx.h) configure_file(git-info.h.in ${CMAKE_CURRENT_BINARY_DIR}/git-info.h) @@ -374,7 +379,8 @@ target_link_libraries(keepassxc_gui ${browser_LIB} ${fdosecrets_LIB} ${keeshare_LIB} - ${sshagent_LIB}) + ${sshagent_LIB} + ${urloverride_LIB}) if(APPLE) target_link_libraries(keepassxc_gui "-framework Foundation -framework AppKit -framework Carbon -framework Security -framework LocalAuthentication -framework ScreenCaptureKit") diff --git a/src/config-keepassx.h.in b/src/config-keepassx.h.in index c354daecac..435c97bafc 100644 --- a/src/config-keepassx.h.in +++ b/src/config-keepassx.h.in @@ -20,6 +20,7 @@ #cmakedefine KPXC_FEATURE_BROWSER #cmakedefine KPXC_FEATURE_SSHAGENT #cmakedefine KPXC_FEATURE_FDOSECRETS +#cmakedefine KPXC_FEATURE_URLOVERRIDE /* Minor Features */ #cmakedefine KPXC_FEATURE_NETWORK diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 90c01c4dfe..fcbeb1e667 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -237,7 +237,9 @@ static const QHash configStrings = { // Messages {Config::Messages_NoLegacyKeyFileWarning, {QS("Messages/NoLegacyKeyFileWarning"), Roaming, false}}, - {Config::Messages_HidePreReleaseWarning, {QS("Messages/HidePreReleaseWarning"), Local, {}}}}; + {Config::Messages_HidePreReleaseWarning, {QS("Messages/HidePreReleaseWarning"), Local, {}}}, + + {Config::UrlOverride_Rules, {QS("UrlOverride/Rules"), Roaming, {}}}}; // clang-format on diff --git a/src/core/Config.h b/src/core/Config.h index b1c7e5eea8..e8d17c58c2 100644 --- a/src/core/Config.h +++ b/src/core/Config.h @@ -212,6 +212,8 @@ class Config : public QObject Messages_NoLegacyKeyFileWarning, Messages_HidePreReleaseWarning, + UrlOverride_Rules, + // Special internal value Deleted }; diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp index 5eca3b6e8e..a253dc449e 100644 --- a/src/gui/DatabaseWidget.cpp +++ b/src/gui/DatabaseWidget.cpp @@ -60,6 +60,10 @@ #include "remote/RemoteHandler.h" #include "remote/RemoteSettings.h" +#ifdef KPXC_FEATURE_URLOVERRIDE +#include "urloverride/UrlOverride.h" +#endif + #ifdef KPXC_FEATURE_NETWORK #include "gui/IconDownloaderDialog.h" #endif @@ -1001,14 +1005,22 @@ void DatabaseWidget::openUrlForEntry(Entry* entry) return; } - QString cmdString = entry->resolveMultiplePlaceholders(entry->url()); + QString rawTemplate = entry->url(); +#ifdef KPXC_FEATURE_URLOVERRIDE + const auto overrideCommand = UrlOverride::findCommand(rawTemplate); + if (!overrideCommand.isEmpty()) { + rawTemplate = overrideCommand; + } +#endif + QString cmdString = entry->resolveMultiplePlaceholders(rawTemplate); + if (cmdString.startsWith("cmd://")) { // check if decision to execute command was stored bool launch = (entry->attributes()->value(EntryAttributes::RememberCmdExecAttr) == "1"); // otherwise ask user if (!launch && cmdString.length() > 6) { - QString cmdTruncated = entry->resolveMultiplePlaceholders(entry->maskPasswordPlaceholders(entry->url())); + QString cmdTruncated = entry->resolveMultiplePlaceholders(entry->maskPasswordPlaceholders(rawTemplate)); cmdTruncated = cmdTruncated.mid(6); if (cmdTruncated.length() > 400) { cmdTruncated = cmdTruncated.left(400) + " […]"; @@ -1043,7 +1055,11 @@ void DatabaseWidget::openUrlForEntry(Entry* entry) QStringList cmdList = QProcess::splitCommand(cmd); if (!cmdList.isEmpty()) { const QString program = cmdList.takeFirst(); +#ifdef KPXC_FEATURE_URLOVERRIDE + UrlOverride::executeCommand(program, cmdList); +#else QProcess::startDetached(program, cmdList); +#endif } if (config()->get(Config::MinimizeOnOpenUrl).toBool()) { @@ -1053,7 +1069,7 @@ void DatabaseWidget::openUrlForEntry(Entry* entry) } else if (cmdString.startsWith("kdbx://")) { openDatabaseFromEntry(entry, false); } else { - QUrl url = QUrl::fromUserInput(entry->resolveMultiplePlaceholders(entry->url())); + QUrl url = QUrl::fromUserInput(cmdString); if (!url.isEmpty()) { #ifdef KEEPASSXC_DIST_APPIMAGE QProcess::execute("xdg-open", {url.toString(QUrl::FullyEncoded)}); diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index 9b39a62e4a..ac32ed54b1 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -72,6 +72,10 @@ #include "browser/BrowserService.h" #endif +#ifdef KPXC_FEATURE_URLOVERRIDE +#include "urloverride/UrlOverrideSettingsPage.h" +#endif + #if defined(Q_OS_UNIX) && !defined(Q_OS_MACOS) && !defined(QT_NO_DBUS) #include "mainwindowadaptor.h" #endif @@ -203,6 +207,9 @@ MainWindow::MainWindow() initActionCollection(); m_ui->settingsWidget->addSettingsPage(new ShortcutSettingsPage()); +#ifdef KPXC_FEATURE_URLOVERRIDE + m_ui->settingsWidget->addSettingsPage(new UrlOverrideSettingsPage()); +#endif #ifdef KPXC_FEATURE_BROWSER connect( diff --git a/src/gui/UrlTools.cpp b/src/gui/UrlTools.cpp index 8ff791f6a1..16aa78e3c5 100644 --- a/src/gui/UrlTools.cpp +++ b/src/gui/UrlTools.cpp @@ -210,3 +210,14 @@ bool UrlTools::domainHasIllegalCharacters(const QString& domain) static const QRegularExpression re(R"([\s\^#|/:<>\?@\[\]\\])"); return re.match(domain).hasMatch(); } + +// Extracts the URI scheme (ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ), per RFC 3986) from a +// possibly messy string, e.g. one entered by a user who isn't thinking about exact syntax. +// Anything before or after the matched scheme is simply not part of it and is discarded, rather +// than guessing which surrounding characters (like a trailing "://") to strip. +QString UrlTools::normalizeScheme(const QString& scheme) +{ + static const QRegularExpression schemePattern("[A-Za-z][A-Za-z0-9+.-]*"); + const auto match = schemePattern.match(scheme); + return match.hasMatch() ? match.captured(0) : QString(); +} diff --git a/src/gui/UrlTools.h b/src/gui/UrlTools.h index 60db7cb656..080c3ddd1c 100644 --- a/src/gui/UrlTools.h +++ b/src/gui/UrlTools.h @@ -36,6 +36,7 @@ namespace UrlTools bool isUrlIdentical(QString first, QString second); bool isUrlValid(const QString& urlField, bool looseComparison = false); bool domainHasIllegalCharacters(const QString& domain); + QString normalizeScheme(const QString& scheme); extern const QString URL_WILDCARD; } // namespace UrlTools diff --git a/src/urloverride/CMakeLists.txt b/src/urloverride/CMakeLists.txt new file mode 100644 index 0000000000..03bec44197 --- /dev/null +++ b/src/urloverride/CMakeLists.txt @@ -0,0 +1,26 @@ +# Copyright (C) 2026 KeePassXC Team +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 2 or (at your option) +# version 3 of the License. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +if(KPXC_FEATURE_URLOVERRIDE) + include_directories(${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR}) + + set(urloverride_SOURCES + UrlOverride.cpp + UrlOverrideSettingsPage.cpp + ) + + add_library(urloverride STATIC ${urloverride_SOURCES}) + target_link_libraries(urloverride Qt6::Core Qt6::Widgets) +endif() diff --git a/src/urloverride/UrlOverride.cpp b/src/urloverride/UrlOverride.cpp new file mode 100644 index 0000000000..0973f22a6d --- /dev/null +++ b/src/urloverride/UrlOverride.cpp @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2026 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "UrlOverride.h" + +#include "core/Config.h" +#include "gui/UrlTools.h" + +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +#include + +namespace +{ + // Reads the PE header of an executable to determine whether it is a console-subsystem + // application (e.g. ssh.exe) as opposed to a GUI-subsystem one (e.g. firefox.exe). Only + // console-subsystem programs need a console window allocated for them; forcing one for a + // GUI program would just pop up an empty, unwanted console window next to it. + bool isConsoleSubsystemExecutable(const QString& path) + { + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const qint64 fileSize = file.size(); + if (fileSize < static_cast(sizeof(IMAGE_DOS_HEADER))) { + return false; + } + + IMAGE_DOS_HEADER dosHeader{}; + if (file.read(reinterpret_cast(&dosHeader), sizeof(dosHeader)) != sizeof(dosHeader) + || dosHeader.e_magic != IMAGE_DOS_SIGNATURE) { + return false; + } + + // e_lfanew is a file offset to the PE header + if (dosHeader.e_lfanew < static_cast(sizeof(IMAGE_DOS_HEADER)) + || dosHeader.e_lfanew > fileSize - static_cast(sizeof(DWORD) + sizeof(IMAGE_FILE_HEADER))) { + return false; + } + + if (!file.seek(dosHeader.e_lfanew)) { + return false; + } + + DWORD peSignature{}; + if (file.read(reinterpret_cast(&peSignature), sizeof(peSignature)) != sizeof(peSignature) + || peSignature != IMAGE_NT_SIGNATURE) { + return false; + } + + IMAGE_FILE_HEADER fileHeader{}; + if (file.read(reinterpret_cast(&fileHeader), sizeof(fileHeader)) != sizeof(fileHeader)) { + return false; + } + + if (fileHeader.SizeOfOptionalHeader < sizeof(WORD)) { + return false; + } + + const qint64 optionalHeaderStart = file.pos(); + + WORD magic{}; + if (file.read(reinterpret_cast(&magic), sizeof(magic)) != sizeof(magic)) { + return false; + } + + if (!file.seek(optionalHeaderStart)) { + return false; + } + + WORD subsystem = 0; + + if (magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) { + if (fileHeader.SizeOfOptionalHeader < sizeof(IMAGE_OPTIONAL_HEADER64) + || optionalHeaderStart + static_cast(sizeof(IMAGE_OPTIONAL_HEADER64)) > fileSize) { + return false; + } + + IMAGE_OPTIONAL_HEADER64 optionalHeader{}; + if (file.read(reinterpret_cast(&optionalHeader), sizeof(optionalHeader)) != sizeof(optionalHeader)) { + return false; + } + + subsystem = optionalHeader.Subsystem; + } else if (magic == IMAGE_NT_OPTIONAL_HDR32_MAGIC) { + if (fileHeader.SizeOfOptionalHeader < sizeof(IMAGE_OPTIONAL_HEADER32) + || optionalHeaderStart + static_cast(sizeof(IMAGE_OPTIONAL_HEADER32)) > fileSize) { + return false; + } + + IMAGE_OPTIONAL_HEADER32 optionalHeader{}; + if (file.read(reinterpret_cast(&optionalHeader), sizeof(optionalHeader)) != sizeof(optionalHeader)) { + return false; + } + + subsystem = optionalHeader.Subsystem; + } else { + return false; + } + + return subsystem == IMAGE_SUBSYSTEM_WINDOWS_CUI; + } +} // namespace +#endif + +namespace +{ + // Rules are stored as an XML string under a single Config key, the same way KeeShare stores + // its (also structured, list-shaped) settings via KeeShareSettings::serialize/deserialize. + QString serializeRules(const QList& rules) + { + QString buffer; + QXmlStreamWriter writer(&buffer); + writer.writeStartDocument(); + writer.writeStartElement("UrlOverrides"); + for (const auto& rule : rules) { + writer.writeStartElement("Rule"); + writer.writeAttribute("Enabled", rule.enabled ? "1" : "0"); + writer.writeTextElement("Scheme", rule.scheme); + writer.writeTextElement("Command", rule.command); + writer.writeEndElement(); + } + writer.writeEndElement(); + writer.writeEndDocument(); + return buffer; + } + + QList deserializeRules(const QString& raw) + { + QList rules; + QXmlStreamReader reader(raw); + if (!reader.readNextStartElement() || reader.name().toString() != QLatin1String("UrlOverrides")) { + return rules; + } + + while (reader.readNextStartElement()) { + if (reader.name().toString() != QLatin1String("Rule")) { + reader.skipCurrentElement(); + continue; + } + + UrlOverride::Rule rule; + rule.enabled = reader.attributes().value("Enabled").toString() != QLatin1String("0"); + while (reader.readNextStartElement()) { + if (reader.name().toString() == QLatin1String("Scheme")) { + rule.scheme = reader.readElementText(); + } else if (reader.name().toString() == QLatin1String("Command")) { + rule.command = reader.readElementText(); + } else { + reader.skipCurrentElement(); + } + } + rules.append(rule); + } + return rules; + } +} // namespace + +namespace UrlOverride +{ + QList getRules() + { + const auto raw = config()->get(Config::UrlOverride_Rules).toString(); + if (raw.isEmpty()) { + // Nothing has ever been saved for this feature (fresh install, or a fresh config + // file): seed a disabled example rule so the feature and its placeholder syntax are + // discoverable in the settings page without doing anything until explicitly enabled. + // Once the user saves anything (including an empty list), this is never shown again. + return {{false, "ssh", "cmd://ssh {USERNAME}@{URL:HOST}"}}; + } + return deserializeRules(raw); + } + + void setRules(const QList& rules) + { + QList normalizedRules; + normalizedRules.reserve(rules.size()); + for (const auto& rule : rules) { + normalizedRules.append({rule.enabled, UrlTools::normalizeScheme(rule.scheme), rule.command}); + } + config()->set(Config::UrlOverride_Rules, serializeRules(normalizedRules)); + } + + QString findCommand(const QString& url) + { + const QString urlScheme = QUrl(url).scheme(); + if (urlScheme.isEmpty()) { + return {}; + } + + for (const auto& rule : getRules()) { + // A rule with an empty command has nothing to run: skip it and keep looking, rather + // than matching and returning an empty command that blocks any lower-priority rule + // for the same scheme. + if (!rule.enabled || rule.scheme.isEmpty() || rule.command.isEmpty()) { + continue; + } + if (rule.scheme.compare(urlScheme, Qt::CaseInsensitive) == 0) { + return rule.command; + } + } + return {}; + } + + void executeCommand(const QString& program, const QStringList& arguments) + { +#ifdef Q_OS_WIN + // QProcess::startDetached() does not request a new console for the child process. + // KeePassXC itself has no console, so without this, a console-subsystem command (e.g. + // ssh) ends up with no visible window and no way to answer interactive prompts. Only do + // this for console-subsystem programs; forcing it for a GUI program (e.g. a browser) + // would just pop up an empty console window alongside it. + const auto resolvedProgram = QStandardPaths::findExecutable(program); + if (!resolvedProgram.isEmpty() && isConsoleSubsystemExecutable(resolvedProgram)) { + QProcess process; + process.setProgram(resolvedProgram); + process.setArguments(arguments); + process.setCreateProcessArgumentsModifier( + [](QProcess::CreateProcessArguments* args) { args->flags |= CREATE_NEW_CONSOLE; }); + process.startDetached(); + return; + } +#endif + QProcess::startDetached(program, arguments); + } +} // namespace UrlOverride diff --git a/src/urloverride/UrlOverride.h b/src/urloverride/UrlOverride.h new file mode 100644 index 0000000000..e3c82c66a2 --- /dev/null +++ b/src/urloverride/UrlOverride.h @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_URLOVERRIDE_H +#define KEEPASSXC_URLOVERRIDE_H + +#include +#include + +// Global URL scheme override support. Lets a URL scheme (e.g. "ssh", "kdbx") be mapped to an +// external command template instead of the default browser action. Storage is self-contained +// here rather than in core/Config so the whole feature can be built out via +// KPXC_FEATURE_URLOVERRIDE. +namespace UrlOverride +{ + struct Rule + { + bool enabled; + QString scheme; + QString command; + + bool operator==(const Rule& other) const + { + return enabled == other.enabled && scheme == other.scheme && command == other.command; + } + }; + + QList getRules(); + void setRules(const QList& rules); + QString findCommand(const QString& url); + + // Runs a "cmd://"-style external command detached from KeePassXC. On Windows, ensures a + // visible console window is allocated for console-subsystem programs (e.g. ssh, plink) while + // leaving GUI-subsystem programs (e.g. a browser) unaffected. + void executeCommand(const QString& program, const QStringList& arguments); +} // namespace UrlOverride + +#endif // KEEPASSXC_URLOVERRIDE_H diff --git a/src/urloverride/UrlOverrideSettingsPage.cpp b/src/urloverride/UrlOverrideSettingsPage.cpp new file mode 100644 index 0000000000..a8ee60e56e --- /dev/null +++ b/src/urloverride/UrlOverrideSettingsPage.cpp @@ -0,0 +1,219 @@ +/* + * Copyright (C) 2026 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "UrlOverrideSettingsPage.h" + +#include "UrlOverride.h" +#include "gui/Icons.h" +#include "gui/MessageBox.h" +#include "gui/UrlTools.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + enum Column + { + EnabledColumn, + SchemeColumn, + CommandColumn + }; +} // namespace + +class UrlOverrideSettingsWidget final : public QWidget +{ +public: + explicit UrlOverrideSettingsWidget(QWidget* parent = nullptr) + : QWidget(parent) + , m_table(new QTableWidget(0, 3, this)) + , m_addButton(new QPushButton(QObject::tr("Add"), this)) + , m_removeButton(new QPushButton(QObject::tr("Remove"), this)) + , m_moveUpButton(new QPushButton(QObject::tr("Move Up"), this)) + , m_moveDownButton(new QPushButton(QObject::tr("Move Down"), this)) + { + auto* layout = new QVBoxLayout(this); + + auto* infoLabel = new QLabel( + QObject::tr("Define rules to run a command instead of the default action for a matching URL scheme.
" + "The first enabled rule whose scheme exactly matches is used.
" + "Commands must start with cmd:// and may use " + "" + "Entry Placeholders."), + this); + infoLabel->setTextFormat(Qt::RichText); + infoLabel->setOpenExternalLinks(true); + infoLabel->setWordWrap(true); + layout->addWidget(infoLabel); + + m_table->setHorizontalHeaderLabels({QObject::tr("Enabled"), QObject::tr("URL Scheme"), QObject::tr("Command")}); + m_table->horizontalHeader()->setSectionResizeMode(EnabledColumn, QHeaderView::ResizeToContents); + m_table->horizontalHeader()->setSectionResizeMode(SchemeColumn, QHeaderView::Interactive); + m_table->horizontalHeader()->setSectionResizeMode(CommandColumn, QHeaderView::Stretch); + m_table->verticalHeader()->hide(); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setSelectionMode(QAbstractItemView::SingleSelection); + layout->addWidget(m_table); + + auto* buttonLayout = new QHBoxLayout(); + buttonLayout->addWidget(m_addButton); + buttonLayout->addWidget(m_removeButton); + buttonLayout->addStretch(); + buttonLayout->addWidget(m_moveUpButton); + buttonLayout->addWidget(m_moveDownButton); + layout->addLayout(buttonLayout); + + QObject::connect(m_addButton, &QPushButton::clicked, this, [this] { addRow(true, {}, {}); }); + QObject::connect(m_removeButton, &QPushButton::clicked, this, &UrlOverrideSettingsWidget::removeSelectedRow); + QObject::connect(m_moveUpButton, &QPushButton::clicked, this, [this] { moveSelectedRow(-1); }); + QObject::connect(m_moveDownButton, &QPushButton::clicked, this, [this] { moveSelectedRow(1); }); + } + + void loadSettings() + { + m_originalRules = UrlOverride::getRules(); + m_table->setRowCount(0); + for (const auto& rule : m_originalRules) { + addRow(rule.enabled, rule.scheme, rule.command); + } + } + + void saveSettings() + { + auto rules = tableRules(); + if (rules == m_originalRules) { + // Nothing changed, no need to ask for confirmation + return; + } + + auto answer = MessageBox::question( + this, + QObject::tr("Confirm URL Scheme Overrides"), + QObject::tr("You are about to change how KeePassXC opens URLs with certain schemes. Entries whose URL " + "scheme matches one of these rules will run the configured command instead of the default " + "action.\n\nDo you want to save these changes?"), + MessageBox::Yes | MessageBox::Cancel, + MessageBox::Cancel); + if (answer != MessageBox::Yes) { + // Discard the edits and restore the table to the last saved state + m_table->setRowCount(0); + for (const auto& rule : m_originalRules) { + addRow(rule.enabled, rule.scheme, rule.command); + } + return; + } + + UrlOverride::setRules(rules); + m_originalRules = UrlOverride::getRules(); + } + +private: + QList tableRules() const + { + QList rules; + rules.reserve(m_table->rowCount()); + for (int row = 0; row < m_table->rowCount(); ++row) { + UrlOverride::Rule rule; + rule.enabled = m_table->item(row, EnabledColumn)->checkState() == Qt::Checked; + rule.scheme = UrlTools::normalizeScheme(m_table->item(row, SchemeColumn)->text()); + rule.command = m_table->item(row, CommandColumn)->text().trimmed(); + if (rule.scheme.isEmpty() && rule.command.isEmpty()) { + continue; + } + rules.append(rule); + } + return rules; + } + + void addRow(bool enabled, const QString& scheme, const QString& command) + { + int row = m_table->rowCount(); + m_table->insertRow(row); + + auto* enabledItem = new QTableWidgetItem(); + enabledItem->setFlags(Qt::ItemIsUserCheckable | Qt::ItemIsEnabled | Qt::ItemIsSelectable); + enabledItem->setCheckState(enabled ? Qt::Checked : Qt::Unchecked); + m_table->setItem(row, EnabledColumn, enabledItem); + + m_table->setItem(row, SchemeColumn, new QTableWidgetItem(scheme)); + m_table->setItem(row, CommandColumn, new QTableWidgetItem(command)); + + m_table->selectRow(row); + m_table->setCurrentCell(row, SchemeColumn); + } + + void removeSelectedRow() + { + auto row = m_table->currentRow(); + if (row >= 0) { + m_table->removeRow(row); + } + } + + void moveSelectedRow(int direction) + { + auto row = m_table->currentRow(); + auto newRow = row + direction; + if (row < 0 || newRow < 0 || newRow >= m_table->rowCount()) { + return; + } + + for (int col = 0; col < m_table->columnCount(); ++col) { + auto* item = m_table->takeItem(row, col); + auto* otherItem = m_table->takeItem(newRow, col); + m_table->setItem(newRow, col, item); + m_table->setItem(row, col, otherItem); + } + m_table->selectRow(newRow); + } + + QTableWidget* const m_table; + QPushButton* const m_addButton; + QPushButton* const m_removeButton; + QPushButton* const m_moveUpButton; + QPushButton* const m_moveDownButton; + QList m_originalRules; +}; + +QString UrlOverrideSettingsPage::name() +{ + return QObject::tr("URL Overrides"); +} + +QIcon UrlOverrideSettingsPage::icon() +{ + return icons()->icon("internet-web-browser"); +} + +QWidget* UrlOverrideSettingsPage::createWidget() +{ + return new UrlOverrideSettingsWidget(); +} + +void UrlOverrideSettingsPage::loadSettings(QWidget* widget) +{ + static_cast(widget)->loadSettings(); +} + +void UrlOverrideSettingsPage::saveSettings(QWidget* widget) +{ + static_cast(widget)->saveSettings(); +} diff --git a/src/urloverride/UrlOverrideSettingsPage.h b/src/urloverride/UrlOverrideSettingsPage.h new file mode 100644 index 0000000000..955e2edef0 --- /dev/null +++ b/src/urloverride/UrlOverrideSettingsPage.h @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSXC_URLOVERRIDESETTINGSPAGE_H +#define KEEPASSXC_URLOVERRIDESETTINGSPAGE_H + +#include "gui/ApplicationSettingsWidget.h" + +class UrlOverrideSettingsPage : public ISettingsPage +{ +public: + explicit UrlOverrideSettingsPage() = default; + ~UrlOverrideSettingsPage() override = default; + + QString name() override; + QIcon icon() override; + QWidget* createWidget() override; + void loadSettings(QWidget* widget) override; + void saveSettings(QWidget* widget) override; +}; + +#endif // KEEPASSXC_URLOVERRIDESETTINGSPAGE_H diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a500c517d2..be80535dca 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -146,6 +146,11 @@ add_unit_test(NAME testtools SOURCES TestTools.cpp add_unit_test(NAME testconfig SOURCES TestConfig.cpp LIBS testsupport ${TEST_LIBRARIES}) +if(KPXC_FEATURE_URLOVERRIDE) + add_unit_test(NAME testurloverride SOURCES TestUrlOverride.cpp + LIBS urloverride testsupport ${TEST_LIBRARIES}) +endif() + add_unit_test(NAME testcli SOURCES TestCli.cpp LIBS testsupport cli ${ZXCVBN_LIBRARIES} ${TEST_LIBRARIES}) target_compile_definitions(testcli PRIVATE KEEPASSX_CLI_PATH="$") diff --git a/tests/TestUrlOverride.cpp b/tests/TestUrlOverride.cpp new file mode 100644 index 0000000000..9e8c699eb9 --- /dev/null +++ b/tests/TestUrlOverride.cpp @@ -0,0 +1,186 @@ +/* + * Copyright (C) 2026 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "TestUrlOverride.h" + +#include + +#include "core/Config.h" +#include "util/TemporaryFile.h" + +QTEST_GUILESS_MAIN(TestUrlOverride) + +void TestUrlOverride::initTestCase() +{ + QLocale::setDefault(QLocale::c()); +} + +void TestUrlOverride::testUrlOverrides() +{ + TemporaryFile tempFile; + tempFile.open(); + tempFile.close(); + Config::createConfigFromFile(tempFile.fileName()); + + // A freshly created config has never had anything saved for this feature, so a disabled + // example rule is seeded to make the feature discoverable (see testDefaultSeedRule) + QCOMPARE(UrlOverride::getRules().size(), 1); + + QList rules; + rules.append({true, "ssh", "cmd://ssh {USERNAME}@{URL:HOST}"}); + rules.append({false, "vpn", "cmd://vpn-client --host {URL:HOST}"}); + rules.append({true, "ftp", "cmd://ftp {URL:HOST}"}); + UrlOverride::setRules(rules); + + // The rules must round-trip exactly, including order and the disabled rule + auto storedRules = UrlOverride::getRules(); + QCOMPARE(storedRules.size(), 3); + for (int i = 0; i < rules.size(); ++i) { + QCOMPARE(storedRules[i].enabled, rules[i].enabled); + QCOMPARE(storedRules[i].scheme, rules[i].scheme); + QCOMPARE(storedRules[i].command, rules[i].command); + } + + // Overwriting with a shorter list must not leave stale entries behind + UrlOverride::setRules({{true, "ssh", "cmd://ssh {URL:HOST}"}}); + QCOMPARE(UrlOverride::getRules().size(), 1); + + // An explicitly saved empty list must stay empty (not be confused with "never configured") + UrlOverride::setRules({}); + QVERIFY(UrlOverride::getRules().isEmpty()); + + tempFile.remove(); +} + +void TestUrlOverride::testDefaultSeedRule() +{ + TemporaryFile tempFile; + tempFile.open(); + tempFile.close(); + Config::createConfigFromFile(tempFile.fileName()); + + // Fresh config: a disabled example rule is seeded so the feature is discoverable + auto rules = UrlOverride::getRules(); + QCOMPARE(rules.size(), 1); + QCOMPARE(rules.first().enabled, false); + QCOMPARE(rules.first().scheme, QString("ssh")); + QCOMPARE(rules.first().command, QString("cmd://ssh {USERNAME}@{URL:HOST}")); + + // It must never match anything on its own, since it's disabled by default + QVERIFY(UrlOverride::findCommand("ssh://example.com").isEmpty()); + + // Saving anything at all (including clearing the list) must permanently stop the reseed + UrlOverride::setRules({}); + QVERIFY(UrlOverride::getRules().isEmpty()); + + tempFile.remove(); +} + +void TestUrlOverride::testUrlOverrideSchemeNormalization() +{ + // The actual scheme-extraction grammar is tested in TestUrlTools::testNormalizeScheme(); this + // only verifies that setRules() applies it before persisting: a scheme entered as "ff://" + // must be normalized and stored as the literal scheme "ff", not a regular expression. + TemporaryFile tempFile; + tempFile.open(); + tempFile.close(); + Config::createConfigFromFile(tempFile.fileName()); + + UrlOverride::setRules({{true, "ff://", "cmd://ff-handler {URL}"}}); + QCOMPARE(UrlOverride::getRules().first().scheme, QString("ff")); + + tempFile.remove(); +} + +void TestUrlOverride::testXmlSpecialCharactersRoundTrip() +{ + TemporaryFile tempFile; + tempFile.open(); + tempFile.close(); + Config::createConfigFromFile(tempFile.fileName()); + + // Rules are stored as hand-written XML; special characters in the command must survive a + // save/load round-trip without breaking the XML structure or getting corrupted + const QString command = R"(cmd://ssh -o ProxyCommand="nc %h %p & echo 'quoted'" {USERNAME}@{URL:HOST})"; + UrlOverride::setRules({{true, "ssh", command}}); + + auto rules = UrlOverride::getRules(); + QCOMPARE(rules.size(), 1); + QCOMPARE(rules.first().command, command); + QCOMPARE(UrlOverride::findCommand("ssh://example.com"), command); + + tempFile.remove(); +} + +void TestUrlOverride::testFindUrlOverrideCommand() +{ + TemporaryFile tempFile; + tempFile.open(); + tempFile.close(); + Config::createConfigFromFile(tempFile.fileName()); + + QList rules; + // Disabled rule must be skipped even though it matches + rules.append({false, "ssh", "cmd://disabled-should-not-be-used"}); + rules.append({true, "ssh", "cmd://ssh {USERNAME}@{URL:HOST}"}); + rules.append({true, "VPN", "cmd://vpn-client --host {URL:HOST}"}); + rules.append({true, "ff", "cmd://msedge {URL:RMVSCM}"}); + UrlOverride::setRules(rules); + + // The scheme comparison is a literal, case-insensitive match (not a regular expression) + QCOMPARE(UrlOverride::findCommand("ssh://user@example.com"), QString("cmd://ssh {USERNAME}@{URL:HOST}")); + QCOMPARE(UrlOverride::findCommand("vpn://example.com"), QString("cmd://vpn-client --host {URL:HOST}")); + QCOMPARE(UrlOverride::findCommand("ff://example.com"), QString("cmd://msedge {URL:RMVSCM}")); + // "sshfs" must not match the "ssh" scheme rule + QVERIFY(UrlOverride::findCommand("sshfs://example.com").isEmpty()); + QVERIFY(UrlOverride::findCommand("https://example.com").isEmpty()); + + tempFile.remove(); +} + +void TestUrlOverride::testFindUrlOverrideCommandEdgeCases() +{ + TemporaryFile tempFile; + tempFile.open(); + tempFile.close(); + Config::createConfigFromFile(tempFile.fileName()); + + // With two enabled rules for the same scheme, the first one in the list must win + UrlOverride::setRules({{true, "ssh", "cmd://first-ssh-handler"}, {true, "ssh", "cmd://second-ssh-handler"}}); + QCOMPARE(UrlOverride::findCommand("ssh://example.com"), QString("cmd://first-ssh-handler")); + + // An enabled rule with an empty scheme must never match anything + UrlOverride::setRules({{true, "", "cmd://should-never-be-used"}}); + QVERIFY(UrlOverride::findCommand("ssh://example.com").isEmpty()); + QVERIFY(UrlOverride::findCommand("https://example.com").isEmpty()); + + // An enabled rule with an empty command has nothing to run: it must be skipped rather than + // matching and blocking a lower-priority rule for the same scheme + UrlOverride::setRules({{true, "ssh", ""}, {true, "ssh", "cmd://real-ssh-handler"}}); + QCOMPARE(UrlOverride::findCommand("ssh://example.com"), QString("cmd://real-ssh-handler")); + + // If every rule for a scheme has an empty command, there is simply nothing to run + UrlOverride::setRules({{true, "ssh", ""}}); + QVERIFY(UrlOverride::findCommand("ssh://example.com").isEmpty()); + + // A URL without a scheme at all must not match any rule, even a permissive one + UrlOverride::setRules({{true, "ssh", "cmd://should-never-be-used"}}); + QVERIFY(UrlOverride::findCommand("not-a-url-at-all").isEmpty()); + QVERIFY(UrlOverride::findCommand("").isEmpty()); + + tempFile.remove(); +} diff --git a/tests/TestUrlOverride.h b/tests/TestUrlOverride.h new file mode 100644 index 0000000000..6ba3eabe65 --- /dev/null +++ b/tests/TestUrlOverride.h @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2026 KeePassXC Team + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 2 or (at your option) + * version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef KEEPASSX_TESTURLOVERRIDE_H +#define KEEPASSX_TESTURLOVERRIDE_H + +#include + +#include "urloverride/UrlOverride.h" + +class TestUrlOverride : public QObject +{ + Q_OBJECT +private slots: + void initTestCase(); + + void testUrlOverrides(); + void testDefaultSeedRule(); + void testUrlOverrideSchemeNormalization(); + void testXmlSpecialCharactersRoundTrip(); + void testFindUrlOverrideCommand(); + void testFindUrlOverrideCommandEdgeCases(); +}; + +#endif // KEEPASSX_TESTURLOVERRIDE_H diff --git a/tests/TestUrlTools.cpp b/tests/TestUrlTools.cpp index f4ad7edb72..92d74f1b22 100644 --- a/tests/TestUrlTools.cpp +++ b/tests/TestUrlTools.cpp @@ -171,3 +171,23 @@ void TestUrlTools::testDomainHasIllegalCharacters() QVERIFY(UrlTools::domainHasIllegalCharacters("domain has spaces.com")); QVERIFY(UrlTools::domainHasIllegalCharacters("example#|.com")); } + +void TestUrlTools::testNormalizeScheme() +{ + QCOMPARE(UrlTools::normalizeScheme("ff://"), QString("ff")); + QCOMPARE(UrlTools::normalizeScheme("ff:"), QString("ff")); + QCOMPARE(UrlTools::normalizeScheme("ff"), QString("ff")); + QCOMPARE(UrlTools::normalizeScheme(" ff:// "), QString("ff")); + + // Extracted via the URI scheme grammar, not by stripping specific characters off either end: + // stray leading junk, doubled-up separators, and trailing garbage that isn't ":"/"/" all still + // resolve to just the scheme + QCOMPARE(UrlTools::normalizeScheme("/:ff:// "), QString("ff")); + QCOMPARE(UrlTools::normalizeScheme("://ff:// "), QString("ff")); + QCOMPARE(UrlTools::normalizeScheme("ff:&& "), QString("ff")); + + // No letters at all: there is no scheme to extract + QVERIFY(UrlTools::normalizeScheme("://").isEmpty()); + QVERIFY(UrlTools::normalizeScheme("123").isEmpty()); + QVERIFY(UrlTools::normalizeScheme("").isEmpty()); +} diff --git a/tests/TestUrlTools.h b/tests/TestUrlTools.h index d4b9fe016e..8582b21ebb 100644 --- a/tests/TestUrlTools.h +++ b/tests/TestUrlTools.h @@ -34,5 +34,6 @@ private slots: void testIsUrlValid(); void testIsUrlValidWithLooseComparison(); void testDomainHasIllegalCharacters(); + void testNormalizeScheme(); }; #endif // KEEPASSXC_TESTURLTOOLS_H