feat(#283): render pasted Giphy/Tenor URLs inline behind a host allowlist

Pasted GIF links rendered as plain text because parseGif only matched the
compact forms. Adds GifHelper.resolveGifUrl, which maps any supported payload
to the URL that renders it, and widens coverage to the links people actually
paste.

Allowlist is deliberately narrow, Giphy and Tenor only. Off-list URLs return
null and stay plain tap-to-open links, never auto-fetched: auto-loading a
stranger's URL leaks the viewer's IP and enables tracking-pixel abuse, and
inline-rendering unmoderated hosts is a malware and inappropriate-content
vector. Tests pin the rejections, including a lookalike host
(giphy.com.evil.example) and a general image host (imgur).

Newly rendered:
- i.giphy.com/<id>.gif and .webp, the form a browser copy produces
- media.tenor.com and c.tenor.com direct assets

A tenor.com/view/<slug>-<id> PAGE url is deliberately NOT matched. The modern
Tenor CDN path uses an opaque hash that cannot be derived from the page id, so
resolving one requires the Tenor API and a key. Such a link stays plain rather
than silently rendering the wrong image. Supporting it is a separate decision.

Also removes the hardcoded 'https://media.giphy.com/media/$gifId/giphy.gif'
literal duplicated across all four render call sites. That duplication is the
same drift that caused the #284 notification regression, where a second
hand-rolled copy of GIF detection went stale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/329/head
Strycher 2 days ago
parent af6485f2e3
commit 643af20ddf

@ -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/<slug>-<id>` 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;
}
}

@ -642,8 +642,8 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
final enableTracing = settingsService.settings.enableMessageTracing;
final hashWidth = context.read<MeshCoreConnector>().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<ChannelChatScreen> {
? (_) => _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<ChannelChatScreen> {
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<ChannelChatScreen> {
),
),
),
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<ChannelChatScreen> {
textScale,
message.senderName,
)
else if (gifId != null)
else if (gifUrl != null)
Column(
crossAxisAlignment: isOutgoing
? CrossAxisAlignment.end
@ -752,8 +752,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
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<ChannelChatScreen> {
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<ChannelChatScreen> {
child: ValueListenableBuilder<TextEditingValue>(
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<ChannelChatScreen> {
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,

@ -621,8 +621,8 @@ class _ChatScreenState extends State<ChatScreen> {
child: ValueListenableBuilder<TextEditingValue>(
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<ChatScreen> {
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,

@ -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:<id>', () {
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,
);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.