From 4c12b1a4c2d25785feb271a3b2f1b61644380d87 Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Fri, 24 Jul 2026 18:51:44 +0400 Subject: [PATCH 01/11] Add KPXC_FEATURE_URLOVERRIDE build option Introduces the CMake option for an optional global URL scheme override feature, following the existing KPXC_FEATURE_BROWSER/SSHAGENT/FDOSECRETS pattern: option declaration, KPXC_MINIMAL opt-out, feature summary, and the config-keepassx.h define. No consumer yet. --- CMakeLists.txt | 3 +++ INSTALL.md | 1 + src/config-keepassx.h.cmake | 1 + 3 files changed, 5 insertions(+) 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/config-keepassx.h.cmake b/src/config-keepassx.h.cmake index 51d2403892..bd16085226 100644 --- a/src/config-keepassx.h.cmake +++ b/src/config-keepassx.h.cmake @@ -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 From ffad5e755c33d5fb8bace7b647ab02993de195af Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Fri, 24 Jul 2026 18:52:13 +0400 Subject: [PATCH 02/11] Add urloverride plugin: global URL scheme override rules New optional static library (src/urloverride, built only when KPXC_FEATURE_URLOVERRIDE is on) that maps a URL scheme (e.g. "ssh", "kdbx") to an external "cmd://" command template, plus a settings page to manage the rule table (enable, scheme, command, reordering, with a confirmation prompt before saving changes). - UrlOverride::getRules/setRules persist the rule list as an XML string under a single new Config key (UrlOverride_Rules), the same way KeeShare stores its structured settings - no other change to core/Config, and no second QSettings instance on the config file. - UrlOverride::findCommand does a literal, case-insensitive match of a rule's scheme against the URL's own scheme (not a regex), first enabled match with a non-empty command wins. - A disabled example rule ("ssh" -> "cmd://ssh {USERNAME}@{URL:HOST}") is seeded once, on first-ever use, purely for discoverability. - UrlOverride::executeCommand launches the resolved command; on Windows it detects console-subsystem targets (e.g. ssh.exe) via the PE header and forces a new console so they get a visible window, without doing so for GUI targets like a browser. --- src/CMakeLists.txt | 8 +- src/core/Config.cpp | 4 +- src/core/Config.h | 2 + src/urloverride/CMakeLists.txt | 26 +++ src/urloverride/UrlOverride.cpp | 224 ++++++++++++++++++++ src/urloverride/UrlOverride.h | 53 +++++ src/urloverride/UrlOverrideSettingsPage.cpp | 217 +++++++++++++++++++ src/urloverride/UrlOverrideSettingsPage.h | 36 ++++ 8 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 src/urloverride/CMakeLists.txt create mode 100644 src/urloverride/UrlOverride.cpp create mode 100644 src/urloverride/UrlOverride.h create mode 100644 src/urloverride/UrlOverrideSettingsPage.cpp create mode 100644 src/urloverride/UrlOverrideSettingsPage.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fa104fe0a2..b49605da74 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.cmake ${CMAKE_CURRENT_BINARY_DIR}/config-keepassx.h) configure_file(git-info.h.cmake ${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/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/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..1ba3a6f6e0 --- /dev/null +++ b/src/urloverride/UrlOverride.cpp @@ -0,0 +1,224 @@ +/* + * 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 +#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; + } + + IMAGE_DOS_HEADER dosHeader; + if (file.read(reinterpret_cast(&dosHeader), sizeof(dosHeader)) != sizeof(dosHeader) + || dosHeader.e_magic != IMAGE_DOS_SIGNATURE) { + 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; + } + + const qint64 optionalHeaderStart = file.pos(); + WORD magic; + if (file.read(reinterpret_cast(&magic), sizeof(magic)) != sizeof(magic) + || !file.seek(optionalHeaderStart)) { + return false; + } + + WORD subsystem; + if (magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) { + 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) { + 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, 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 {}; + } + + QString normalizeScheme(const QString& scheme) + { + QString normalized = scheme.trimmed(); + while (normalized.endsWith(QLatin1Char(':')) || normalized.endsWith(QLatin1Char('/'))) { + normalized.chop(1); + } + return normalized; + } + + 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(program); + 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..4331c678cc --- /dev/null +++ b/src/urloverride/UrlOverride.h @@ -0,0 +1,53 @@ +/* + * 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); + QString normalizeScheme(const QString& scheme); + + // 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..3c7e414f3f --- /dev/null +++ b/src/urloverride/UrlOverrideSettingsPage.cpp @@ -0,0 +1,217 @@ +/* + * 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 +#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 launch an external command instead of the default action when opening a " + "URL. The first enabled rule whose URL Scheme (e.g. \"http\", \"ftp\", or a custom scheme " + "such as \"kdbx\") exactly matches an entry's URL scheme is used. The command may use the " + "same placeholders as Auto-Type (e.g. {USERNAME}, {PASSWORD}, {URL:HOST}, {URL:PORT}) and " + "must start with \"cmd://\" to be executed as a command."), + this); + 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 = UrlOverride::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 From 1e0c58996299525b1d2088a6f22037a273a5dacf Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Fri, 24 Jul 2026 18:52:48 +0400 Subject: [PATCH 03/11] Wire URL scheme overrides into DatabaseWidget and Settings dialog DatabaseWidget::openUrlForEntry() now checks UrlOverride::findCommand() for the entry's URL scheme before falling back to the existing cmd://, kdbx:// and default-browser handling, and the existing cmd:// launch path now goes through UrlOverride::executeCommand() to pick up the Windows console-visibility fix. Both are guarded by KPXC_FEATURE_URLOVERRIDE with an unchanged fallback when the feature is built out. MainWindow registers the new "URL Overrides" settings page alongside the existing Shortcuts page. --- src/gui/DatabaseWidget.cpp | 22 +++++++++++++++++++--- src/gui/MainWindow.cpp | 7 +++++++ 2 files changed, 26 insertions(+), 3 deletions(-) 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( From b18deec7a6e6ede435192b308a17b6d25600f7a6 Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Fri, 24 Jul 2026 18:53:07 +0400 Subject: [PATCH 04/11] Add unit tests for the URL override plugin New guiless testurloverride target (built only when KPXC_FEATURE_URLOVERRIDE is on, like testbrowser/testsshagent), covering: - rule list round-trip through Config, including order, disabled entries, and overwriting with a shorter/empty list - the one-time disabled example rule seeded on first use - scheme normalization (not a regex: "ff://" stores as "ff") - literal case-insensitive scheme matching, first-match-wins ordering, empty scheme/command being skipped rather than blocking lower rules, and URLs with no scheme never matching - XML special characters in a command surviving the save/load round-trip --- tests/CMakeLists.txt | 5 + tests/TestUrlOverride.cpp | 190 ++++++++++++++++++++++++++++++++++++++ tests/TestUrlOverride.h | 39 ++++++++ 3 files changed, 234 insertions(+) create mode 100644 tests/TestUrlOverride.cpp create mode 100644 tests/TestUrlOverride.h diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4de25d2738..75d337410a 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..1cd81f5dc9 --- /dev/null +++ b/tests/TestUrlOverride.cpp @@ -0,0 +1,190 @@ +/* + * 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() +{ + QCOMPARE(UrlOverride::normalizeScheme("ff://"), QString("ff")); + QCOMPARE(UrlOverride::normalizeScheme("ff:"), QString("ff")); + QCOMPARE(UrlOverride::normalizeScheme("ff"), QString("ff")); + QCOMPARE(UrlOverride::normalizeScheme(" ff:// "), QString("ff")); + + TemporaryFile tempFile; + tempFile.open(); + tempFile.close(); + Config::createConfigFromFile(tempFile.fileName()); + + // The scheme is not a regular expression: a scheme entered as "ff://" must be + // normalized and stored as the literal scheme "ff" + 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 From 7076e98584da95a4d0433c4cb2320ceb6777eb45 Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Sat, 25 Jul 2026 13:12:19 +0400 Subject: [PATCH 05/11] Fix UrlOverride scheme normalization to use RFC 3986 grammar --- src/urloverride/UrlOverride.cpp | 14 +++++++++----- tests/TestUrlOverride.cpp | 12 ++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/urloverride/UrlOverride.cpp b/src/urloverride/UrlOverride.cpp index 1ba3a6f6e0..401f6530ea 100644 --- a/src/urloverride/UrlOverride.cpp +++ b/src/urloverride/UrlOverride.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include #include @@ -193,11 +194,14 @@ namespace UrlOverride QString normalizeScheme(const QString& scheme) { - QString normalized = scheme.trimmed(); - while (normalized.endsWith(QLatin1Char(':')) || normalized.endsWith(QLatin1Char('/'))) { - normalized.chop(1); - } - return normalized; + // A URI scheme is ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) per RFC 3986. Rather than + // guessing which surrounding characters to strip (e.g. a trailing-":"/"/" loop, which + // breaks on stray leading characters or on trailing junk that isn't ":" or "/"), extract + // the first run of characters matching that grammar and use it as-is - anything before or + // after it is simply not part of a 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(); } void executeCommand(const QString& program, const QStringList& arguments) diff --git a/tests/TestUrlOverride.cpp b/tests/TestUrlOverride.cpp index 1cd81f5dc9..4ce76d58aa 100644 --- a/tests/TestUrlOverride.cpp +++ b/tests/TestUrlOverride.cpp @@ -97,6 +97,18 @@ void TestUrlOverride::testUrlOverrideSchemeNormalization() QCOMPARE(UrlOverride::normalizeScheme("ff"), QString("ff")); QCOMPARE(UrlOverride::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(UrlOverride::normalizeScheme("/:ff:// "), QString("ff")); + QCOMPARE(UrlOverride::normalizeScheme("://ff:// "), QString("ff")); + QCOMPARE(UrlOverride::normalizeScheme("ff:&& "), QString("ff")); + + // No letters at all: there is no scheme to extract + QVERIFY(UrlOverride::normalizeScheme("://").isEmpty()); + QVERIFY(UrlOverride::normalizeScheme("123").isEmpty()); + QVERIFY(UrlOverride::normalizeScheme("").isEmpty()); + TemporaryFile tempFile; tempFile.open(); tempFile.close(); From 821cd0cc664e332a11994795a89fa4f1e12f584c Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Sat, 25 Jul 2026 15:37:34 +0400 Subject: [PATCH 06/11] Add UrlTools::normalizeScheme() for extracting a URI scheme from free-form input Extracts the URI scheme grammar (ALPHA *(ALPHA / DIGIT / "+" / "-" / ".") per RFC 3986) from a string instead of guessing which surrounding characters to strip. Handles messy input (stray leading characters, doubled-up separators, trailing garbage that isn't ":"/"/") that a naive trim-based approach would get wrong. --- src/gui/UrlTools.cpp | 11 +++++++++++ src/gui/UrlTools.h | 1 + tests/TestUrlTools.cpp | 20 ++++++++++++++++++++ tests/TestUrlTools.h | 1 + 4 files changed, 33 insertions(+) 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/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 From c0ccecdf6f0b97504f204cb750a9fa79d2204054 Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Sat, 25 Jul 2026 15:37:55 +0400 Subject: [PATCH 07/11] Move URL override scheme normalization to shared UrlTools UrlOverride::normalizeScheme() duplicated logic that belongs in UrlTools, a general-purpose URL utility namespace used elsewhere in the app. Drop the plugin's own copy and call UrlTools::normalizeScheme() instead; the plugin's own tests now only cover that setRules() applies it before persisting, the extraction grammar itself is covered by TestUrlTools. --- src/urloverride/UrlOverride.cpp | 16 ++------------- src/urloverride/UrlOverride.h | 1 - src/urloverride/UrlOverrideSettingsPage.cpp | 3 ++- tests/TestUrlOverride.cpp | 22 +++------------------ 4 files changed, 7 insertions(+), 35 deletions(-) diff --git a/src/urloverride/UrlOverride.cpp b/src/urloverride/UrlOverride.cpp index 401f6530ea..bea851aae4 100644 --- a/src/urloverride/UrlOverride.cpp +++ b/src/urloverride/UrlOverride.cpp @@ -18,10 +18,10 @@ #include "UrlOverride.h" #include "core/Config.h" +#include "gui/UrlTools.h" #include #include -#include #include #include #include @@ -166,7 +166,7 @@ namespace UrlOverride QList normalizedRules; normalizedRules.reserve(rules.size()); for (const auto& rule : rules) { - normalizedRules.append({rule.enabled, normalizeScheme(rule.scheme), rule.command}); + normalizedRules.append({rule.enabled, UrlTools::normalizeScheme(rule.scheme), rule.command}); } config()->set(Config::UrlOverride_Rules, serializeRules(normalizedRules)); } @@ -192,18 +192,6 @@ namespace UrlOverride return {}; } - QString normalizeScheme(const QString& scheme) - { - // A URI scheme is ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) per RFC 3986. Rather than - // guessing which surrounding characters to strip (e.g. a trailing-":"/"/" loop, which - // breaks on stray leading characters or on trailing junk that isn't ":" or "/"), extract - // the first run of characters matching that grammar and use it as-is - anything before or - // after it is simply not part of a 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(); - } - void executeCommand(const QString& program, const QStringList& arguments) { #ifdef Q_OS_WIN diff --git a/src/urloverride/UrlOverride.h b/src/urloverride/UrlOverride.h index 4331c678cc..e3c82c66a2 100644 --- a/src/urloverride/UrlOverride.h +++ b/src/urloverride/UrlOverride.h @@ -42,7 +42,6 @@ namespace UrlOverride QList getRules(); void setRules(const QList& rules); QString findCommand(const QString& url); - QString normalizeScheme(const QString& scheme); // 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 diff --git a/src/urloverride/UrlOverrideSettingsPage.cpp b/src/urloverride/UrlOverrideSettingsPage.cpp index 3c7e414f3f..b6372527dc 100644 --- a/src/urloverride/UrlOverrideSettingsPage.cpp +++ b/src/urloverride/UrlOverrideSettingsPage.cpp @@ -20,6 +20,7 @@ #include "UrlOverride.h" #include "gui/Icons.h" #include "gui/MessageBox.h" +#include "gui/UrlTools.h" #include #include @@ -131,7 +132,7 @@ class UrlOverrideSettingsWidget final : public QWidget for (int row = 0; row < m_table->rowCount(); ++row) { UrlOverride::Rule rule; rule.enabled = m_table->item(row, EnabledColumn)->checkState() == Qt::Checked; - rule.scheme = UrlOverride::normalizeScheme(m_table->item(row, SchemeColumn)->text()); + 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; diff --git a/tests/TestUrlOverride.cpp b/tests/TestUrlOverride.cpp index 4ce76d58aa..9e8c699eb9 100644 --- a/tests/TestUrlOverride.cpp +++ b/tests/TestUrlOverride.cpp @@ -92,30 +92,14 @@ void TestUrlOverride::testDefaultSeedRule() void TestUrlOverride::testUrlOverrideSchemeNormalization() { - QCOMPARE(UrlOverride::normalizeScheme("ff://"), QString("ff")); - QCOMPARE(UrlOverride::normalizeScheme("ff:"), QString("ff")); - QCOMPARE(UrlOverride::normalizeScheme("ff"), QString("ff")); - QCOMPARE(UrlOverride::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(UrlOverride::normalizeScheme("/:ff:// "), QString("ff")); - QCOMPARE(UrlOverride::normalizeScheme("://ff:// "), QString("ff")); - QCOMPARE(UrlOverride::normalizeScheme("ff:&& "), QString("ff")); - - // No letters at all: there is no scheme to extract - QVERIFY(UrlOverride::normalizeScheme("://").isEmpty()); - QVERIFY(UrlOverride::normalizeScheme("123").isEmpty()); - QVERIFY(UrlOverride::normalizeScheme("").isEmpty()); - + // 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()); - // The scheme is not a regular expression: a scheme entered as "ff://" must be - // normalized and stored as the literal scheme "ff" UrlOverride::setRules({{true, "ff://", "cmd://ff-handler {URL}"}}); QCOMPARE(UrlOverride::getRules().first().scheme, QString("ff")); From fb581b7714a41192e664a9add0ed8b2de9248819 Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Sat, 25 Jul 2026 18:28:58 +0400 Subject: [PATCH 08/11] Fix executeCommand to launch the resolved executable path isConsoleSubsystemExecutable() was checked against the PATH-resolved program, but QProcess was then started with the original, unresolved name, so the console-subsystem check and the launched binary could diverge. --- src/urloverride/UrlOverride.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/urloverride/UrlOverride.cpp b/src/urloverride/UrlOverride.cpp index bea851aae4..9771e4fe43 100644 --- a/src/urloverride/UrlOverride.cpp +++ b/src/urloverride/UrlOverride.cpp @@ -203,7 +203,7 @@ namespace UrlOverride const auto resolvedProgram = QStandardPaths::findExecutable(program); if (!resolvedProgram.isEmpty() && isConsoleSubsystemExecutable(resolvedProgram)) { QProcess process; - process.setProgram(program); + process.setProgram(resolvedProgram); process.setArguments(arguments); process.setCreateProcessArgumentsModifier( [](QProcess::CreateProcessArguments* args) { args->flags |= CREATE_NEW_CONSOLE; }); From 5b7853ee4e70681441aedd6377066f2d235ee5ab Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Sat, 25 Jul 2026 19:00:11 +0400 Subject: [PATCH 09/11] Harden PE header validation in isConsoleSubsystemExecutable Add bounds checks that reject malformed or truncated executables before trusting offsets/sizes taken from the file itself: e_lfanew is validated against the file size before seeking to it, and SizeOfOptionalHeader is checked against both the declared and the actual IMAGE_OPTIONAL_HEADER32/64 size before reading it. Prevents misreading Subsystem out of a corrupt or truncated PE. --- src/urloverride/UrlOverride.cpp | 56 ++++++++++++++++++++++++++------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/urloverride/UrlOverride.cpp b/src/urloverride/UrlOverride.cpp index 9771e4fe43..abad71e641 100644 --- a/src/urloverride/UrlOverride.cpp +++ b/src/urloverride/UrlOverride.cpp @@ -43,46 +43,80 @@ namespace return false; } - IMAGE_DOS_HEADER dosHeader; + 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; + DWORD peSignature{}; if (file.read(reinterpret_cast(&peSignature), sizeof(peSignature)) != sizeof(peSignature) || peSignature != IMAGE_NT_SIGNATURE) { return false; } - IMAGE_FILE_HEADER fileHeader; + 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) - || !file.seek(optionalHeaderStart)) { + + WORD magic{}; + if (file.read(reinterpret_cast(&magic), sizeof(magic)) != sizeof(magic)) { + return false; + } + + if (!file.seek(optionalHeaderStart)) { return false; } - WORD subsystem; + WORD subsystem = 0; + if (magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) { - IMAGE_OPTIONAL_HEADER64 optionalHeader; - if (file.read(reinterpret_cast(&optionalHeader), sizeof(optionalHeader)) != sizeof(optionalHeader)) { + 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) { - IMAGE_OPTIONAL_HEADER32 optionalHeader; - if (file.read(reinterpret_cast(&optionalHeader), sizeof(optionalHeader)) != sizeof(optionalHeader)) { + 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; From 4beb22d78c2cbafee21d8eb7cce61d5ab6ba51db Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Tue, 28 Jul 2026 14:00:55 +0400 Subject: [PATCH 10/11] Link to Entry Placeholders docs from URL override info label Add a link to the Entry Placeholders documentation section instead of listing placeholder examples inline. --- src/urloverride/UrlOverrideSettingsPage.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/urloverride/UrlOverrideSettingsPage.cpp b/src/urloverride/UrlOverrideSettingsPage.cpp index b6372527dc..4ef0ac71dc 100644 --- a/src/urloverride/UrlOverrideSettingsPage.cpp +++ b/src/urloverride/UrlOverrideSettingsPage.cpp @@ -53,12 +53,14 @@ class UrlOverrideSettingsWidget final : public QWidget auto* layout = new QVBoxLayout(this); auto* infoLabel = new QLabel( - QObject::tr("Define rules to launch an external command instead of the default action when opening a " - "URL. The first enabled rule whose URL Scheme (e.g. \"http\", \"ftp\", or a custom scheme " - "such as \"kdbx\") exactly matches an entry's URL scheme is used. The command may use the " - "same placeholders as Auto-Type (e.g. {USERNAME}, {PASSWORD}, {URL:HOST}, {URL:PORT}) and " - "must start with \"cmd://\" to be executed as a command."), + 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); From 859327e9d1f723d11c9c99169647cfe023ab0f26 Mon Sep 17 00:00:00 2001 From: Alexander Shkarlatov Date: Wed, 5 Aug 2026 09:55:53 +0400 Subject: [PATCH 11/11] Format code with clang-format --- src/urloverride/UrlOverride.cpp | 6 ++---- src/urloverride/UrlOverrideSettingsPage.cpp | 3 +-- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/urloverride/UrlOverride.cpp b/src/urloverride/UrlOverride.cpp index abad71e641..0973f22a6d 100644 --- a/src/urloverride/UrlOverride.cpp +++ b/src/urloverride/UrlOverride.cpp @@ -99,8 +99,7 @@ namespace } IMAGE_OPTIONAL_HEADER64 optionalHeader{}; - if (file.read(reinterpret_cast(&optionalHeader), sizeof(optionalHeader)) - != sizeof(optionalHeader)) { + if (file.read(reinterpret_cast(&optionalHeader), sizeof(optionalHeader)) != sizeof(optionalHeader)) { return false; } @@ -112,8 +111,7 @@ namespace } IMAGE_OPTIONAL_HEADER32 optionalHeader{}; - if (file.read(reinterpret_cast(&optionalHeader), sizeof(optionalHeader)) - != sizeof(optionalHeader)) { + if (file.read(reinterpret_cast(&optionalHeader), sizeof(optionalHeader)) != sizeof(optionalHeader)) { return false; } diff --git a/src/urloverride/UrlOverrideSettingsPage.cpp b/src/urloverride/UrlOverrideSettingsPage.cpp index 4ef0ac71dc..a8ee60e56e 100644 --- a/src/urloverride/UrlOverrideSettingsPage.cpp +++ b/src/urloverride/UrlOverrideSettingsPage.cpp @@ -64,8 +64,7 @@ class UrlOverrideSettingsWidget final : public QWidget infoLabel->setWordWrap(true); layout->addWidget(infoLabel); - m_table->setHorizontalHeaderLabels( - {QObject::tr("Enabled"), QObject::tr("URL Scheme"), QObject::tr("Command")}); + 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);