diff --git a/lib/helpers/gif_helper.dart b/lib/helpers/gif_helper.dart index f97ee3a..5cbf244 100644 --- a/lib/helpers/gif_helper.dart +++ b/lib/helpers/gif_helper.dart @@ -31,7 +31,14 @@ class GifHelper { final pageMatch = RegExp( r'^(?:https?:\/\/)?giphy\.com\/gifs\/(?:[^/?]*-)?([A-Za-z0-9_]+)\/?$', ).firstMatch(trimmed); - return pageMatch?.group(1); + if (pageMatch != null) { + return pageMatch.group(1); + } + // The CDN form people actually paste out of a browser. (#283) + final cdnMatch = RegExp( + r'^(?:https?:\/\/)?i\.giphy\.com\/([A-Za-z0-9_-]+)\.(?:gif|webp)$', + ).firstMatch(trimmed); + return cdnMatch?.group(1); } /// Encode a GIF as a Giphy page URL, a format parseGif() also parses. @@ -43,4 +50,31 @@ class GifHelper { static String encodeGif(String gifId) { return 'https://giphy.com/gifs/$gifId'; } + + /// Tenor's own CDN, the only Tenor form that is directly renderable. + /// + /// A `tenor.com/view/-` page URL is deliberately NOT matched: the + /// modern CDN path uses an opaque hash that cannot be derived from the page + /// id, so resolving one needs the Tenor API. Such a link stays plain text + /// rather than silently rendering the wrong image. (#283) + static final RegExp _tenorMedia = RegExp( + r'^(?:https?:\/\/)?(?:media|c)\.tenor\.com\/[A-Za-z0-9_\-\/]+\.(?:gif|webp)$', + ); + + /// The URL that renders [text], or null if it is not a GIF we will display. + /// + /// Rendering is restricted to an allowlist of curated GIF platforms (Giphy + /// and Tenor). Anything off-list returns null and stays a plain tap-to-open + /// link: auto-fetching an arbitrary URL leaks the viewer's IP and enables + /// tracking-pixel abuse, and inline-rendering unmoderated hosts is a malware + /// and inappropriate-content vector. Widening the allowlist is a deliberate + /// decision, not a convenience. (#283) + static String? resolveGifUrl(String text) { + final gifId = parseGif(text); + if (gifId != null) { + return 'https://media.giphy.com/media/$gifId/giphy.gif'; + } + final trimmed = text.trim().replaceFirst(RegExp(r'^@\[[^\]]+\]\s+'), ''); + return _tenorMedia.hasMatch(trimmed) ? trimmed : null; + } } diff --git a/lib/screens/channel_chat_screen.dart b/lib/screens/channel_chat_screen.dart index 60f4c87..68cc7dd 100644 --- a/lib/screens/channel_chat_screen.dart +++ b/lib/screens/channel_chat_screen.dart @@ -642,8 +642,8 @@ class _ChannelChatScreenState extends State { final enableTracing = settingsService.settings.enableMessageTracing; final hashWidth = context.read().pathHashByteWidth; final isOutgoing = message.isOutgoing; - final gifId = GifHelper.parseGif(message.text); - final gifReplyName = gifId != null + final gifUrl = GifHelper.resolveGifUrl(message.text); + final gifReplyName = gifUrl != null ? TranslatedMessageContent.leadingReplyName(message.text) : null; final poi = parseMarkerText(message.text); @@ -689,7 +689,7 @@ class _ChannelChatScreenState extends State { ? (_) => _showMessageActions(message) : null, child: Container( - padding: gifId != null + padding: gifUrl != null ? const EdgeInsets.all(4) : const EdgeInsets.symmetric(horizontal: 12, vertical: 8), constraints: BoxConstraints( @@ -706,7 +706,7 @@ class _ChannelChatScreenState extends State { children: [ if (!isOutgoing) ...[ Padding( - padding: gifId != null + padding: gifUrl != null ? const EdgeInsets.only( left: 8, top: 4, @@ -722,7 +722,7 @@ class _ChannelChatScreenState extends State { ), ), ), - if (gifId == null) const SizedBox(height: 4), + if (gifUrl == null) const SizedBox(height: 4), ], if (poi != null) _buildPoiMessage( @@ -732,7 +732,7 @@ class _ChannelChatScreenState extends State { textScale, message.senderName, ) - else if (gifId != null) + else if (gifUrl != null) Column( crossAxisAlignment: isOutgoing ? CrossAxisAlignment.end @@ -752,8 +752,7 @@ class _ChannelChatScreenState extends State { ClipRRect( borderRadius: BorderRadius.circular(8), child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', + url: gifUrl, backgroundColor: Colors.transparent, fallbackTextColor: isOutgoing ? Theme.of(context) @@ -797,7 +796,7 @@ class _ChannelChatScreenState extends State { if (enableTracing && displayPath.isNotEmpty) ...[ const SizedBox(height: 2), Padding( - padding: gifId != null + padding: gifUrl != null ? const EdgeInsets.symmetric(horizontal: 8) : EdgeInsets.zero, child: Text( @@ -1161,8 +1160,8 @@ class _ChannelChatScreenState extends State { child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { - final gifId = GifHelper.parseGif(value.text); - if (gifId != null) { + final gifUrl = GifHelper.resolveGifUrl(value.text); + if (gifUrl != null) { return Focus( autofocus: true, onKeyEvent: (node, event) { @@ -1181,8 +1180,7 @@ class _ChannelChatScreenState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(12), child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', + url: gifUrl, backgroundColor: Theme.of( context, ).colorScheme.surfaceContainerHighest, diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 156378e..c43886b 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -621,8 +621,8 @@ class _ChatScreenState extends State { child: ValueListenableBuilder( valueListenable: _textController, builder: (context, value, child) { - final gifId = GifHelper.parseGif(value.text); - if (gifId != null) { + final gifUrl = GifHelper.resolveGifUrl(value.text); + if (gifUrl != null) { return Focus( autofocus: true, onKeyEvent: (node, event) { @@ -641,8 +641,7 @@ class _ChatScreenState extends State { child: ClipRRect( borderRadius: BorderRadius.circular(12), child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', + url: gifUrl, backgroundColor: colorScheme.surfaceContainerHighest, fallbackTextColor: colorScheme.onSurface @@ -1866,7 +1865,7 @@ class _MessageBubble extends StatelessWidget { final enableTracing = settingsService.settings.enableMessageTracing; final isOutgoing = message.isOutgoing; final colorScheme = Theme.of(context).colorScheme; - final gifId = GifHelper.parseGif(message.text); + final gifUrl = GifHelper.resolveGifUrl(message.text); final poi = parseMarkerText(message.text); final isFailed = message.status == MessageStatus.failed; final bubbleColor = isFailed @@ -1915,7 +1914,7 @@ class _MessageBubble extends StatelessWidget { ], Flexible( child: Container( - padding: gifId != null + padding: gifUrl != null ? const EdgeInsets.all(4) : const EdgeInsets.symmetric( horizontal: 12, @@ -1933,7 +1932,7 @@ class _MessageBubble extends StatelessWidget { children: [ if (!isOutgoing) ...[ Padding( - padding: gifId != null + padding: gifUrl != null ? const EdgeInsets.only( left: 8, top: 4, @@ -1949,7 +1948,7 @@ class _MessageBubble extends StatelessWidget { ), ), ), - if (gifId == null) const SizedBox(height: 4), + if (gifUrl == null) const SizedBox(height: 4), ], if (poi != null) _buildPoiMessage( @@ -1974,14 +1973,13 @@ class _MessageBubble extends StatelessWidget { ) : null, ) - else if (gifId != null) + else if (gifUrl != null) Stack( children: [ ClipRRect( borderRadius: BorderRadius.circular(12), child: GifMessage( - url: - 'https://media.giphy.com/media/$gifId/giphy.gif', + url: gifUrl, backgroundColor: Colors.transparent, fallbackTextColor: textColor.withValues( alpha: 0.7, @@ -2053,7 +2051,7 @@ class _MessageBubble extends StatelessWidget { if (isOutgoing && message.retryCount > 0) ...[ const SizedBox(height: 4), Padding( - padding: gifId != null + padding: gifUrl != null ? const EdgeInsets.symmetric(horizontal: 8) : EdgeInsets.zero, child: Text( @@ -2074,7 +2072,7 @@ class _MessageBubble extends StatelessWidget { ], const SizedBox(height: 4), Padding( - padding: gifId != null + padding: gifUrl != null ? const EdgeInsets.only( left: 8, right: 8, diff --git a/test/helpers/gif_media_url_test.dart b/test/helpers/gif_media_url_test.dart new file mode 100644 index 0000000..7ec3f5b --- /dev/null +++ b/test/helpers/gif_media_url_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/gif_helper.dart'; + +// #283: render pasted GIF/media URLs inline, but ONLY from an allowlist of +// curated hosts (Giphy + Tenor). Anything off-list must stay a plain link and +// must never be auto-fetched: auto-loading a stranger's URL leaks the viewer's +// IP and enables tracking-pixel / read-receipt abuse, and inline-rendering +// arbitrary hosts is a malware and inappropriate-content vector. +void main() { + const gifId = 'zaMiq1BvCdAVIiu3Mb'; + const giphyRender = 'https://media.giphy.com/media/$gifId/giphy.gif'; + + group('Giphy forms all resolve to a renderable URL', () { + test('the app payload (giphy page URL)', () { + expect( + GifHelper.resolveGifUrl('https://giphy.com/gifs/$gifId'), + giphyRender, + ); + }); + + test('legacy g:', () { + expect(GifHelper.resolveGifUrl('g:$gifId'), giphyRender); + }); + + test('media.giphy.com direct asset', () { + expect(GifHelper.resolveGifUrl(giphyRender), giphyRender); + }); + + test('i.giphy.com webp, the form users actually paste', () { + expect( + GifHelper.resolveGifUrl('https://i.giphy.com/$gifId.webp'), + giphyRender, + ); + }); + + test('i.giphy.com gif', () { + expect( + GifHelper.resolveGifUrl('https://i.giphy.com/$gifId.gif'), + giphyRender, + ); + }); + + test('a reply carrying a pasted Giphy URL', () { + expect( + GifHelper.resolveGifUrl('@[Some Node] https://i.giphy.com/$gifId.webp'), + giphyRender, + ); + }); + }); + + group('Tenor direct media resolves to itself', () { + test('media.tenor.com gif', () { + const url = 'https://media.tenor.com/abc123XYZ/happy-dance.gif'; + expect(GifHelper.resolveGifUrl(url), url); + }); + + test('c.tenor.com gif', () { + const url = 'https://c.tenor.com/abc123XYZ/tenor.gif'; + expect(GifHelper.resolveGifUrl(url), url); + }); + + test('media.tenor.com webp', () { + const url = 'https://media.tenor.com/abc123XYZ/thing.webp'; + expect(GifHelper.resolveGifUrl(url), url); + }); + }); + + group('off-allowlist URLs must NOT render (never auto-fetch)', () { + test('an arbitrary https image', () { + expect( + GifHelper.resolveGifUrl('https://evil.example.com/tracker.gif'), + isNull, + ); + }); + + test('a lookalike host is not trusted', () { + expect( + GifHelper.resolveGifUrl('https://giphy.com.evil.example/$gifId.gif'), + isNull, + ); + }); + + test('a general image host (imgur) is off-list', () { + expect(GifHelper.resolveGifUrl('https://i.imgur.com/abc123.gif'), isNull); + }); + + test('plain text is not a GIF', () { + expect(GifHelper.resolveGifUrl('hey are you there'), isNull); + }); + + test('a non-media URL is not a GIF', () { + expect(GifHelper.resolveGifUrl('https://example.com/page'), isNull); + }); + + test('a tenor PAGE url does not render (needs API resolution)', () { + // Modern Tenor CDN paths use an opaque hash not derivable from the page + // id, so a page URL cannot be resolved without the Tenor API. Stays a + // plain link rather than silently rendering the wrong thing. + expect( + GifHelper.resolveGifUrl('https://tenor.com/view/happy-dance-12345'), + isNull, + ); + }); + }); +}