mudlet/test/UntrustedTextTest.cpp

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

189 lines
10 KiB
C++
Raw Permalink Normal View History

improve: OSC 8 hyperlink handling, and a setting to turn it off (#9731) #### Brief overview of PR changes/additions - Hardens how OSC 8 link payloads and link text are handled before they are run or displayed; link commands are no longer built by string-formatting remote text into Lua source. - Adds a per-profile setting (General → Game protocols) to turn OSC 8 hyperlinks off, which also reports `0` for every `OSC_HYPERLINKS*` NEW-ENVIRON variable and sends an INFO update if toggled mid-session. - Fixes `selected=` callbacks on `send:` links, which never fired, and keeps emoji and Persian/Arabic/Indic text intact in tooltips and menu labels. #### Motivation for adding to Mudlet Inspired by [conversation](https://discord.com/channels/279748146316312576/1416447642472284261/1535109010066251890) on the MUD Discord and updates to terminal emulators. OSC 8 sequences arrive from the game server — and often from another player whose say/tell text the server relays — so they have to be treated as untrusted input rather than as content the user chose to load. #### Other info (issues closed, discussion etc) New unit tests: `LuaLiteralTest` (28 cases, including an exhaustive sweep over the bracket alphabet, each evaluated in a real Lua 5.1 state) and `UntrustedTextTest` (26 cases covering emoji sequences, non-Latin shaping and the two sanitization policies). There is no automated NEW-ENVIRON coverage anywhere in the repo, so that path was verified manually against a live server instead. **Test case:** 1. `say !osc8-docs` — every documented feature still works. 2. Send a link whose command ends in `]`, e.g. `send:say [OOC]` — clicking sends the literal text (previously the click silently did nothing). 3. Settings → General → Game protocols → uncheck "Enable OSC 8 hyperlinks from the server" — links stop rendering and the server is told without a reconnect; re-check and they return. 4. Send a tooltip or menu label containing a multi-part emoji such as 👨‍🍳 — it renders normally, not as its component parts. --------- Signed-off-by: Michael Conley <sousesider@gmail.com>
2026-08-08 04:09:32 -04:00
/***************************************************************************
* Copyright (C) 2026 by Mike Conley - mike.conley@stickmud.com *
* *
* 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 "UntrustedText.h"
#include <QtTest/QtTest>
/*
* A game server chooses the tooltip, menu labels and menu title of an OSC 8
* link, and Mudlet shows the link's target URL in the default hint. Bidi
* overrides and zero-width characters let that text claim one target while the
* link carries another, so they are escaped into a visible form before display.
*
* Test inputs spell invisible code points as \uXXXX escapes rather than
* embedding them raw: editors and review tools that strip invisible characters
* have silently deleted them from source before, which turns a real assertion
* into one that passes for the wrong reason.
*/
class UntrustedTextTest : public QObject
{
Q_OBJECT
private slots:
void initTestCase() {}
void testOrdinaryTextUnchanged() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("Open browser to: https://www.mudlet.org")), QStringLiteral("Open browser to: https://www.mudlet.org")); }
void testNonLatinTextUnchanged()
{
// Sanitization must not damage legitimate non-Latin tooltips.
const QString text = QStringLiteral("你好 مرحبا Здравствуй");
QCOMPARE(UntrustedText::forTarget(text), text);
}
void testAstralPlaneTextUnchanged()
{
// Surrogate pairs must round-trip: an emoji is two QChars, one code point.
const QString text = QStringLiteral("a\U0001F600b");
QCOMPARE(UntrustedText::forTarget(text), text);
}
void testRightToLeftOverrideEscaped() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("mudlet\u202Egro.live")), QStringLiteral("mudlet\\u{202E}gro.live")); }
void testZeroWidthEscaped() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("mud\u200Blet.org")), QStringLiteral("mud\\u{200B}let.org")); }
void testByteOrderMarkEscaped() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\uFEFFb")), QStringLiteral("a\\u{FEFF}b")); }
void testControlCharactersEscaped()
{
// A newline in a tooltip creates a second visual line that can forge
// trusted-looking UI text below the real target.
QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\nb")), QStringLiteral("a\\u{A}b"));
QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\u0085b")), QStringLiteral("a\\u{85}b"));
}
void testLineSeparatorEscaped() { QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\u2028b")), QStringLiteral("a\\u{2028}b")); }
void testEmptyText() { QCOMPARE(UntrustedText::forTarget(QString()), QString()); }
void testLiteralEscapeSequenceDisambiguated()
{
// Server text containing the six characters \u{202E} must not display
// the same as a sanitized real U+202E.
QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\\u{202E}b")), QStringLiteral("a\\u{5C}u{202E}b"));
// A backslash not starting a \u{ sequence is left alone.
QCOMPARE(UntrustedText::forTarget(QStringLiteral("C:\\mud\\maps")), QStringLiteral("C:\\mud\\maps"));
}
// Emoji in tooltips and menu labels are a documented OSC 8 feature and are
// used in the wild. The strict policy escapes the joiners and tag
// characters they are assembled from, so authored text must not use it.
void testAuthoredTextKeepsEmoji_data()
{
QTest::addColumn<QString>("emoji");
QTest::newRow("rainbow flag") << QStringLiteral("\U0001F3F3\uFE0F\u200D\U0001F308");
QTest::newRow("pirate flag") << QStringLiteral("\U0001F3F4\u200D\uFE0F");
QTest::newRow("man cook") << QStringLiteral("\U0001F468\u200D\U0001F373");
QTest::newRow("family") << QStringLiteral("\U0001F468\u200D\U0001F469\u200D\U0001F467");
QTest::newRow("scotland flag") << QStringLiteral("\U0001F3F4\U000E0067\U000E0062\U000E0073\U000E0063\U000E0074\U000E007F");
QTest::newRow("plain emoji") << QStringLiteral("\uFE0F");
QTest::newRow("skin tone") << QStringLiteral("\U0001F44D\U0001F3FD");
QTest::newRow("regional flag") << QStringLiteral("\U0001F1EC\U0001F1E7");
}
void testAuthoredTextKeepsEmoji()
{
QFETCH(QString, emoji);
QCOMPARE(UntrustedText::forAuthoredText(emoji), emoji);
}
void testAuthoredTextKeepsPersianShaping()
{
// ZWNJ separates the prefix in this Persian verb; escaping it changes
// how the word is shaped and read.
const QString text = QStringLiteral("می\u200Cرود");
QCOMPARE(UntrustedText::forAuthoredText(text), text);
}
void testAuthoredTextEscapesUnusableTagCharacters()
{
// The exception covers only what an emoji flag needs. The deprecated
// language tag and the unassigned code points below it are not that.
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\U000E0001b")), QStringLiteral("a\\u{E0001}b"));
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\U000E0000b")), QStringLiteral("a\\u{E0000}b"));
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\U000E001Fb")), QStringLiteral("a\\u{E001F}b"));
// The first assigned tag character is where the exception starts.
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\U000E0020b")), QStringLiteral("a\U000E0020b"));
}
void testTargetStillEscapesWhatAuthoredTextKeeps()
{
// The same characters must not survive in a link target, where they
// would hide part of what the user is being asked to trust.
QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\u200Db")), QStringLiteral("a\\u{200D}b"));
QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\u200Cb")), QStringLiteral("a\\u{200C}b"));
QCOMPARE(UntrustedText::forTarget(QStringLiteral("a\U000E0067b")), QStringLiteral("a\\u{E0067}b"));
}
void testAuthoredTextStillEscapesReordering()
{
// Relaxing the joiners must not relax the characters that let a label
// misrepresent itself or forge a second line of UI.
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("mudlet\u202Egro.live")), QStringLiteral("mudlet\\u{202E}gro.live"));
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\u2066b")), QStringLiteral("a\\u{2066}b"));
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\nb")), QStringLiteral("a\\u{A}b"));
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\u2028b")), QStringLiteral("a\\u{2028}b"));
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\u200Bb")), QStringLiteral("a\\u{200B}b"));
QCOMPARE(UntrustedText::forAuthoredText(QStringLiteral("a\uFEFFb")), QStringLiteral("a\\u{FEFF}b"));
}
void testClassification()
{
QVERIFY(UntrustedText::unsafeCharacter(0x202E));
QVERIFY(UntrustedText::unsafeCharacter(0x200B));
QVERIFY(UntrustedText::unsafeCharacter(0x0000));
QVERIFY(UntrustedText::unsafeCharacter(0x009F));
// Bidi embeddings and overrides, and the isolates that replaced them.
QVERIFY(UntrustedText::unsafeCharacter(0x202A));
QVERIFY(UntrustedText::unsafeCharacter(0x202D));
QVERIFY(UntrustedText::unsafeCharacter(0x2066));
QVERIFY(UntrustedText::unsafeCharacter(0x2069));
// Arabic letter mark, zero-width joiners and the word joiner.
QVERIFY(UntrustedText::unsafeCharacter(0x061C));
QVERIFY(UntrustedText::unsafeCharacter(0x200C));
QVERIFY(UntrustedText::unsafeCharacter(0x200D));
QVERIFY(UntrustedText::unsafeCharacter(0x2060));
// Both ends of the invisible-by-design tag character block.
QVERIFY(UntrustedText::unsafeCharacter(0xE0000));
QVERIFY(UntrustedText::unsafeCharacter(0xE007F));
QVERIFY(!UntrustedText::unsafeCharacter(0x0041));
QVERIFY(!UntrustedText::unsafeCharacter(0x00A0));
QVERIFY(!UntrustedText::unsafeCharacter(0x4F60));
}
void testAuthoredClassificationDiffersOnlyWhereIntended()
{
// The authored policy is the strict one minus exactly three things.
for (char32_t codePoint = 0; codePoint <= 0xE0100; ++codePoint) {
const bool relaxed = UntrustedText::unsafeCharacter(codePoint) && !UntrustedText::unsafeAuthoredCharacter(codePoint);
const bool expected = codePoint == 0x200C || codePoint == 0x200D || (codePoint >= 0xE0020 && codePoint <= 0xE007F);
if (relaxed != expected) {
QFAIL(qPrintable(QStringLiteral("policies diverge unexpectedly at U+%1").arg(QString::number(static_cast<uint>(codePoint), 16).toUpper())));
}
// Authored text may never mark something unsafe that strict does not.
QVERIFY(!(UntrustedText::unsafeAuthoredCharacter(codePoint) && !UntrustedText::unsafeCharacter(codePoint)));
}
}
};
QTEST_MAIN(UntrustedTextTest)
#include "UntrustedTextTest.moc"