diff --git a/src/browser/BrowserService.cpp b/src/browser/BrowserService.cpp index 1854f26894..36b2c1083b 100644 --- a/src/browser/BrowserService.cpp +++ b/src/browser/BrowserService.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2025 KeePassXC Team * Copyright (C) 2017 Sami Vänttinen * Copyright (C) 2013 Francois Ferrand * @@ -51,6 +51,7 @@ #include #include #include +#include #include const QString BrowserService::KEEPASSXCBROWSER_NAME = QStringLiteral("KeePassXC-Browser Settings"); @@ -1375,9 +1376,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; } } @@ -1465,17 +1472,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"); @@ -1515,6 +1540,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; @@ -1528,6 +1558,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 BrowserService::getDatabase(const QUuid& rootGroupUuid) { if (!rootGroupUuid.isNull()) { diff --git a/src/browser/BrowserService.h b/src/browser/BrowserService.h index a77c945305..c59f9303d6 100644 --- a/src/browser/BrowserService.h +++ b/src/browser/BrowserService.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2025 KeePassXC Team * Copyright (C) 2017 Sami Vänttinen * Copyright (C) 2013 Francois Ferrand * @@ -132,6 +132,7 @@ class BrowserService : public QObject static const QString OPTION_ONLY_HTTP_AUTH; static const QString OPTION_NOT_HTTP_AUTH; static const QString OPTION_OMIT_WWW; + static const QString ADDITIONAL_URL; static const QString OPTION_RESTRICT_KEY; signals: @@ -199,7 +200,9 @@ private slots: 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(); void hideWindow() const; diff --git a/src/core/Entry.cpp b/src/core/Entry.cpp index 1acf663818..0147ec0f95 100644 --- a/src/core/Entry.cpp +++ b/src/core/Entry.cpp @@ -381,16 +381,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)) { diff --git a/src/core/Entry.h b/src/core/Entry.h index 3fb3fbcbbc..3c9a0f3dea 100644 --- a/src/core/Entry.h +++ b/src/core/Entry.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2025 KeePassXC Team * Copyright (C) 2010 Felix Geyer * * This program is free software: you can redistribute it and/or modify @@ -100,7 +100,9 @@ class Entry : public ModifiableObject 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; diff --git a/src/gui/UrlTools.cpp b/src/gui/UrlTools.cpp index 508bbefdaa..bd57ba8e0c 100644 --- a/src/gui/UrlTools.cpp +++ b/src/gui/UrlTools.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 KeePassXC Team + * Copyright (C) 2025 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 @@ -24,6 +24,8 @@ #include #include +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; + 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); + } + } + + QUrl qUrl; if (urlField.contains("://")) { - url = urlField; + qUrl = url; } else { - url = QUrl::fromUserInput(urlField); + qUrl = QUrl::fromUserInput(url); } - if (url.scheme() != "file" && url.host().isEmpty()) { + 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; } diff --git a/src/gui/UrlTools.h b/src/gui/UrlTools.h index 3f91696b45..5cadb45d8c 100644 --- a/src/gui/UrlTools.h +++ b/src/gui/UrlTools.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2025 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 @@ -41,9 +41,11 @@ class UrlTools : public QObject 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; diff --git a/src/gui/entry/EntryURLModel.cpp b/src/gui/entry/EntryURLModel.cpp index b43343026d..d0201562b8 100644 --- a/src/gui/entry/EntryURLModel.cpp +++ b/src/gui/entry/EntryURLModel.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 KeePassXC Team + * Copyright (C) 2025 KeePassXC Team * Copyright (C) 2012 Felix Geyer * * 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); diff --git a/tests/TestBrowser.cpp b/tests/TestBrowser.cpp index 187a101922..f13c13c42a 100644 --- a/tests/TestBrowser.cpp +++ b/tests/TestBrowser.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 KeePassXC Team + * Copyright (C) 2025 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 @@ -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::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::create(); @@ -516,14 +631,18 @@ void TestBrowser::testSubdomainsAndPaths() QCOMPARE(result.length(), 1); } -QList TestBrowser::createEntries(QStringList& urls, Group* root) const +QList TestBrowser::createEntries(QStringList& urls, Group* root, bool additionalUrl) const { QList 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())); diff --git a/tests/TestBrowser.h b/tests/TestBrowser.h index 6b53a577d9..6a99e085dc 100644 --- a/tests/TestBrowser.h +++ b/tests/TestBrowser.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2023 KeePassXC Team + * Copyright (C) 2025 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 @@ -45,6 +45,7 @@ private slots: void testSearchEntriesByReference(); void testSearchEntriesWithPort(); void testSearchEntriesWithAdditionalURLs(); + void testSearchEntriesWithWildcardURLs(); void testInvalidEntries(); void testSubdomainsAndPaths(); void testBestMatchingCredentials(); @@ -52,7 +53,7 @@ private slots: void testRestrictBrowserKey(); private: - QList createEntries(QStringList& urls, Group* root) const; + QList createEntries(QStringList& urls, Group* root, bool additionalUrl = false) const; void compareEntriesByPath(QSharedPointer db, QList entries, QString path); QScopedPointer m_browserAction; diff --git a/tests/TestUrlTools.cpp b/tests/TestUrlTools.cpp index bc6f3546b1..49aae559da 100644 --- a/tests/TestUrlTools.cpp +++ b/tests/TestUrlTools.cpp @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2025 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 @@ -136,6 +136,36 @@ void TestUrlTools::testIsUrlValid() } } +void TestUrlTools::testIsUrlValidWithLooseComparison() +{ + QHash 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 i(urls); + while (i.hasNext()) { + i.next(); + QCOMPARE(urlTools()->isUrlValid(i.key(), true), i.value()); + } +} + void TestUrlTools::testDomainHasIllegalCharacters() { QVERIFY(!urlTools()->domainHasIllegalCharacters("example.com")); diff --git a/tests/TestUrlTools.h b/tests/TestUrlTools.h index dea7ca3954..c2ba770b88 100644 --- a/tests/TestUrlTools.h +++ b/tests/TestUrlTools.h @@ -1,5 +1,5 @@ /* - * Copyright (C) 2024 KeePassXC Team + * Copyright (C) 2025 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 @@ -34,6 +34,7 @@ private slots: void testIsIpAddress(); void testIsUrlIdentical(); void testIsUrlValid(); + void testIsUrlValidWithLooseComparison(); void testDomainHasIllegalCharacters(); private: