diff --git a/client/core/utils/constants/protocolConstants.h b/client/core/utils/constants/protocolConstants.h index 5c65d881e..b4f57e218 100644 --- a/client/core/utils/constants/protocolConstants.h +++ b/client/core/utils/constants/protocolConstants.h @@ -271,6 +271,7 @@ namespace amnezia constexpr char workersModeAuto[] = "auto"; constexpr char workersModeManual[] = "manual"; constexpr int maxWorkers = 32; + constexpr int botTagHexLength = 32; } } // namespace protocols diff --git a/client/ui/models/services/telemtConfigModel.cpp b/client/ui/models/services/telemtConfigModel.cpp index 6a3fd9eb1..c54cd3092 100644 --- a/client/ui/models/services/telemtConfigModel.cpp +++ b/client/ui/models/services/telemtConfigModel.cpp @@ -1,7 +1,13 @@ #include "telemtConfigModel.h" -#include +#include "ui/models/utils/mtproxy_public_host_input.h" +#include +#include +#include +#include + +#include "core/utils/networkUtilities.h" #include "core/utils/qrCodeUtils.h" #include "core/utils/constants/configKeys.h" #include "core/utils/constants/protocolConstants.h" @@ -9,7 +15,9 @@ using namespace amnezia; -TelemtConfigModel::TelemtConfigModel(QObject *parent) : QAbstractListModel(parent) {} +TelemtConfigModel::TelemtConfigModel(QObject *parent) : QAbstractListModel(parent) { + qmlRegisterType("TelemtConfig", 1, 0, "PublicHostInputValidator"); +} void TelemtConfigModel::applyDefaults(TelemtProtocolConfig &c) { if (c.port.isEmpty()) { @@ -49,7 +57,11 @@ bool TelemtConfigModel::setData(const QModelIndex &index, const QVariant &value, break; } case Roles::TagRole: { - m_protocolConfig.tag = value.toString(); + const QString tag = sanitizeMtProxyTagFieldText(value.toString()); + if (!isValidMtProxyTag(tag)) { + return false; + } + m_protocolConfig.tag = tag; break; } case Roles::IsEnabledRole: { @@ -57,7 +69,11 @@ bool TelemtConfigModel::setData(const QModelIndex &index, const QVariant &value, break; } case Roles::PublicHostRole: { - m_protocolConfig.publicHost = value.toString(); + const QString h = value.toString().trimmed(); + if (!isValidPublicHost(h)) { + return false; + } + m_protocolConfig.publicHost = h; break; } case Roles::TransportModeRole: { @@ -65,7 +81,11 @@ bool TelemtConfigModel::setData(const QModelIndex &index, const QVariant &value, break; } case Roles::TlsDomainRole: { - m_protocolConfig.tlsDomain = value.toString(); + const QString d = value.toString().trimmed(); + if (!isValidFakeTlsDomain(d)) { + return false; + } + m_protocolConfig.tlsDomain = d; break; } case Roles::AdditionalSecretsRole: { @@ -85,11 +105,19 @@ bool TelemtConfigModel::setData(const QModelIndex &index, const QVariant &value, break; } case Roles::NatInternalIpRole: { - m_protocolConfig.natInternalIp = value.toString(); + const QString ip = value.toString().trimmed(); + if (!isValidOptionalIpv4(ip)) { + return false; + } + m_protocolConfig.natInternalIp = ip; break; } case Roles::NatExternalIpRole: { - m_protocolConfig.natExternalIp = value.toString(); + const QString ip = value.toString().trimmed(); + if (!isValidOptionalIpv4(ip)) { + return false; + } + m_protocolConfig.natExternalIp = ip; break; } case Roles::MaskEnabledRole: { @@ -379,6 +407,293 @@ QString TelemtConfigModel::workersModeManual() const { return QString::fromUtf8(protocols::telemt::workersModeManual); } +bool TelemtConfigModel::isValidPublicHost(const QString &host) const { + const QString t = host.trimmed(); + if (t.isEmpty()) { + return true; + } + if (t.length() > 253) { + return false; + } + QHostAddress a(t); + if (a.protocol() == QHostAddress::IPv4Protocol) { + return NetworkUtilities::checkIPv4Format(t); + } + if (a.protocol() == QHostAddress::IPv6Protocol) { + // Reject unusable special addresses such as "::" (any), loopback and null. + if (a.isNull() || a.isLoopback() || a == QHostAddress(QHostAddress::AnyIPv6)) { + return false; + } + return true; + } + static const QRegularExpression onlyAsciiDigits(QStringLiteral(R"(^\d+$)")); + if (onlyAsciiDigits.match(t).hasMatch()) { + return false; + } + return NetworkUtilities::domainRegExp().exactMatch(t); +} + +bool TelemtConfigModel::isPublicHostInputAllowed(const QString &text) const { + return mtproxyPublicHostInputAllowed(text); +} + +bool TelemtConfigModel::isPublicHostTypingIncomplete(const QString &text) const { + const QString t = text.trimmed(); + if (isValidPublicHost(t)) { + return false; + } + + static const QRegularExpression onlyDigitDot(QStringLiteral(R"(^[0-9.]+$)")); + if (onlyDigitDot.match(t).hasMatch()) { + if (t.endsWith(QLatin1Char('.'))) { + return true; + } + const QStringList parts = t.split(QLatin1Char('.'), Qt::KeepEmptyParts); + if (parts.size() < 4) { + return true; + } + for (const QString &part: parts) { + if (part.isEmpty()) { + return true; + } + } + return false; + } + + if (t.contains(QLatin1Char(':'))) { + if (t.contains(QLatin1String(":::"))) { + return false; + } + if (t.endsWith(QLatin1Char(':'))) { + return true; + } + QHostAddress a(t); + if (a.protocol() == QHostAddress::IPv6Protocol) { + return false; + } + if (!t.contains(QLatin1String("::")) && t.count(QLatin1Char(':')) < 7 && !t.contains(QLatin1Char('.'))) { + return true; + } + return false; + } + + if (!t.contains(QLatin1Char('.'))) { + return true; + } + + return false; +} + +bool TelemtConfigModel::isValidMtProxyTag(const QString &tag) const { + if (tag.isEmpty()) { + return true; + } + static const QRegularExpression re( + QStringLiteral("^([0-9a-fA-F]{%1})$").arg(protocols::telemt::botTagHexLength)); + return re.match(tag).hasMatch(); +} + +bool TelemtConfigModel::isMtProxyTagTypingIncomplete(const QString &text) const { + const QString t = text.trimmed(); + if (t.isEmpty()) { + return true; + } + static const QRegularExpression hexOnly(QStringLiteral(R"(^[0-9a-fA-F]*$)")); + if (!hexOnly.match(t).hasMatch()) { + return false; + } + return t.size() < protocols::telemt::botTagHexLength; +} + +int TelemtConfigModel::mtProxyBotTagHexLength() const { + return protocols::telemt::botTagHexLength; +} + +bool TelemtConfigModel::isValidFakeTlsDomain(const QString &domain) const { + const QString t = domain.trimmed(); + if (t.isEmpty()) { + return true; + } + if (t.length() > 253) { + return false; + } + QHostAddress addr; + if (addr.setAddress(t)) { + return false; + } + static const QRegularExpression onlyAsciiDigits(QStringLiteral(R"(^\d+$)")); + if (onlyAsciiDigits.match(t).hasMatch()) { + return false; + } + QRegExp re(NetworkUtilities::domainRegExp()); + re.setCaseSensitivity(Qt::CaseInsensitive); + if (!re.exactMatch(t)) { + return false; + } + // ee + 32 hex (base secret) + hex(UTF-8 domain); keep headroom under typical client limits. + if (t.toUtf8().size() > 111) { + return false; + } + return true; +} + +QString TelemtConfigModel::normalizeFakeTlsDomainInput(const QString &input) const { + QString t = input.trimmed(); + if (t.startsWith(QLatin1String("https://"), Qt::CaseInsensitive)) { + t = t.mid(8); + } else if (t.startsWith(QLatin1String("http://"), Qt::CaseInsensitive)) { + t = t.mid(7); + } + if (const int slash = t.indexOf(QLatin1Char('/')); slash >= 0) { + t = t.left(slash); + } + if (const int at = t.indexOf(QLatin1Char('@')); at >= 0) { + t = t.mid(at + 1); + } + if (const int colon = t.indexOf(QLatin1Char(':')); colon >= 0) { + t = t.left(colon); + } + if (t.startsWith(QLatin1String("www."), Qt::CaseInsensitive)) { + const QString rest = t.mid(4); + if (rest.contains(QLatin1Char('.'))) { + t = rest; + } + } + return t.trimmed(); +} + +bool TelemtConfigModel::isFakeTlsDomainTypingIncomplete(const QString &text) const { + const QString t = text.trimmed(); + if (t.isEmpty()) { + return true; + } + if (isValidFakeTlsDomain(t)) { + return false; + } + if (t.contains(QLatin1Char('/')) || t.contains(QLatin1Char(':')) || t.contains(QLatin1Char('@')) + || t.contains(QLatin1Char(' '))) { + return false; + } + if (t.contains(QLatin1String(".."))) { + return false; + } + if (!t.contains(QLatin1Char('.'))) { + return true; + } + if (t.endsWith(QLatin1Char('.'))) { + return true; + } + static const QRegularExpression legalPartial(QStringLiteral(R"(^[a-zA-Z0-9.-]*$)")); + if (!legalPartial.match(t).hasMatch()) { + return false; + } + return true; +} + +bool TelemtConfigModel::isFakeTlsDomainInputAllowed(const QString &text) const { + if (text.length() > 253) { + return false; + } + static const QRegularExpression re(QStringLiteral(R"(^[a-zA-Z0-9.-]*$)")); + return re.match(text).hasMatch(); +} + +QString TelemtConfigModel::sanitizeFakeTlsDomainFieldText(const QString &input) const { + const QString t = normalizeFakeTlsDomainInput(input); + QString out; + out.reserve(t.size()); + for (const QChar &c: t) { + const ushort u = c.unicode(); + const bool letter = (u >= 'a' && u <= 'z') || (u >= 'A' && u <= 'Z'); + const bool digit = (u >= '0' && u <= '9'); + if (letter || digit || u == '.' || u == '-') { + out.append(c); + } + } + if (out.size() > 253) { + out.truncate(253); + } + return out; +} + +QString TelemtConfigModel::sanitizePublicHostFieldText(const QString &input) const { + QString out; + const int cap = qMin(input.size(), 253); + out.reserve(cap); + for (const QChar &c: input) { + if (out.size() >= 253) { + break; + } + const ushort u = c.unicode(); + if ((u >= 'a' && u <= 'z') || (u >= 'A' && u <= 'Z') || (u >= '0' && u <= '9') || u == '.' || u == ':' || + u == '-') { + out.append(c); + } + } + return out; +} + +QString TelemtConfigModel::sanitizePortFieldText(const QString &input) const { + QString out; + out.reserve(qMin(input.size(), 5)); + for (const QChar &c: input) { + const ushort u = c.unicode(); + if (u >= '0' && u <= '9' && out.size() < 5) { + out.append(c); + } + } + return out; +} + +QString TelemtConfigModel::sanitizeMtProxyTagFieldText(const QString &input) const { + QString trimmed = input.trimmed(); + if (trimmed.startsWith(QLatin1String("0x"), Qt::CaseInsensitive)) { + trimmed = trimmed.mid(2).trimmed(); + } + // Prefer a contiguous 32-hex run (paste from bot message with extra text). + static const QRegularExpression runHex(QStringLiteral(R"(([0-9a-fA-F]{32}))")); + const QRegularExpressionMatch m = runHex.match(trimmed); + if (m.hasMatch()) { + return m.captured(1); + } + const int cap = protocols::telemt::botTagHexLength; + QString out; + out.reserve(qMin(trimmed.size(), cap)); + for (const QChar &c: trimmed) { + if (out.size() >= cap) { + break; + } + const ushort u = c.unicode(); + if ((u >= '0' && u <= '9') || (u >= 'a' && u <= 'f') || (u >= 'A' && u <= 'F')) { + out.append(c); + } + } + return out; +} + +QString TelemtConfigModel::sanitizeOptionalIpv4FieldText(const QString &input) const { + QString out; + out.reserve(qMin(input.size(), 15)); + for (const QChar &c: input) { + if (out.size() >= 15) { + break; + } + const ushort u = c.unicode(); + if ((u >= '0' && u <= '9') || u == '.') { + out.append(c); + } + } + return out; +} + +bool TelemtConfigModel::isValidOptionalIpv4(const QString &ip) const { + const QString t = ip.trimmed(); + if (t.isEmpty()) { + return true; + } + return NetworkUtilities::checkIPv4Format(t); +} + QHash TelemtConfigModel::roleNames() const { QHash roles; diff --git a/client/ui/models/services/telemtConfigModel.h b/client/ui/models/services/telemtConfigModel.h index c386d210e..d458f18dc 100644 --- a/client/ui/models/services/telemtConfigModel.h +++ b/client/ui/models/services/telemtConfigModel.h @@ -116,12 +116,44 @@ public slots: Q_INVOKABLE QString workersModeManual() const; + Q_INVOKABLE bool isValidPublicHost(const QString &host) const; + + Q_INVOKABLE bool isPublicHostInputAllowed(const QString &text) const; + + Q_INVOKABLE bool isPublicHostTypingIncomplete(const QString &text) const; + + Q_INVOKABLE bool isValidMtProxyTag(const QString &tag) const; + + Q_INVOKABLE bool isMtProxyTagTypingIncomplete(const QString &text) const; + + Q_INVOKABLE int mtProxyBotTagHexLength() const; + + Q_INVOKABLE bool isValidFakeTlsDomain(const QString &domain) const; + + Q_INVOKABLE bool isFakeTlsDomainTypingIncomplete(const QString &text) const; + + Q_INVOKABLE bool isFakeTlsDomainInputAllowed(const QString &text) const; + + Q_INVOKABLE QString sanitizeFakeTlsDomainFieldText(const QString &input) const; + + Q_INVOKABLE QString sanitizePublicHostFieldText(const QString &input) const; + + Q_INVOKABLE QString sanitizePortFieldText(const QString &input) const; + + Q_INVOKABLE QString sanitizeMtProxyTagFieldText(const QString &input) const; + + Q_INVOKABLE QString sanitizeOptionalIpv4FieldText(const QString &input) const; + + Q_INVOKABLE bool isValidOptionalIpv4(const QString &ip) const; + protected: QHash roleNames() const override; private: static void applyDefaults(amnezia::TelemtProtocolConfig &c); + QString normalizeFakeTlsDomainInput(const QString &input) const; + amnezia::DockerContainer m_container = amnezia::DockerContainer::None; QJsonObject m_fullConfig; amnezia::TelemtProtocolConfig m_protocolConfig; diff --git a/client/ui/qml/Pages2/PageServiceTelemtSettings.qml b/client/ui/qml/Pages2/PageServiceTelemtSettings.qml index 9636b76dd..e96edde5a 100644 --- a/client/ui/qml/Pages2/PageServiceTelemtSettings.qml +++ b/client/ui/qml/Pages2/PageServiceTelemtSettings.qml @@ -8,6 +8,7 @@ import PageEnum 1.0 import ContainerProps 1.0 import ProtocolEnum 1.0 import Style 1.0 +import TelemtConfig 1.0 import "./" import "../Controls2" @@ -41,6 +42,35 @@ PageType { property string savedTlsDomain: "" property string savedPublicHost: "" + readonly property var natIpv4InputFormat: /^(\d{1,3}\.){0,3}\d{0,3}$/ + + function natIpv4FieldShowInvalidError(text) { + var t = text ? String(text).replace(/^\s+|\s+$/g, '') : "" + if (t === "") + return false + if (TelemtConfigModel.isValidOptionalIpv4(t)) + return false + var parts = t.split('.') + var j + for (j = 0; j < parts.length; j++) { + if (parts[j].length > 3) + return true + } + if (parts.length > 4) + return true + if (t.indexOf('.') < 0 && t.length > 3) + return true + if (t.endsWith('.')) + return false + if (parts.length < 4) + return false + for (var i = 0; i < parts.length; i++) { + if (parts[i] === "") + return true + } + return true + } + onSavedTransportModeChanged: { if (savedTransportMode === "faketls") { root.syncedSecretTabIndex = 1 @@ -889,8 +919,26 @@ PageType { headerText: qsTr("Public host / IP") textField.placeholderText: ServersUiController.serverHostName(ServersUiController.processedServerId) textField.text: publicHost + textField.maximumLength: 253 + textField.validator: PublicHostInputValidator { + } + textField.onTextChanged: { + var t = publicHostTextField.textField.text + if (TelemtConfigModel.isPublicHostTypingIncomplete(t)) { + publicHostTextField.errorText = "" + } else if (!TelemtConfigModel.isValidPublicHost(t)) { + publicHostTextField.errorText = qsTr("Enter a valid IP address or domain name") + } else { + publicHostTextField.errorText = "" + } + } textField.onEditingFinished: { textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (!TelemtConfigModel.isValidPublicHost(textField.text)) { + publicHostTextField.errorText = qsTr("Enter a valid IP address or domain name") + return + } + publicHostTextField.errorText = "" if (textField.text !== publicHost) { publicHost = textField.text TelemtConfigModel.setPublicHost(publicHost) @@ -932,6 +980,7 @@ PageType { headerText: qsTr("Server port") textField.placeholderText: TelemtConfigModel.defaultPort() textField.maximumLength: 5 + textField.inputMethodHints: Qt.ImhDigitsOnly textField.validator: IntValidator { bottom: 1 top: 65535 @@ -940,8 +989,16 @@ PageType { var savedPort = port textField.text = (savedPort === TelemtConfigModel.defaultPort()) ? "" : savedPort } + textField.onTextChanged: { + var cur = portTextField.textField.text + var clean = TelemtConfigModel.sanitizePortFieldText(cur) + if (clean !== cur) { + textField.text = clean + textField.cursorPosition = clean.length + } + } textField.onEditingFinished: { - textField.text = textField.text.replace(/^\s+|\s+$/g, '') + textField.text = TelemtConfigModel.sanitizePortFieldText(textField.text) var portValue = textField.text === "" ? TelemtConfigModel.defaultPort() : textField.text if (portValue !== port) { port = portValue @@ -969,13 +1026,43 @@ PageType { Layout.rightMargin: 16 Layout.bottomMargin: 16 headerText: qsTr("Promoted channel tag (optional)") - textField.placeholderText: qsTr("leave empty if not needed") + textField.placeholderText: qsTr("32 hex chars from @MTProxyBot (e.g. 3b7b2fa9…)") textField.text: tag - textField.maximumLength: 64 + textField.maximumLength: TelemtConfigModel.mtProxyBotTagHexLength() + textField.onTextChanged: { + var cur = tagTextField.textField.text + var clean = TelemtConfigModel.sanitizeMtProxyTagFieldText(cur) + if (clean !== cur) { + textField.text = clean + textField.cursorPosition = clean.length + return + } + var tt = tagTextField.textField.text + if (tt === "") { + tagTextField.errorText = "" + return + } + if (TelemtConfigModel.isMtProxyTagTypingIncomplete(tt)) { + tagTextField.errorText = "" + return + } + if (!TelemtConfigModel.isValidMtProxyTag(tt)) { + tagTextField.errorText = qsTr("Proxy tag must be exactly 32 hexadecimal characters (0-9, A-F).") + return + } + tagTextField.errorText = "" + } textField.onEditingFinished: { - textField.text = textField.text.replace(/^\s+|\s+$/g, '') - if (textField.text !== tag) { - tag = textField.text + var raw = textField.text.replace(/^\s+|\s+$/g, '') + var normalized = TelemtConfigModel.sanitizeMtProxyTagFieldText(raw) + textField.text = normalized + if (!TelemtConfigModel.isValidMtProxyTag(normalized)) { + tagTextField.errorText = qsTr("Proxy tag must be exactly 32 hexadecimal characters (0-9, A-F). Leave empty if unused.") + return + } + tagTextField.errorText = "" + if (normalized !== tag) { + tag = normalized TelemtConfigModel.setTag(tag) } } @@ -1059,13 +1146,30 @@ PageType { visible: transportMode === "faketls" headerText: qsTr("FakeTLS domain") textField.placeholderText: root.previousTlsDomain + textField.validator: RegularExpressionValidator { + regularExpression: /^[A-Za-z0-9.-]*$/ + } Component.onCompleted: { var savedDomain = tlsDomain textField.text = (savedDomain === TelemtConfigModel.defaultTlsDomain() || savedDomain === "") ? "" : savedDomain } + textField.onTextChanged: { + var t = tlsDomainTextField.textField.text + if (t === "" || TelemtConfigModel.isFakeTlsDomainTypingIncomplete(t) + || TelemtConfigModel.isValidFakeTlsDomain(t)) { + tlsDomainTextField.errorText = "" + } else { + tlsDomainTextField.errorText = qsTr("Enter a valid domain name") + } + } textField.onEditingFinished: { textField.text = textField.text.replace(/^\s+|\s+$/g, '') var domainValue = textField.text === "" ? TelemtConfigModel.defaultTlsDomain() : textField.text + if (!TelemtConfigModel.isValidFakeTlsDomain(domainValue)) { + tlsDomainTextField.errorText = qsTr("Enter a valid domain name") + return + } + tlsDomainTextField.errorText = "" if (domainValue !== tlsDomain) { tlsDomain = domainValue TelemtConfigModel.setTlsDomain(tlsDomain) @@ -1323,8 +1427,24 @@ PageType { headerText: qsTr("Internal IP") textField.placeholderText: "172.17.0.2" textField.text: natInternalIp + textField.maximumLength: 15 + textField.validator: RegularExpressionValidator { + regularExpression: root.natIpv4InputFormat + } + textField.onTextChanged: { + if (root.natIpv4FieldShowInvalidError(textField.text)) { + natInternalIpTextField.errorText = qsTr("Enter a valid IPv4 address") + } else { + natInternalIpTextField.errorText = "" + } + } textField.onEditingFinished: { textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (!TelemtConfigModel.isValidOptionalIpv4(textField.text)) { + natInternalIpTextField.errorText = qsTr("Enter a valid IPv4 address") + return + } + natInternalIpTextField.errorText = "" if (textField.text !== natInternalIp) { natInternalIp = textField.text TelemtConfigModel.setNatInternalIp(natInternalIp) @@ -1342,8 +1462,24 @@ PageType { headerText: qsTr("External IP") textField.placeholderText: "1.2.3.4" textField.text: natExternalIp + textField.maximumLength: 15 + textField.validator: RegularExpressionValidator { + regularExpression: root.natIpv4InputFormat + } + textField.onTextChanged: { + if (root.natIpv4FieldShowInvalidError(textField.text)) { + natExternalIpTextField.errorText = qsTr("Enter a valid IPv4 address") + } else { + natExternalIpTextField.errorText = "" + } + } textField.onEditingFinished: { textField.text = textField.text.replace(/^\s+|\s+$/g, '') + if (!TelemtConfigModel.isValidOptionalIpv4(textField.text)) { + natExternalIpTextField.errorText = qsTr("Enter a valid IPv4 address") + return + } + natExternalIpTextField.errorText = "" if (textField.text !== natExternalIp) { natExternalIp = textField.text TelemtConfigModel.setNatExternalIp(natExternalIp)