Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for URL wildcards and exact URL #9835

Open
wants to merge 4 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 77 additions & 7 deletions src/browser/BrowserService.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <[email protected]>
* Copyright (C) 2025 KeePassXC Team <[email protected]>
* Copyright (C) 2017 Sami Vänttinen <[email protected]>
* Copyright (C) 2013 Francois Ferrand
*
Expand Down Expand Up @@ -51,6 +51,7 @@
#include <QLocalSocket>
#include <QLocale>
#include <QProgressDialog>
#include <QStringView>
#include <QUrl>

const QString BrowserService::KEEPASSXCBROWSER_NAME = QStringLiteral("KeePassXC-Browser Settings");
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
Expand All @@ -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<Database> BrowserService::getDatabase(const QUuid& rootGroupUuid)
{
if (!rootGroupUuid.isNull()) {
Expand Down
7 changes: 5 additions & 2 deletions src/browser/BrowserService.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <[email protected]>
* Copyright (C) 2025 KeePassXC Team <[email protected]>
* Copyright (C) 2017 Sami Vänttinen <[email protected]>
* Copyright (C) 2013 Francois Ferrand
*
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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;
Expand Down
22 changes: 19 additions & 3 deletions src/core/Entry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
4 changes: 3 additions & 1 deletion src/core/Entry.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <[email protected]>
* Copyright (C) 2025 KeePassXC Team <[email protected]>
* Copyright (C) 2010 Felix Geyer <[email protected]>
*
* This program is free software: you can redistribute it and/or modify
Expand Down Expand Up @@ -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;
Expand Down
40 changes: 31 additions & 9 deletions src/gui/UrlTools.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <[email protected]>
* Copyright (C) 2025 KeePassXC Team <[email protected]>
*
* 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
Expand All @@ -24,6 +24,8 @@
#include <QRegularExpression>
#include <QUrl>

const QString UrlTools::URL_WILDCARD = "1kpxcwc1";

Q_GLOBAL_STATIC(UrlTools, s_urlTools)

UrlTools* UrlTools::instance()
Expand Down Expand Up @@ -137,36 +139,56 @@ 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;
}

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("\"") && url.length() > 2) {
if (url.contains("*")) {
// Do not allow exact URL with wildcards
return false;
}

// Verify the URL inside ""
url.remove(0, 1);
url.remove(url.length() - 1, 1);
} else if (url.contains("*")) {
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;
}

// 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;
}
Expand Down
6 changes: 4 additions & 2 deletions src/gui/UrlTools.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2024 KeePassXC Team <[email protected]>
* Copyright (C) 2025 KeePassXC Team <[email protected]>
*
* 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
Expand Down Expand Up @@ -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;

Expand Down
4 changes: 2 additions & 2 deletions src/gui/entry/EntryURLModel.cpp
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (C) 2023 KeePassXC Team <[email protected]>
* Copyright (C) 2025 KeePassXC Team <[email protected]>
* Copyright (C) 2012 Felix Geyer <[email protected]>
*
* This program is free software: you can redistribute it and/or modify
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading