diff --git a/share/translations/keepassxc_en.ts b/share/translations/keepassxc_en.ts
index 6e5fafb47b..f7fcaf58bf 100644
--- a/share/translations/keepassxc_en.ts
+++ b/share/translations/keepassxc_en.ts
@@ -668,6 +668,10 @@
Show autocomplete suggestions for username and additional attributes
+
+ URL Overrides
+
+ ApplicationSettingsWidgetSecurity
@@ -10669,6 +10673,51 @@ Example: JBSWY3DPEHPK3PXP
+
+ UrlOverrideWidget
+
+ Add
+
+
+
+ Remove
+
+
+
+ Move Up
+
+
+
+ Move Down
+
+
+
+ Enabled
+
+
+
+ URL Scheme
+
+
+
+ Command
+
+
+
+ Confirm URL Scheme Overrides
+
+
+
+ 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.
+
+Do you want to save these changes?
+
+
+
+ Run a command instead of opening URLs with a matching scheme. The first enabled match is used.<br>Commands must start with <code>cmd://</code> and can use <a href="https://keepassxc.org/docs/KeePassXC_UserGuide#_entry_placeholders">Entry Placeholders</a>.
+
+
+WelcomeWidget
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 07ed318eef..e9118e9db4 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -144,6 +144,8 @@ set(gui_SOURCES
gui/TotpExportSettingsDialog.cpp
gui/DatabaseOpenDialog.cpp
gui/URLEdit.cpp
+ gui/UrlOverride.cpp
+ gui/UrlOverrideWidget.cpp
gui/UrlTools.cpp
gui/WelcomeWidget.cpp
gui/csvImport/CsvImportWidget.cpp
diff --git a/src/core/Config.cpp b/src/core/Config.cpp
index 209113bd63..cb6fa18062 100644
--- a/src/core/Config.cpp
+++ b/src/core/Config.cpp
@@ -238,7 +238,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 fe59ad5fc4..8d19f28557 100644
--- a/src/core/Config.h
+++ b/src/core/Config.h
@@ -213,6 +213,8 @@ class Config : public QObject
Messages_NoLegacyKeyFileWarning,
Messages_HidePreReleaseWarning,
+ UrlOverride_Rules,
+
// Special internal value
Deleted
};
diff --git a/src/gui/ApplicationSettingsWidget.cpp b/src/gui/ApplicationSettingsWidget.cpp
index 7f8402a562..42ff088555 100644
--- a/src/gui/ApplicationSettingsWidget.cpp
+++ b/src/gui/ApplicationSettingsWidget.cpp
@@ -463,6 +463,8 @@ void ApplicationSettingsWidget::loadSettings()
hideWindowOnCopyCheckBoxToggled(m_generalUi->hideWindowOnCopyCheckBox->isChecked());
showExpiredEntriesOnDatabaseUnlockToggled(m_generalUi->showExpiredEntriesOnDatabaseUnlockCheckBox->isChecked());
+ m_generalUi->urlOverrideWidget->loadSettings();
+
for (const ExtraPage& page : asConst(m_extraPages)) {
page.loadSettings();
}
@@ -566,6 +568,8 @@ void ApplicationSettingsWidget::saveSettings()
config()->remove(Config::LastChallengeResponse);
}
+ m_generalUi->urlOverrideWidget->saveSettings();
+
// Save settings for additional pages
for (const ExtraPage& page : asConst(m_extraPages)) {
page.saveSettings();
diff --git a/src/gui/ApplicationSettingsWidgetGeneral.ui b/src/gui/ApplicationSettingsWidgetGeneral.ui
index 302ba0a2b2..c253b24bbf 100644
--- a/src/gui/ApplicationSettingsWidgetGeneral.ui
+++ b/src/gui/ApplicationSettingsWidgetGeneral.ui
@@ -1580,11 +1580,38 @@
+
+
+ URL Overrides
+
+
+
+ 10
+
+
+ 10
+
+
+ 10
+
+
+ 10
+
+
+
+
+
+
+
+ UrlOverrideWidget
+ QWidget
+ gui/UrlOverrideWidget.h
+ ShortcutWidgetQLineEdit
diff --git a/src/gui/DatabaseWidget.cpp b/src/gui/DatabaseWidget.cpp
index 2f5c6c708a..86210ca0c2 100644
--- a/src/gui/DatabaseWidget.cpp
+++ b/src/gui/DatabaseWidget.cpp
@@ -49,6 +49,7 @@
#include "gui/TotpDialog.h"
#include "gui/TotpExportSettingsDialog.h"
#include "gui/TotpSetupDialog.h"
+#include "gui/UrlOverride.h"
#include "gui/dbsettings/DatabaseSettingsDialog.h"
#include "gui/entry/EntryView.h"
#include "gui/group/EditGroupWidget.h"
@@ -1001,7 +1002,13 @@ void DatabaseWidget::openUrlForEntry(Entry* entry)
return;
}
- QString cmdString = entry->resolveMultiplePlaceholders(entry->url());
+ QString rawTemplate = entry->url();
+ const auto overrideCommand = UrlOverride::findCommand(rawTemplate);
+ if (!overrideCommand.isEmpty()) {
+ rawTemplate = overrideCommand;
+ }
+ 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");
@@ -1009,7 +1016,7 @@ void DatabaseWidget::openUrlForEntry(Entry* entry)
// otherwise ask user
if (!launch && cmdString.length() > 6) {
QString cmdTruncated =
- entry->resolveMultiplePlaceholders(EntryPlaceholders::maskPasswordPlaceholders(entry->url()));
+ entry->resolveMultiplePlaceholders(EntryPlaceholders::maskPasswordPlaceholders(rawTemplate));
cmdTruncated = cmdTruncated.mid(6);
if (cmdTruncated.length() > 400) {
cmdTruncated = cmdTruncated.left(400) + " […]";
@@ -1040,7 +1047,7 @@ void DatabaseWidget::openUrlForEntry(Entry* entry)
QStringList cmdList = QProcess::splitCommand(cmd);
if (!cmdList.isEmpty()) {
const QString program = cmdList.takeFirst();
- QProcess::startDetached(program, cmdList);
+ UrlOverride::executeCommand(program, cmdList);
}
if (config()->get(Config::MinimizeOnOpenUrl).toBool()) {
@@ -1050,7 +1057,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/UrlOverride.cpp b/src/gui/UrlOverride.cpp
new file mode 100644
index 0000000000..e232c926fe
--- /dev/null
+++ b/src/gui/UrlOverride.cpp
@@ -0,0 +1,252 @@
+/*
+ * 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 "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;
+ }
+ // Only cmd:// commands can be run; anything else would be opened as a regular URL
+ if (!rule.command.startsWith("cmd://")) {
+ 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/gui/UrlOverride.h b/src/gui/UrlOverride.h
new file mode 100644
index 0000000000..7e3f25492f
--- /dev/null
+++ b/src/gui/UrlOverride.h
@@ -0,0 +1,51 @@
+/*
+ * 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 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. Rules are stored in the
+// application config under Config::UrlOverride_Rules.
+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/gui/UrlOverrideWidget.cpp b/src/gui/UrlOverrideWidget.cpp
new file mode 100644
index 0000000000..41872683a9
--- /dev/null
+++ b/src/gui/UrlOverrideWidget.cpp
@@ -0,0 +1,183 @@
+/*
+ * 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 "UrlOverrideWidget.h"
+
+#include "gui/MessageBox.h"
+#include "gui/UrlTools.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+ enum Column
+ {
+ EnabledColumn,
+ SchemeColumn,
+ CommandColumn
+ };
+} // namespace
+
+UrlOverrideWidget::UrlOverrideWidget(QWidget* parent)
+ : QWidget(parent)
+ , m_table(new QTableWidget(0, 3, this))
+ , m_addButton(new QPushButton(tr("Add"), this))
+ , m_removeButton(new QPushButton(tr("Remove"), this))
+ , m_moveUpButton(new QPushButton(tr("Move Up"), this))
+ , m_moveDownButton(new QPushButton(tr("Move Down"), this))
+{
+ auto* layout = new QVBoxLayout(this);
+ layout->setContentsMargins(0, 0, 0, 0);
+
+ auto* infoLabel = new QLabel(
+ tr("Run a command instead of opening URLs with a matching scheme. The first enabled match is used. "
+ "Commands must start with cmd:// and can use "
+ ""
+ "Entry Placeholders."),
+ this);
+ infoLabel->setTextFormat(Qt::RichText);
+ infoLabel->setOpenExternalLinks(true);
+ infoLabel->setWordWrap(true);
+ layout->addWidget(infoLabel);
+
+ m_table->setObjectName("urlOverrideTable");
+ m_table->setHorizontalHeaderLabels({tr("Enabled"), tr("URL Scheme"), 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);
+
+ connect(m_addButton, &QPushButton::clicked, this, [this] { addRow(true, {}, {}); });
+ connect(m_removeButton, &QPushButton::clicked, this, &UrlOverrideWidget::removeSelectedRow);
+ connect(m_moveUpButton, &QPushButton::clicked, this, [this] { moveSelectedRow(-1); });
+ connect(m_moveDownButton, &QPushButton::clicked, this, [this] { moveSelectedRow(1); });
+}
+
+void UrlOverrideWidget::loadSettings()
+{
+ m_originalRules = UrlOverride::getRules();
+ setTableRules(m_originalRules);
+}
+
+void UrlOverrideWidget::saveSettings()
+{
+ auto rules = tableRules();
+ if (rules == m_originalRules) {
+ // Nothing changed, no need to ask for confirmation
+ return;
+ }
+
+ auto answer = MessageBox::question(
+ this,
+ tr("Confirm URL Scheme Overrides"),
+ 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
+ setTableRules(m_originalRules);
+ return;
+ }
+
+ UrlOverride::setRules(rules);
+ m_originalRules = UrlOverride::getRules();
+}
+
+QList UrlOverrideWidget::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 UrlOverrideWidget::setTableRules(const QList& rules)
+{
+ m_table->setRowCount(0);
+ for (const auto& rule : rules) {
+ addRow(rule.enabled, rule.scheme, rule.command);
+ }
+}
+
+void UrlOverrideWidget::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 UrlOverrideWidget::removeSelectedRow()
+{
+ auto row = m_table->currentRow();
+ if (row >= 0) {
+ m_table->removeRow(row);
+ }
+}
+
+void UrlOverrideWidget::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);
+}
diff --git a/src/gui/UrlOverrideWidget.h b/src/gui/UrlOverrideWidget.h
new file mode 100644
index 0000000000..3a4526be4b
--- /dev/null
+++ b/src/gui/UrlOverrideWidget.h
@@ -0,0 +1,54 @@
+/*
+ * 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 KEEPASSXC_URLOVERRIDEWIDGET_H
+#define KEEPASSXC_URLOVERRIDEWIDGET_H
+
+#include
+
+#include "gui/UrlOverride.h"
+
+class QPushButton;
+class QTableWidget;
+
+// Editor for the URL scheme override rules, shown as a tab of the general application settings
+class UrlOverrideWidget : public QWidget
+{
+ Q_OBJECT
+
+public:
+ explicit UrlOverrideWidget(QWidget* parent = nullptr);
+
+ void loadSettings();
+ void saveSettings();
+
+private:
+ QList tableRules() const;
+ void setTableRules(const QList& rules);
+ void addRow(bool enabled, const QString& scheme, const QString& command);
+ void removeSelectedRow();
+ void moveSelectedRow(int direction);
+
+ QTableWidget* const m_table;
+ QPushButton* const m_addButton;
+ QPushButton* const m_removeButton;
+ QPushButton* const m_moveUpButton;
+ QPushButton* const m_moveDownButton;
+ QList m_originalRules;
+};
+
+#endif // KEEPASSXC_URLOVERRIDEWIDGET_H
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/CMakeLists.txt b/tests/CMakeLists.txt
index 44d4cabeea..d514029705 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -160,6 +160,9 @@ add_unit_test(NAME testtools SOURCES TestTools.cpp
add_unit_test(NAME testconfig SOURCES TestConfig.cpp
LIBS testsupport ${TEST_LIBRARIES})
+add_unit_test(NAME testurloverride SOURCES TestUrlOverride.cpp
+ LIBS testsupport ${TEST_LIBRARIES})
+
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..527a404416
--- /dev/null
+++ b/tests/TestUrlOverride.cpp
@@ -0,0 +1,192 @@
+/*
+ * 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"));
+
+ // A command without the cmd:// prefix cannot be run and must be skipped like an empty one
+ UrlOverride::setRules({{true, "ssh", "ssh {USERNAME}@{URL:HOST}"}, {true, "ssh", "cmd://real-ssh-handler"}});
+ QCOMPARE(UrlOverride::findCommand("ssh://example.com"), QString("cmd://real-ssh-handler"));
+ UrlOverride::setRules({{true, "ssh", "ssh {USERNAME}@{URL:HOST}"}});
+ QVERIFY(UrlOverride::findCommand("ssh://example.com").isEmpty());
+
+ // 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..512d4f608d
--- /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 "gui/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