Add support for URL wildcards and exact URL (#9835)

* Add support for URL wildcards with Additional URL feature

* Only check TLD if wildcard is used

* Avoid using network function in no-feature build

---------

Co-authored-by: varjolintu <sami.vanttinen@ahmala.org>
Co-authored-by: Jonathan White <support@dmapps.us>
This commit is contained in:
Jonathan White 2025-02-10 19:32:27 -05:00
parent 3b2f54daff
commit 3af68b1d3f
No known key found for this signature in database
GPG Key ID: 440FC65F2E0C6E01
11 changed files with 316 additions and 36 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
* Copyright (C) 2013 Francois Ferrand
*
@ -50,6 +50,7 @@
#include <QListWidget>
#include <QLocalSocket>
#include <QProgressDialog>
#include <QStringView>
#include <QUrl>
const QString BrowserService::KEEPASSXCBROWSER_NAME = QStringLiteral("KeePassXC-Browser Settings");
@ -1427,9 +1428,15 @@ bool BrowserService::shouldIncludeEntry(Entry* entry,
return url.endsWith("by-path/" + entry->path());
}
const auto allEntryUrls = entry->getAllUrls();
for (const auto& entryUrl : allEntryUrls) {
if (handleURL(entryUrl, url, submitUrl, omitWwwSubdomain)) {
// Handle the entry URL
if (handleURL(entry->resolveUrl(), url, submitUrl, omitWwwSubdomain)) {
return true;
}
// Handle additional URLs
const auto additionalUrls = entry->getAdditionalUrls();
for (const auto& additionalUrl : additionalUrls) {
if (handleURL(additionalUrl, url, submitUrl, omitWwwSubdomain, true)) {
return true;
}
}
@ -1517,17 +1524,35 @@ QJsonObject BrowserService::getPasskeyError(int errorCode) const
bool BrowserService::handleURL(const QString& entryUrl,
const QString& siteUrl,
const QString& formUrl,
const bool omitWwwSubdomain)
const bool omitWwwSubdomain,
const bool allowWildcards)
{
if (entryUrl.isEmpty()) {
return false;
}
bool isWildcardUrl = false;
auto tempUrl = entryUrl;
// Allows matching with exact URL and wildcards
if (allowWildcards) {
// Exact match where URL is wrapped inside " characters
if (entryUrl.startsWith("\"") && entryUrl.endsWith("\"")) {
return QStringView{entryUrl}.mid(1, entryUrl.length() - 2) == siteUrl;
}
// Replace wildcards
isWildcardUrl = entryUrl.contains("*");
if (isWildcardUrl) {
tempUrl = tempUrl.replace("*", UrlTools::URL_WILDCARD);
}
}
QUrl entryQUrl;
if (entryUrl.contains("://")) {
entryQUrl = entryUrl;
entryQUrl = tempUrl;
} else {
entryQUrl = QUrl::fromUserInput(entryUrl);
entryQUrl = QUrl::fromUserInput(tempUrl);
if (browserSettings()->matchUrlScheme()) {
entryQUrl.setScheme("https");
@ -1567,6 +1592,11 @@ bool BrowserService::handleURL(const QString& entryUrl,
return false;
}
// Use wildcard matching instead
if (isWildcardUrl) {
return handleURLWithWildcards(entryQUrl, siteUrl);
}
// Match the base domain
if (urlTools()->getBaseDomainFromUrl(siteQUrl.host()) != urlTools()->getBaseDomainFromUrl(entryQUrl.host())) {
return false;
@ -1580,6 +1610,46 @@ bool BrowserService::handleURL(const QString& entryUrl,
return false;
}
bool BrowserService::handleURLWithWildcards(const QUrl& entryQUrl, const QString& siteUrl)
{
auto matchWithRegex = [&](QString firstPart, const QString& secondPart, bool hostnameUsed = false) {
if (firstPart == secondPart) {
return true;
}
// If there's no wildcard with hostname, just compare directly
if (hostnameUsed && !firstPart.contains(UrlTools::URL_WILDCARD) && firstPart != secondPart) {
return false;
}
// Escape illegal characters
auto re = firstPart.replace(QRegularExpression(R"(([!\^\$\+\-\(\)@<>]))"), "\\\\1");
if (hostnameUsed) {
// Replace all host parts with wildcards
re = re.replace(QString("%1.").arg(UrlTools::URL_WILDCARD), "(.*?)");
}
// Append a + to the end of regex to match all paths after the last asterisk
if (re.endsWith(UrlTools::URL_WILDCARD)) {
re.append("+");
}
// Replace any remaining wildcards for paths
re = re.replace(UrlTools::URL_WILDCARD, "(.*?)");
return QRegularExpression(re).match(secondPart).hasMatch();
};
// Match hostname and path
QUrl siteQUrl = siteUrl;
if (!matchWithRegex(entryQUrl.host(), siteQUrl.host(), true)
|| !matchWithRegex(entryQUrl.path(), siteQUrl.path())) {
return false;
}
return true;
}
QSharedPointer<Database> BrowserService::getDatabase(const QUuid& rootGroupUuid)
{
if (!rootGroupUuid.isNull()) {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2017 Sami Vänttinen <sami.vanttinen@protonmail.com>
* Copyright (C) 2013 Francois Ferrand
*
@ -196,7 +196,9 @@ private:
bool handleURL(const QString& entryUrl,
const QString& siteUrl,
const QString& formUrl,
const bool omitWwwSubdomain = false);
const bool omitWwwSubdomain = false,
const bool allowWildcards = false);
bool handleURLWithWildcards(const QUrl& entryQUrl, const QString& siteUrl);
QString getDatabaseRootUuid();
QString getDatabaseRecycleBinUuid();
bool checkLegacySettings(QSharedPointer<Database> db);

View File

@ -380,16 +380,32 @@ QString Entry::url() const
return m_attributes->value(EntryAttributes::URLKey);
}
QString Entry::resolveUrl() const
{
const auto entryUrl = url();
if (entryUrl.isEmpty()) {
return {};
}
return EntryAttributes::matchReference(entryUrl).hasMatch() ? resolveMultiplePlaceholders(entryUrl) : entryUrl;
}
QStringList Entry::getAllUrls() const
{
QStringList urlList;
auto entryUrl = url();
const auto entryUrl = resolveUrl();
if (!entryUrl.isEmpty()) {
urlList << (EntryAttributes::matchReference(entryUrl).hasMatch() ? resolveMultiplePlaceholders(entryUrl)
: entryUrl);
urlList << entryUrl;
}
return urlList << getAdditionalUrls();
}
QStringList Entry::getAdditionalUrls() const
{
QStringList urlList;
for (const auto& key : m_attributes->keys()) {
if (key.startsWith(EntryAttributes::AdditionalUrlAttribute)
|| key == QString("%1_RELYING_PARTY").arg(EntryAttributes::PasskeyAttribute)) {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2010 Felix Geyer <debfx@fobos.de>
*
* This program is free software: you can redistribute it and/or modify
@ -100,7 +100,9 @@ public:
const AutoTypeAssociations* autoTypeAssociations() const;
QString title() const;
QString url() const;
QString resolveUrl() const;
QStringList getAllUrls() const;
QStringList getAdditionalUrls() const;
QString webUrl() const;
QString displayUrl() const;
QString username() const;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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
@ -24,6 +24,8 @@
#include <QRegularExpression>
#include <QUrl>
const QString UrlTools::URL_WILDCARD = "1kpxcwc1";
Q_GLOBAL_STATIC(UrlTools, s_urlTools)
UrlTools* UrlTools::instance()
@ -137,8 +139,9 @@ bool UrlTools::isUrlIdentical(const QString& first, const QString& second) const
return false;
}
const auto firstUrl = trimUrl(first);
const auto secondUrl = trimUrl(second);
// Replace URL wildcards for comparison if found
const auto firstUrl = trimUrl(QString(first).replace("*", UrlTools::URL_WILDCARD));
const auto secondUrl = trimUrl(QString(second).replace("*", UrlTools::URL_WILDCARD));
if (firstUrl == secondUrl) {
return true;
}
@ -146,27 +149,61 @@ bool UrlTools::isUrlIdentical(const QString& first, const QString& second) const
return QUrl(firstUrl).matches(QUrl(secondUrl), QUrl::StripTrailingSlash);
}
bool UrlTools::isUrlValid(const QString& urlField) const
bool UrlTools::isUrlValid(const QString& urlField, bool looseComparison) const
{
if (urlField.isEmpty() || urlField.startsWith("cmd://", Qt::CaseInsensitive)
|| urlField.startsWith("kdbx://", Qt::CaseInsensitive) || urlField.startsWith("{REF:A", Qt::CaseInsensitive)) {
return true;
}
QUrl url;
if (urlField.contains("://")) {
url = urlField;
} else {
url = QUrl::fromUserInput(urlField);
auto url = urlField;
// Loose comparison that allows wildcards and exact URL inside " characters
if (looseComparison) {
// Exact URL
if (url.startsWith("\"") && url.endsWith("\"")) {
// Do not allow exact URL with wildcards, or empty exact URL
if (url.contains("*") || url.length() == 2) {
return false;
}
// Get the URL inside ""
url.remove(0, 1);
url.remove(url.length() - 1, 1);
} else {
// Do not allow URL with just wildcards, or double wildcards, or no separator (.)
if (url.length() == url.count("*") || url.contains("**") || url.contains("*.*") || !url.contains(".")) {
return false;
}
url.replace("*", UrlTools::URL_WILDCARD);
}
}
if (url.scheme() != "file" && url.host().isEmpty()) {
QUrl qUrl;
if (urlField.contains("://")) {
qUrl = url;
} else {
qUrl = QUrl::fromUserInput(url);
}
if (qUrl.scheme() != "file" && qUrl.host().isEmpty()) {
return false;
}
#if defined(WITH_XC_NETWORKING) || defined(WITH_XC_BROWSER)
// Prevent TLD wildcards
if (looseComparison && url.contains(UrlTools::URL_WILDCARD)) {
const auto tld = getTopLevelDomainFromUrl(url);
if (qUrl.host() == QString("%1.%2").arg(UrlTools::URL_WILDCARD, tld)) {
return false;
}
}
#endif
// Check for illegal characters. Adds also the wildcard * to the list
QRegularExpression re("[<>\\^`{|}\\*]");
auto match = re.match(urlField);
auto match = re.match(url);
if (match.hasMatch()) {
return false;
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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
@ -39,9 +39,11 @@ public:
bool isIpAddress(const QString& host) const;
#endif
bool isUrlIdentical(const QString& first, const QString& second) const;
bool isUrlValid(const QString& urlField) const;
bool isUrlValid(const QString& urlField, bool looseComparison = false) const;
bool domainHasIllegalCharacters(const QString& domain) const;
static const QString URL_WILDCARD;
private:
QUrl convertVariantToUrl(const QVariant& var) const;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2012 Felix Geyer <debfx@fobos.de>
*
* This program is free software: you can redistribute it and/or modify
@ -67,7 +67,7 @@ QVariant EntryURLModel::data(const QModelIndex& index, int role) const
}
const auto value = m_entryAttributes->value(key);
const auto urlValid = urlTools()->isUrlValid(value);
const auto urlValid = urlTools()->isUrlValid(value, true);
// Check for duplicate URLs in the attribute list. Excludes the current key/value from the comparison.
auto customAttributeKeys = m_entryAttributes->customKeys().filter(EntryAttributes::AdditionalUrlAttribute);

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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
@ -222,7 +222,7 @@ void TestBrowser::testSearchEntries()
QCOMPARE(result[4]->url(), QString("http://github.com"));
QCOMPARE(result[5]->url(), QString("http://github.com/login"));
// With matching there should be only 3 results + 4 without a scheme
// With matching there should be only 4 results + 4 without a scheme
browserSettings()->setMatchUrlScheme(true);
result = m_browserService->searchEntries(db, "https://github.com", "https://github.com/session");
QCOMPARE(result.length(), 7);
@ -396,6 +396,121 @@ void TestBrowser::testSearchEntriesWithAdditionalURLs()
QCOMPARE(additionalResult[0]->url(), QString("https://github.com/"));
}
void TestBrowser::testSearchEntriesWithWildcardURLs()
{
auto db = QSharedPointer<Database>::create();
auto* root = db->rootGroup();
QStringList urls = {
"https://github.com/login_page/*",
"https://github.com/*/second",
"https://github.com/*",
"http://github.com/*",
"github.com/*", // Defaults to https
"https://*.github.com/*",
"https://subdomain.*.github.com/*/second",
"https://*.sub.github.com/*",
"https://********", // Invalid wildcard URL
"https://subdomain.yes.github.com/*",
"https://example.com:8448/*",
"https://example.com/*/*",
"https://example.com/$/*",
"https://127.128.129.*:8448/",
"https://127.128.*/",
"https://127.160.*.2/login",
"http://[2001:db8:85a3:8d3:1319:8a2e:370:*]/",
"https://[2001:db8:85a3:8d3:*]:443/",
"fe80::1ff:fe23:4567:890a",
"2001-db8-85a3-8d3-1319-8a2e-370-7348.ipv6-literal.net",
"\"https://thisisatest.com/login.php\"" // Exact URL
};
createEntries(urls, root, true);
browserSettings()->setMatchUrlScheme(false);
// Return first Additional URL
auto firstUrl = [&](Entry* entry) { return entry->attributes()->value(EntryAttributes::AdditionalUrlAttribute); };
auto result = m_browserService->searchEntries(
db, "https://github.com/login_page/second", "https://github.com/login_page/second");
QCOMPARE(result.length(), 6);
QCOMPARE(firstUrl(result[0]), QString("https://github.com/login_page/*"));
QCOMPARE(firstUrl(result[1]), QString("https://github.com/*/second"));
QCOMPARE(firstUrl(result[2]), QString("https://github.com/*"));
QCOMPARE(firstUrl(result[3]), QString("http://github.com/*"));
QCOMPARE(firstUrl(result[4]), QString("github.com/*"));
QCOMPARE(firstUrl(result[5]), QString("https://*.github.com/*"));
result = m_browserService->searchEntries(
db, "https://subdomain.sub.github.com/login_page/second", "https://subdomain.sub.github.com/login_page/second");
QCOMPARE(result.length(), 3);
QCOMPARE(firstUrl(result[0]), QString("https://*.github.com/*"));
QCOMPARE(firstUrl(result[1]), QString("https://subdomain.*.github.com/*/second"));
QCOMPARE(firstUrl(result[2]), QString("https://*.sub.github.com/*"));
result = m_browserService->searchEntries(
db, "https://subdomain.sub.github.com/other_page", "https://subdomain.sub.github.com/other_page");
QCOMPARE(result.length(), 2);
QCOMPARE(firstUrl(result[0]), QString("https://*.github.com/*"));
QCOMPARE(firstUrl(result[1]), QString("https://*.sub.github.com/*"));
result = m_browserService->searchEntries(
db, "https://subdomain.yes.github.com/other_page/second", "https://subdomain.yes.github.com/other_page/second");
QCOMPARE(result.length(), 3);
QCOMPARE(firstUrl(result[0]), QString("https://*.github.com/*"));
QCOMPARE(firstUrl(result[1]), QString("https://subdomain.*.github.com/*/second"));
QCOMPARE(firstUrl(result[2]), QString("https://subdomain.yes.github.com/*"));
result = m_browserService->searchEntries(
db, "https://example.com:8448/login/page", "https://example.com:8448/login/page");
QCOMPARE(result.length(), 2);
QCOMPARE(firstUrl(result[0]), QString("https://example.com:8448/*"));
QCOMPARE(firstUrl(result[1]), QString("https://example.com/*/*"));
result = m_browserService->searchEntries(
db, "https://example.com:8449/login/page", "https://example.com:8449/login/page");
QCOMPARE(result.length(), 1);
QCOMPARE(firstUrl(result[0]), QString("https://example.com/*/*"));
result =
m_browserService->searchEntries(db, "https://example.com/$/login_page", "https://example.com/$/login_page");
QCOMPARE(result.length(), 2);
QCOMPARE(firstUrl(result[0]), QString("https://example.com/*/*"));
QCOMPARE(firstUrl(result[1]), QString("https://example.com/$/*"));
result = m_browserService->searchEntries(db, "https://127.128.129.130:8448/", "https://127.128.129.130:8448/");
QCOMPARE(result.length(), 2);
result = m_browserService->searchEntries(db, "https://127.128.129.130/", "https://127.128.129.130/");
QCOMPARE(result.length(), 1);
QCOMPARE(firstUrl(result[0]), QString("https://127.128.*/"));
result = m_browserService->searchEntries(db, "https://127.1.129.130/", "https://127.1.129.130/");
QCOMPARE(result.length(), 0);
result = m_browserService->searchEntries(db, "https://127.160.8.2/login", "https://127.160.8.2/login");
QCOMPARE(result.length(), 1);
QCOMPARE(firstUrl(result[0]), QString("https://127.160.*.2/login"));
// Exact URL
result =
m_browserService->searchEntries(db, "https://thisisatest.com/login.php", "https://thisisatest.com/login.php");
QCOMPARE(result.length(), 1);
QCOMPARE(firstUrl(result[0]), QString("\"https://thisisatest.com/login.php\""));
// With scheme matching enabled
browserSettings()->setMatchUrlScheme(true);
result = m_browserService->searchEntries(
db, "https://github.com/login_page/second", "https://github.com/login_page/second");
QCOMPARE(result.length(), 5);
QCOMPARE(firstUrl(result[0]), QString("https://github.com/login_page/*"));
QCOMPARE(firstUrl(result[1]), QString("https://github.com/*/second"));
QCOMPARE(firstUrl(result[2]), QString("https://github.com/*"));
QCOMPARE(firstUrl(result[3]), QString("github.com/*")); // Defaults to https
QCOMPARE(firstUrl(result[4]), QString("https://*.github.com/*"));
}
void TestBrowser::testInvalidEntries()
{
auto db = QSharedPointer<Database>::create();
@ -516,14 +631,18 @@ void TestBrowser::testSubdomainsAndPaths()
QCOMPARE(result.length(), 1);
}
QList<Entry*> TestBrowser::createEntries(QStringList& urls, Group* root) const
QList<Entry*> TestBrowser::createEntries(QStringList& urls, Group* root, bool additionalUrl) const
{
QList<Entry*> entries;
for (int i = 0; i < urls.length(); ++i) {
auto entry = new Entry();
entry->setGroup(root);
entry->beginUpdate();
entry->setUrl(urls[i]);
if (additionalUrl) {
entry->attributes()->set(EntryAttributes::AdditionalUrlAttribute, urls[i]);
} else {
entry->setUrl(urls[i]);
}
entry->setUsername(QString("User %1").arg(i));
entry->setUuid(QUuid::createUuid());
entry->setTitle(QString("Name_%1").arg(entry->uuidToHex()));

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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
@ -45,13 +45,14 @@ private slots:
void testSearchEntriesByReference();
void testSearchEntriesWithPort();
void testSearchEntriesWithAdditionalURLs();
void testSearchEntriesWithWildcardURLs();
void testInvalidEntries();
void testSubdomainsAndPaths();
void testBestMatchingCredentials();
void testBestMatchingWithAdditionalURLs();
private:
QList<Entry*> createEntries(QStringList& urls, Group* root) const;
QList<Entry*> createEntries(QStringList& urls, Group* root, bool additionalUrl = false) const;
void compareEntriesByPath(QSharedPointer<Database> db, QList<Entry*> entries, QString path);
QScopedPointer<BrowserAction> m_browserAction;

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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
@ -136,6 +136,36 @@ void TestUrlTools::testIsUrlValid()
}
}
void TestUrlTools::testIsUrlValidWithLooseComparison()
{
QHash<QString, bool> urls;
urls[""] = true;
urls["\"https://github.com/login\""] = true;
urls["https://*.github.com/"] = true;
urls["*.github.com"] = true;
urls["https://*.com"] = false;
urls["https://*.computer.com"] = true; // TLD in domain (com) should not affect
urls["\"\""] = false;
urls["\"*.example.com\""] = false;
urls["http://*"] = false;
urls["*"] = false;
urls["****"] = false;
urls["*.co.jp"] = false;
urls["*.com"] = false;
urls["*.computer.com"] = true;
urls["*.computer.com/*com"] = true; // TLD in path should not affect this
urls["*com"] = false;
urls["*.com/"] = false;
urls["*.com/*"] = false;
urls["**.com/**"] = false;
QHashIterator<QString, bool> i(urls);
while (i.hasNext()) {
i.next();
QCOMPARE(urlTools()->isUrlValid(i.key(), true), i.value());
}
}
void TestUrlTools::testDomainHasIllegalCharacters()
{
QVERIFY(!urlTools()->domainHasIllegalCharacters("example.com"));

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <team@keepassxc.org>
* Copyright (C) 2025 KeePassXC Team <team@keepassxc.org>
*
* 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
@ -34,6 +34,7 @@ private slots:
void testIsIpAddress();
void testIsUrlIdentical();
void testIsUrlValid();
void testIsUrlValidWithLooseComparison();
void testDomainHasIllegalCharacters();
private: