mudlet/test/functional_tests/TelnetTlsPromptTest.cpp
Vadim Peretokin c1d1f1aec8
fix: stop the telnet reader writing a NUL past the data it was given (#9677)
#### Brief overview of PR changes/additions

* `cTelnet::processSocketData()` terminated its input at
`in_buffer[amount + 1]`, one byte past the data it was given, and did so
*before* checking the `-1`/`0` returns from `QIODevice::read()`. It now
guards first, then terminates at `in_buffer[amount]`.
* The guard is `amount <= 0` rather than `== -1`, because
`loopbackTest()` narrows a `qsizetype` into an `int` and can produce a
negative that is not `-1`.
* Adds `cTelnetBufferTest` (5 slots) and drops the `reserve(size + 16)`
slack that three existing telnet tests carried purely to absorb the
stray write, which turns them into regression guards too.

#### Motivation for adding to Mudlet

The socket path survived this because `slot_socketReadyToBeRead()`
over-allocates its stack buffer, but the same function is reached from
Lua's `feedTelnet()` via `loopbackTest()`, which passes a `QByteArray`
sized exactly to its contents - so the stray NUL landed one byte past a
heap allocation. That is a real out-of-bounds write reachable from any
script, and the workarounds already sitting in our test suite show it
has been quietly worked around rather than fixed.

#### Other info (issues closed, discussion etc)

Closes #1065. Supersedes the closed #8438, which carried the same fix
under 19 commits of unrelated history and had CI red on a faulty
assertion in its own test.

**Test case:** reverting the `src/ctelnet.cpp` hunk makes 3 of the 5 new
slots fail and AddressSanitizer report `heap-buffer-overflow ... in
cTelnet::processSocketData(char*, int, bool)`; restoring it gives 7
passed / 0 failed, and the full suite is 72/72 serially.

One thing to flag for review: this adds `friend class
cTelnetBufferTest;` to `cTelnet`, since `processSocketData()` is private
and the public `loopbackTest()` cannot express a caller-laid-out buffer.
It sits beside the existing `friend class TelnetTlsPromptTest;`, so
there is precedent, but it is a test name in a shipped header and worth
a second opinion.

Three review findings were deliberately left out of scope. The MCCP
decompression path hit the same overflow, via the re-entry that passes
`remainingData`/`remainingAmount` back into `processSocketData()` - the
fix covers it, but nothing under `test/` exercises compression at all,
so it is fixed-but-unguarded and a dedicated MCCP test belongs in its
own PR. The other two are pre-existing and orthogonal: a read error
(`amount == -1`) is still silent, because `slot_socketError()` has been
commented out as unused since 2017; and `mDecompressionRecursionDepth`
is hand-balanced across four decrements rather than held by a scope
guard (verified balanced today, but fragile). Happy to do any of them as
a follow-up.

Assisted-by: Claude:claude-opus-5
2026-08-05 14:06:45 +02:00

475 lines
21 KiB
C++

/***************************************************************************
* Copyright (C) 2026 by Mudlet Developers *
* *
* 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 of the License, or *
* (at your option) any later version. *
* *
* 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, write to the *
* Free Software Foundation, Inc., *
* 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. *
***************************************************************************/
#include <QtTest/QtTest>
#include <chrono>
#include "MudletInstanceCoordinator.h"
#include "TMainConsole.h"
#include "TelnetServerStub.h"
#include "ctelnet.h"
#include "dlgConnectionProfiles.h"
#include "mudlet.h"
#include "utils.h"
#include <QHostAddress>
#include <QNetworkReply>
#include <QPointer>
#include <QProgressDialog>
#include <QTcpServer>
#include <QRegularExpression>
extern void qInitResources_mudlet();
extern void qInitResources_qm();
extern void qInitResources_additional_splash_screens();
extern void qInitResources_mudlet_fonts_common();
extern void qInitResources_mudlet_fonts_posix();
void initializeQRCResourcesForTlsPrompt();
using namespace std::chrono_literals;
// Exercises the widget-free seam introduced when cTelnet was de-widgeted: when
// MSSP advertises a secure (TLS) port on an unencrypted connection, cTelnet must
// no longer construct a QMessageBox itself; instead it emits
// signal_promptTlsAvailable() carrying the (already translated) strings, and the
// frontend owns the modal dialog. This test verifies the signal is emitted with
// the expected payload.
class TelnetTlsPromptTest : public QObject
{
Q_OBJECT
private:
TelnetServerStub* mpServer = nullptr;
const QString mHostname = "Test-Telnet-Tls-Prompt";
const QString mLocalhost = "localhost";
QString mPort;
private slots:
void initTestCase() { initializeQRCResourcesForTlsPrompt(); }
void init()
{
mpServer = new TelnetServerStub(qApp);
// Bind an ephemeral OS-assigned port so parallel test runs (e.g. across
// git worktrees) do not collide on a shared fixed port.
mpServer->start(mLocalhost, 0);
mPort = QString::number(mpServer->serverPort());
mudlet::start();
mudlet::self()->setupConfig();
mudlet::self()->takeOwnershipOfInstanceCoordinator(std::make_unique<MudletInstanceCoordinator>("MudletInstanceCoordinator"));
mudlet::self()->init();
mudlet::self()->setStorePasswordsSecurely(false);
deleteProfileDirectory(mHostname);
}
void test_msspTlsPortEmitsPromptSignal()
{
#if defined(QT_NO_SSL)
QSKIP("Built without SSL support - the TLS upgrade prompt does not exist.");
#else
startProfile(mHostname, mLocalhost, mPort);
auto host = mudlet::self()->getActiveHost();
QVERIFY2(host, "No active host available for the test.");
// Detach the frontend's modal handler: mudlet::addConsoleForNewHost
// connects a lambda that would call QMessageBox::exec() and block the
// test (there is no user to click it). Disconnect by signal alone
// (nullptr receiver/slot) rather than by receiver: if the frontend
// wiring ever moves off mudlet::self() this still detaches every
// handler, so the test cannot hang forever inside exec().
disconnect(&host->mTelnet, &cTelnet::signal_promptTlsAvailable, nullptr, nullptr);
QSignalSpy spy(&host->mTelnet, &cTelnet::signal_promptTlsAvailable);
QVERIFY(spy.isValid());
// Build an MSSP subnegotiation advertising a secure port:
// IAC SB MSSP MSSP_VAR "TLS" MSSP_VAL "48000" IAC SE
const QByteArray tlsPort = "48000";
QByteArray data;
data.append(TN_IAC);
data.append(TN_SB);
data.append(OPT_MSSP);
data.append(MSSP_VAR);
data.append("TLS");
data.append(MSSP_VAL);
data.append(tlsPort);
data.append(TN_IAC);
data.append(TN_SE);
host->mTelnet.loopbackTest(data);
// The signal is emitted synchronously inside loopbackTest(), so it has
// almost certainly already arrived; only wait if it somehow has not,
// otherwise an unconditional spy.wait() would burn its full timeout.
if (spy.isEmpty()) {
QVERIFY2(spy.wait(2s), "cTelnet did not emit signal_promptTlsAvailable when MSSP advertised a TLS port.");
}
QCOMPARE(spy.count(), 1);
// The informative text carries the advertised port, keeping the string
// (and its translation context) inside cTelnet.
const QList<QVariant> arguments = spy.takeFirst();
QCOMPARE(arguments.size(), 2);
const QString informativeText = arguments.at(1).toString();
QVERIFY2(informativeText.contains(QString::fromLatin1(tlsPort)), qPrintable(QString("informativeText did not mention the TLS port: %1").arg(informativeText)));
// cTelnet recorded the advertised port as well.
QCOMPARE(host->mMSSPTlsPort, tlsPort.toInt());
#endif
}
// No path: declining the TLS upgrade must leave the port and ssl_tsl
// untouched, stop the client asking again this session, and still bring the
// connection back (slot_tlsUpgradeResponse() does disconnectIt() +
// reconnect() against the stub, which accepts repeat connections).
void test_tlsUpgradeDeclinedKeepsPortAndStopsAsking()
{
#if defined(QT_NO_SSL)
QSKIP("Built without SSL support - the TLS upgrade prompt does not exist.");
#else
startProfile(mHostname, mLocalhost, mPort);
auto host = mudlet::self()->getActiveHost();
QVERIFY2(host, "No active host available for the test.");
// Detach the frontend modal handler so no dialog blocks the test.
disconnect(&host->mTelnet, &cTelnet::signal_promptTlsAvailable, nullptr, nullptr);
const int originalPort = host->getPort();
const bool originalSsl = host->mSslTsl;
// Advertise a secure port so mMSSPTlsPort is populated (the handler is
// detached, so nothing pops up).
QByteArray advertise = msspTlsPayload("48000");
host->mTelnet.loopbackTest(advertise);
// The user answers No; the reconnect that follows must complete.
QSignalSpy connectedSpy(&host->mTelnet, &cTelnet::signal_connected);
host->mTelnet.slot_tlsUpgradeResponse(false);
QVERIFY2(connectedSpy.wait(5s), "Declining the TLS upgrade did not reconnect to the server.");
QVERIFY2(!host->mAskTlsAvailable, "Declining the TLS upgrade did not stop the client asking again.");
QCOMPARE(host->getPort(), originalPort);
QCOMPARE(host->mSslTsl, originalSsl);
// Re-advertising the same secure port must NOT prompt again now that
// the user has declined (don't-ask-again is sticky for the session).
QSignalSpy promptSpy(&host->mTelnet, &cTelnet::signal_promptTlsAvailable);
QByteArray advertiseAgain = msspTlsPayload("48000");
host->mTelnet.loopbackTest(advertiseAgain);
QCOMPARE(promptSpy.count(), 0);
#endif
}
// Yes path: accepting switches the profile to the advertised secure port
// and starts a fresh (encrypted) connection to it. A second plain-TCP stub
// stands in for the secure server: connectToHostEncrypted() completes the
// TCP accept before the (doomed) TLS handshake, and SSL errors are reported
// via postMessage rather than a modal dialog, so nothing blocks.
void test_tlsUpgradeAcceptedSwitchesPortAndConnects()
{
#if defined(QT_NO_SSL)
QSKIP("Built without SSL support - the TLS upgrade prompt does not exist.");
#else
startProfile(mHostname, mLocalhost, mPort);
auto host = mudlet::self()->getActiveHost();
QVERIFY2(host, "No active host available for the test.");
disconnect(&host->mTelnet, &cTelnet::signal_promptTlsAvailable, nullptr, nullptr);
TelnetServerStub secureStub;
secureStub.start(mLocalhost, 0);
const quint16 securePort = secureStub.serverPort();
// Advertise the second stub's port as the secure port.
QByteArray advertise = msspTlsPayload(QByteArray::number(securePort));
host->mTelnet.loopbackTest(advertise);
QCOMPARE(host->mMSSPTlsPort, static_cast<int>(securePort));
QSignalSpy acceptSpy(&secureStub, &QTcpServer::newConnection);
host->mTelnet.slot_tlsUpgradeResponse(true);
// The port switch and ssl_tsl flag are set synchronously.
QCOMPARE(host->getPort(), static_cast<int>(securePort));
QVERIFY2(host->mSslTsl, "Accepting the TLS upgrade did not enable ssl_tsl on the profile.");
// The encrypted connect reaches the secure stub's TCP accept.
QVERIFY2(acceptSpy.wait(5s), "Accepting the TLS upgrade did not open a TCP connection to the secure port.");
// Stop talking to the local stub before it is destroyed at scope exit.
host->mTelnet.disconnectIt();
#endif
}
// F3 (modal stacking): while a TLS-upgrade prompt is pending, a server
// re-advertising its secure MSSP port must NOT emit a second prompt. The
// in-flight latch collapses repeats to a single emission until the user
// answers (which clears it), so a hostile server cannot stack modals.
void test_secondTlsAdvertisementWhilePendingDoesNotReprompt()
{
#if defined(QT_NO_SSL)
QSKIP("Built without SSL support - the TLS upgrade prompt does not exist.");
#else
startProfile(mHostname, mLocalhost, mPort);
auto host = mudlet::self()->getActiveHost();
QVERIFY2(host, "No active host available for the test.");
// Detach the frontend modal handler so nothing pops up; the latch is set
// by the emit itself, independent of who is listening.
disconnect(&host->mTelnet, &cTelnet::signal_promptTlsAvailable, nullptr, nullptr);
QSignalSpy spy(&host->mTelnet, &cTelnet::signal_promptTlsAvailable);
QVERIFY(spy.isValid());
QByteArray advertise = msspTlsPayload("48000");
host->mTelnet.loopbackTest(advertise);
if (spy.isEmpty()) {
QVERIFY2(spy.wait(2s), "cTelnet did not emit signal_promptTlsAvailable for the first advertisement.");
}
QCOMPARE(spy.count(), 1);
// Nobody has answered, so the prompt is still in flight: a repeated
// advertisement (as a hostile server could spam) must be swallowed.
QByteArray advertiseAgain = msspTlsPayload("48000");
host->mTelnet.loopbackTest(advertiseAgain);
QCOMPARE(spy.count(), 1);
#endif
}
// With the dialog delivered via a queued connection the answer can arrive
// after the connection has dropped. slot_tlsUpgradeResponse() must discard
// such a stale answer with a warning rather than act on a gone socket or
// crash.
void test_tlsUpgradeAnswerAfterDisconnectIsDiscarded()
{
#if defined(QT_NO_SSL)
QSKIP("Built without SSL support - the TLS upgrade prompt does not exist.");
#else
startProfile(mHostname, mLocalhost, mPort);
auto host = mudlet::self()->getActiveHost();
QVERIFY2(host, "No active host available for the test.");
disconnect(&host->mTelnet, &cTelnet::signal_promptTlsAvailable, nullptr, nullptr);
// Advertise so the port is recorded and the latch is set, mirroring a
// real pending prompt.
QByteArray advertise = msspTlsPayload("48000");
host->mTelnet.loopbackTest(advertise);
const int originalPort = host->getPort();
const bool originalSsl = host->mSslTsl;
// Drop the connection out from under the pending prompt; mpSocket is
// reset to null once the disconnect completes. disconnectFromHost() can
// emit disconnected() synchronously (empty write buffer), so check for an
// already-recorded emission before waiting or wait() would miss it.
QSignalSpy disconnectedSpy(&host->mTelnet, &cTelnet::signal_disconnected);
host->mTelnet.disconnectIt();
if (disconnectedSpy.isEmpty()) {
QVERIFY2(disconnectedSpy.wait(5s), "The connection did not drop.");
}
// The late answer must be discarded with a warning and leave the profile
// untouched (no port switch, no ssl_tsl flip, no crash).
QTest::ignoreMessage(QtWarningMsg, QRegularExpression("slot_tlsUpgradeResponse.*discarding the user's answer"));
host->mTelnet.slot_tlsUpgradeResponse(true);
QCOMPARE(host->getPort(), originalPort);
QCOMPARE(host->mSslTsl, originalSsl);
#endif
}
// A received telnet BELL (0x07) must emit signal_bell() exactly once per
// bell byte so the frontend can flash/beep without re-alerting on redraws.
void test_bellEmitsSignalPerBell()
{
startProfile(mHostname, mLocalhost, mPort);
auto host = mudlet::self()->getActiveHost();
QVERIFY2(host, "No active host available for the test.");
QSignalSpy bellSpy(&host->mTelnet, &cTelnet::signal_bell);
QByteArray oneBell("\a");
host->mTelnet.loopbackTest(oneBell);
QCOMPARE(bellSpy.count(), 1);
QByteArray twoBells("\a\a");
host->mTelnet.loopbackTest(twoBells);
QCOMPARE(bellSpy.count(), 3);
}
// I2: a second package-download prompt must replace (not stack on top of)
// the first dialog, and cancelling with no active download is a no-op.
void test_packageDownloadProgressDialogReplaced()
{
startProfile(mHostname, mLocalhost, mPort);
auto host = mudlet::self()->getActiveHost();
QVERIFY2(host, "No active host available for the test.");
QVERIFY2(host->mpConsole, "The active host has no main console.");
auto console = host->mpConsole;
console->showPackageDownloadProgress("Downloading package 1", "Cancel");
console->showPackageDownloadProgress("Downloading package 2", "Cancel");
// The first dialog closes with WA_DeleteOnClose, so let its queued
// deleteLater() run before counting the live dialogs.
QTest::qWait(50ms);
QCOMPARE(console->findChildren<QProgressDialog*>().count(), 1);
// Cancelling when no download is in flight must be a harmless no-op.
host->mTelnet.slot_cancelPackageDownload();
QCOMPARE(console->findChildren<QProgressDialog*>().count(), 1);
}
// When a second server-initiated GUI download supersedes one still in
// flight (a reconnect re-sends Client.GUI), swapping the progress dialog
// must not cancel the freshly started download. The superseded dialog's
// close() emits canceled(), which used to abort the just-assigned new reply.
// The test above missed this because it swapped dialogs with no reply live.
void test_replacingDownloadDialogKeepsNewDownloadAlive()
{
startProfile(mHostname, mLocalhost, mPort);
auto host = mudlet::self()->getActiveHost();
QVERIFY2(host, "No active host available for the test.");
QVERIFY2(host->mpConsole, "The active host has no main console.");
auto console = host->mpConsole;
// A TCP server that accepts connections but never answers keeps the
// package-download reply in flight (Running, NoError) for the whole
// test, so an unwanted abort() is the only thing that can finish it.
QTcpServer hangingServer;
QVERIFY2(hangingServer.listen(QHostAddress::LocalHost, 0), "Could not start the stand-in download server.");
const QString url = qsl("http://localhost:%1/game-ui.mpackage").arg(hangingServer.serverPort());
// First server-initiated download: starts reply #1 and progress dialog #1.
host->mTelnet.downloadAndInstallGUIPackage(qsl("game-ui"), qsl("game-ui.mpackage"), url);
QVERIFY2(host->mTelnet.mpPackageDownloadReply, "The first GUI download did not start a network reply.");
QCOMPARE(console->findChildren<QProgressDialog*>().count(), 1);
// Second download supersedes the first; reply #2 must take over and stay
// live rather than being cancelled the instant its dialog replaces #1.
host->mTelnet.downloadAndInstallGUIPackage(qsl("game-ui"), qsl("game-ui.mpackage"), url);
QPointer<QNetworkReply> newReply = host->mTelnet.mpPackageDownloadReply;
QVERIFY2(newReply, "The superseding GUI download left no active network reply.");
QVERIFY2(!newReply->isFinished(), "The superseding GUI download was cancelled at birth by the dialog swap.");
QCOMPARE(newReply->error(), QNetworkReply::NoError);
// Let dialog #1's WA_DeleteOnClose deleteLater() run: exactly one dialog
// survives the swap, and the new download is still alive.
QTest::qWait(50ms);
QCOMPARE(console->findChildren<QProgressDialog*>().count(), 1);
QVERIFY2(newReply && newReply->error() == QNetworkReply::NoError, "The superseding GUI download did not survive the dialog swap.");
// The user's Cancel must still abort the live download.
host->mTelnet.slot_cancelPackageDownload();
QTest::qWait(50ms);
}
// Builds an MSSP subnegotiation advertising a secure TLS port:
// IAC SB MSSP MSSP_VAR "TLS" MSSP_VAL <port> IAC SE
QByteArray msspTlsPayload(const QByteArray& port)
{
QByteArray data;
data.append(TN_IAC);
data.append(TN_SB);
data.append(OPT_MSSP);
data.append(MSSP_VAR);
data.append("TLS");
data.append(MSSP_VAL);
data.append(port);
data.append(TN_IAC);
data.append(TN_SE);
return data;
}
void cleanup()
{
delete mpServer;
mpServer = nullptr;
deleteProfileDirectory(mHostname);
delete mudlet::self();
}
// Utility function to manually start a profile like a user would do via the
// GUI
void startProfile(const QString& hostname, const QString& address, const QString& port)
{
QTimer::singleShot(0, qApp, [hostname, address, port]() {
mudlet::self()->startAutoLogin({});
QTest::qWait(100ms);
QTest::mouseClick(mudlet::self()->mpConnectionDialog->new_profile_button, Qt::LeftButton);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), hostname);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), address);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Tab);
QTest::qWait(100ms);
QTest::keyClicks(QApplication::focusWidget(), port);
QTest::qWait(100ms);
QTest::keyClick(QApplication::focusWidget(), Qt::Key_Return);
});
QSignalSpy spy(mudlet::self(), &mudlet::signal_profileLoaded);
if (!spy.wait(5s)) {
QFAIL("Profile took too long to load.");
}
auto host = mudlet::self()->getActiveHost();
if (!host) {
QFAIL("No active host available for the test.");
}
QSignalSpy spy2(&(host->mTelnet), &cTelnet::signal_connected);
if (!spy2.wait(2s)) {
QFAIL("Could not connect with the host.");
}
}
// Utility function
void deleteProfileDirectory(const QString& profileName)
{
const QString path = mudlet::getMudletPath(enums::profileHomePath, profileName);
QDir dir(path);
if (!dir.exists()) {
qInfo() << "Profile directory does not exist:" << path;
return;
}
dir.removeRecursively();
}
};
void initializeQRCResourcesForTlsPrompt()
{
#ifdef INCLUDE_VARIABLE_SPLASH_SCREEN
qInitResources_additional_splash_screens();
#endif
#ifdef INCLUDE_FONTS
qInitResources_mudlet_fonts_common();
#if defined(Q_OS_LINUX) || defined(Q_OS_FREEBSD)
qInitResources_mudlet_fonts_posix();
#endif
#endif
qInitResources_mudlet();
qInitResources_qm();
}
#include "TelnetTlsPromptTest.moc"
QTEST_MAIN(TelnetTlsPromptTest)