diff --git a/.github/actions/spelling/allow/apis.txt b/.github/actions/spelling/allow/apis.txt
index e60f4cc7780..577a7680504 100644
--- a/.github/actions/spelling/allow/apis.txt
+++ b/.github/actions/spelling/allow/apis.txt
@@ -76,6 +76,7 @@ IConnection
ICustom
IDialog
IDirect
+Idn
IExplorer
IFACEMETHOD
IFile
diff --git a/.github/actions/spelling/expect/expect.txt b/.github/actions/spelling/expect/expect.txt
index f471a831251..0cddd653f4c 100644
--- a/.github/actions/spelling/expect/expect.txt
+++ b/.github/actions/spelling/expect/expect.txt
@@ -221,6 +221,7 @@ cmt
cmw
cmyk
CNL
+cnn
cnt
CNTRL
Codeflow
@@ -260,7 +261,6 @@ condrv
conechokey
conemu
configurability
-confusables
conhost
conime
conimeinfo
@@ -808,7 +808,6 @@ HIBYTE
hicon
HIDEWINDOW
hinst
-Hirots
HISTORYBUFS
HISTORYNODUP
HISTORYSIZE
@@ -825,6 +824,8 @@ HMK
hmod
hmodule
hmon
+homeglyphs
+homoglyph
HORZ
hostable
hostlib
@@ -1279,7 +1280,6 @@ nullability
nullness
nullonfailure
nullopts
-NULs
numlock
numpad
NUMSCROLL
@@ -1768,7 +1768,6 @@ somefile
SOURCEBRANCH
sourced
spammy
-spand
SRCCODEPAGE
SRCCOPY
SRCINVERT
@@ -2293,7 +2292,6 @@ xunit
xutr
XVIRTUALSCREEN
XWalk
-xwwyzz
xxyyzz
yact
YCast
diff --git a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw
index 4d95586e120..c94ab15d368 100644
--- a/src/cascadia/TerminalControl/Resources/en-US/Resources.resw
+++ b/src/cascadia/TerminalControl/Resources/en-US/Resources.resw
@@ -178,6 +178,10 @@
Ctrl+Click to follow link
+
+ Invalid URI
+ Whenever we encounter an invalid URI or URL we show this string as a warning.
+
Unable to find the selected font "{0}".
diff --git a/src/cascadia/TerminalControl/TermControl.cpp b/src/cascadia/TerminalControl/TermControl.cpp
index 715939db7d7..1c1fc03da54 100644
--- a/src/cascadia/TerminalControl/TermControl.cpp
+++ b/src/cascadia/TerminalControl/TermControl.cpp
@@ -3042,56 +3042,85 @@ namespace winrt::Microsoft::Terminal::Control::implementation
_core.ClearHoveredCell();
}
- winrt::fire_and_forget TermControl::_hoveredHyperlinkChanged(IInspectable /*sender*/,
- IInspectable /*args*/)
+ // Attackers abuse Unicode characters that happen to look similar to ASCII characters. Cyrillic for instance has
+ // its own glyphs for а, с, е, о, р, х, and у that look practically identical to their ASCII counterparts.
+ // This is called an "IDN homoglyph attack".
+ //
+ // But outright showing Punycode URIs only is similarly flawed as they can end up looking similar to valid ASCII URIs.
+ // xn--cnn.com for instance looks confusingly similar to cnn.com, but actually represents U+407E.
+ //
+ // An optimal solution would detect any URI that contains homoglyphs and show them in their Punycode form.
+ // Such a detector however is not quite trivial and requires constant maintenance, which this project's
+ // maintainers aren't currently well equipped to handle. As such we do the next best thing and show the
+ // Punycode encoding side-by-side with the Unicode string for any IDN.
+ static winrt::hstring sanitizeURI(winrt::hstring uri)
{
- auto weakThis{ get_weak() };
- co_await wil::resume_foreground(Dispatcher());
- if (auto self{ weakThis.get() })
+ if (uri.empty())
{
- auto lastHoveredCell = _core.HoveredCell();
- if (lastHoveredCell)
- {
- winrt::hstring uriText = _core.HoveredUriText();
- if (uriText.empty())
- {
- co_return;
- }
+ return uri;
+ }
- try
- {
- // DisplayUri will filter out non-printable characters and confusables.
- Windows::Foundation::Uri parsedUri{ uriText };
- if (!parsedUri)
- {
- co_return;
- }
- uriText = parsedUri.DisplayUri();
+ wchar_t punycodeBuffer[256];
+ wchar_t unicodeBuffer[256];
- const auto panel = SwapChainPanel();
- const auto scale = panel.CompositionScaleX();
- const auto offset = panel.ActualOffset();
+ // These functions return int, but are documented to only return positive numbers.
+ // Better make sure though. It allows us to pass punycodeLength right into IdnToUnicode.
+ const auto punycodeLength = std::max(0, IdnToAscii(0, uri.data(), gsl::narrow(uri.size()), &punycodeBuffer[0], 256));
+ const auto unicodeLength = std::max(0, IdnToUnicode(0, &punycodeBuffer[0], punycodeLength, &unicodeBuffer[0], 256));
- // Update the tooltip with the URI
- HoveredUri().Text(uriText);
+ if (punycodeLength <= 0 || unicodeLength <= 0)
+ {
+ return RS_(L"InvalidUri");
+ }
- // Set the border thickness so it covers the entire cell
- const auto charSizeInPixels = CharacterDimensions();
- const auto htInDips = charSizeInPixels.Height / scale;
- const auto wtInDips = charSizeInPixels.Width / scale;
- const Thickness newThickness{ wtInDips, htInDips, 0, 0 };
- HyperlinkTooltipBorder().BorderThickness(newThickness);
+ const std::wstring_view punycode{ &punycodeBuffer[0], gsl::narrow_cast(punycodeLength) };
+ const std::wstring_view unicode{ &unicodeBuffer[0], gsl::narrow_cast(unicodeLength) };
- // Compute the location of the top left corner of the cell in DIPS
- const til::point locationInDIPs{ _toPosInDips(lastHoveredCell.Value()) };
+ // IdnToAscii/IdnToUnicode return the input string as is if it's all
+ // plain ASCII. But we don't know if the input URI is Punycode or not.
+ // --> It's non-Punycode and ASCII if it round-trips.
+ if (uri == punycode && uri == unicode)
+ {
+ return uri;
+ }
- // Move the border to the top left corner of the cell
- OverlayCanvas().SetLeft(HyperlinkTooltipBorder(), locationInDIPs.x - offset.x);
- OverlayCanvas().SetTop(HyperlinkTooltipBorder(), locationInDIPs.y - offset.y);
- }
- CATCH_LOG();
- }
+ return winrt::hstring{ fmt::format(FMT_COMPILE(L"{}\n({})"), punycode, unicode) };
+ }
+
+ void TermControl::_hoveredHyperlinkChanged(const IInspectable& /*sender*/, const IInspectable& /*args*/)
+ {
+ const auto lastHoveredCell = _core.HoveredCell();
+ if (!lastHoveredCell)
+ {
+ return;
}
+
+ const auto uriText = sanitizeURI(_core.HoveredUriText());
+ if (uriText.empty())
+ {
+ return;
+ }
+
+ const auto panel = SwapChainPanel();
+ const auto scale = panel.CompositionScaleX();
+ const auto offset = panel.ActualOffset();
+
+ // Update the tooltip with the URI
+ HoveredUri().Text(uriText);
+
+ // Set the border thickness so it covers the entire cell
+ const auto charSizeInPixels = CharacterDimensions();
+ const auto htInDips = charSizeInPixels.Height / scale;
+ const auto wtInDips = charSizeInPixels.Width / scale;
+ const Thickness newThickness{ wtInDips, htInDips, 0, 0 };
+ HyperlinkTooltipBorder().BorderThickness(newThickness);
+
+ // Compute the location of the top left corner of the cell in DIPS
+ const til::point locationInDIPs{ _toPosInDips(lastHoveredCell.Value()) };
+
+ // Move the border to the top left corner of the cell
+ OverlayCanvas().SetLeft(HyperlinkTooltipBorder(), locationInDIPs.x - offset.x);
+ OverlayCanvas().SetTop(HyperlinkTooltipBorder(), locationInDIPs.y - offset.y);
}
winrt::fire_and_forget TermControl::_updateSelectionMarkers(IInspectable /*sender*/, Control::UpdateSelectionMarkersEventArgs args)
diff --git a/src/cascadia/TerminalControl/TermControl.h b/src/cascadia/TerminalControl/TermControl.h
index d0844119231..334c9b2ddc1 100644
--- a/src/cascadia/TerminalControl/TermControl.h
+++ b/src/cascadia/TerminalControl/TermControl.h
@@ -338,7 +338,7 @@ namespace winrt::Microsoft::Terminal::Control::implementation
void _CurrentCursorPositionHandler(const IInspectable& sender, const CursorPositionEventArgs& eventArgs);
void _FontInfoHandler(const IInspectable& sender, const FontInfoEventArgs& eventArgs);
- winrt::fire_and_forget _hoveredHyperlinkChanged(IInspectable sender, IInspectable args);
+ void _hoveredHyperlinkChanged(const IInspectable& sender, const IInspectable& args);
winrt::fire_and_forget _updateSelectionMarkers(IInspectable sender, Control::UpdateSelectionMarkersEventArgs args);
void _coreFontSizeChanged(const int fontWidth,