From 1ab243f29e86bbab05f7eed00172ce53bb8ac430 Mon Sep 17 00:00:00 2001 From: vjrj Date: Sat, 11 Jul 2026 07:14:58 +0200 Subject: [PATCH] feat(chat): disallow links in messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local-first chat between people whose web of trust is still forming is a natural phishing/scam vector, so messages may not contain URLs. A conservative `containsUrl` rule (explicit scheme, www., or a common-TLD domain — but not "3.5kg" or "12.30") gates it: the composer warns and keeps the text for editing, and MessagesCubit.send drops any URL as a backstop. Incoming text was already non-tappable (plain selectable text). --- apps/app_seeds/lib/domain/message_rules.dart | 19 ++++++++ apps/app_seeds/lib/i18n/ast.i18n.json | 3 +- apps/app_seeds/lib/i18n/en.i18n.json | 3 +- apps/app_seeds/lib/i18n/es.i18n.json | 3 +- apps/app_seeds/lib/i18n/pt.i18n.json | 3 +- apps/app_seeds/lib/i18n/strings.g.dart | 4 +- apps/app_seeds/lib/i18n/strings_ast.g.dart | 2 + apps/app_seeds/lib/i18n/strings_en.g.dart | 4 ++ apps/app_seeds/lib/i18n/strings_es.g.dart | 2 + apps/app_seeds/lib/i18n/strings_pt.g.dart | 2 + apps/app_seeds/lib/state/messages_cubit.dart | 48 ++++++++----------- apps/app_seeds/lib/ui/chat_screen.dart | 8 ++++ .../test/domain/message_rules_test.dart | 31 ++++++++++++ .../test/state/messages_cubit_test.dart | 9 ++++ 14 files changed, 107 insertions(+), 34 deletions(-) create mode 100644 apps/app_seeds/lib/domain/message_rules.dart create mode 100644 apps/app_seeds/test/domain/message_rules_test.dart diff --git a/apps/app_seeds/lib/domain/message_rules.dart b/apps/app_seeds/lib/domain/message_rules.dart new file mode 100644 index 0000000..74f1a49 --- /dev/null +++ b/apps/app_seeds/lib/domain/message_rules.dart @@ -0,0 +1,19 @@ +/// Content rules for chat messages. +/// +/// Links are not allowed in messages: it keeps the local-first, offline chat +/// free of phishing/scam URLs between people who may not know each other yet +/// (the web of trust is still forming). Enforced on send; incoming text is never +/// rendered as a tappable link either (the bubble uses plain selectable text). +library; + +/// Matches an obvious URL: an explicit scheme, a `www.` host, or a +/// `domain.tld` with a common TLD. Kept deliberately conservative so ordinary +/// text ("3.5kg", "F1 hybrid") doesn't trip it, while real links do. +final RegExp _urlPattern = RegExp( + r'(https?://|www\.)\S+' + r'|\b[\w-]+\.(com|org|net|io|app|dev|es|eu|info|xyz|co|me|gg|link|shop|store|online|site|page|ru|cn|tk)\b', + caseSensitive: false, +); + +/// Whether [text] contains something that looks like a URL. +bool containsUrl(String text) => _urlPattern.hasMatch(text); diff --git a/apps/app_seeds/lib/i18n/ast.i18n.json b/apps/app_seeds/lib/i18n/ast.i18n.json index 2391428..03ded97 100644 --- a/apps/app_seeds/lib/i18n/ast.i18n.json +++ b/apps/app_seeds/lib/i18n/ast.i18n.json @@ -530,7 +530,8 @@ "g1Copied": "Direición Ğ1 copiada — apégala na to cartera", "today": "Güei", "yesterday": "Ayeri", - "sendError": "Nun se pudo unviar — comprueba la conexón" + "sendError": "Nun se pudo unviar — comprueba la conexón", + "noLinks": "Nun se permiten enllaces nos mensaxes" }, "trust": { "none": "Naide los avala entá", diff --git a/apps/app_seeds/lib/i18n/en.i18n.json b/apps/app_seeds/lib/i18n/en.i18n.json index fb01bce..31f878c 100644 --- a/apps/app_seeds/lib/i18n/en.i18n.json +++ b/apps/app_seeds/lib/i18n/en.i18n.json @@ -533,7 +533,8 @@ "g1Copied": "Ğ1 address copied — paste it in your wallet", "today": "Today", "yesterday": "Yesterday", - "sendError": "Couldn't send — check your connection" + "sendError": "Couldn't send — check your connection", + "noLinks": "Links aren't allowed in messages" }, "trust": { "none": "No one vouches for them yet", diff --git a/apps/app_seeds/lib/i18n/es.i18n.json b/apps/app_seeds/lib/i18n/es.i18n.json index 31281fa..8201646 100644 --- a/apps/app_seeds/lib/i18n/es.i18n.json +++ b/apps/app_seeds/lib/i18n/es.i18n.json @@ -532,7 +532,8 @@ "g1Copied": "Dirección Ğ1 copiada — pégala en tu cartera", "today": "Hoy", "yesterday": "Ayer", - "sendError": "No se pudo enviar — revisa tu conexión" + "sendError": "No se pudo enviar — revisa tu conexión", + "noLinks": "No se permiten enlaces en los mensajes" }, "trust": { "none": "Nadie los avala aún", diff --git a/apps/app_seeds/lib/i18n/pt.i18n.json b/apps/app_seeds/lib/i18n/pt.i18n.json index a56e202..4f4154e 100644 --- a/apps/app_seeds/lib/i18n/pt.i18n.json +++ b/apps/app_seeds/lib/i18n/pt.i18n.json @@ -529,7 +529,8 @@ "g1Copied": "Endereço Ğ1 copiado — cola-o na tua carteira", "today": "Hoje", "yesterday": "Ontem", - "sendError": "Não foi possível enviar — verifica a tua ligação" + "sendError": "Não foi possível enviar — verifica a tua ligação", + "noLinks": "Não são permitidos links nas mensagens" }, "trust": { "none": "Ainda ninguém os avaliza", diff --git a/apps/app_seeds/lib/i18n/strings.g.dart b/apps/app_seeds/lib/i18n/strings.g.dart index 9880afc..a792f80 100644 --- a/apps/app_seeds/lib/i18n/strings.g.dart +++ b/apps/app_seeds/lib/i18n/strings.g.dart @@ -4,9 +4,9 @@ /// To regenerate, run: `dart run slang` /// /// Locales: 4 -/// Strings: 1860 (465 per locale) +/// Strings: 1864 (466 per locale) /// -/// Built on 2026-07-11 at 04:45 UTC +/// Built on 2026-07-11 at 05:13 UTC // coverage:ignore-file // ignore_for_file: type=lint, unused_import diff --git a/apps/app_seeds/lib/i18n/strings_ast.g.dart b/apps/app_seeds/lib/i18n/strings_ast.g.dart index 5b7b371..641be76 100644 --- a/apps/app_seeds/lib/i18n/strings_ast.g.dart +++ b/apps/app_seeds/lib/i18n/strings_ast.g.dart @@ -772,6 +772,7 @@ class _Translations$chat$ast extends Translations$chat$en { @override String get today => 'Güei'; @override String get yesterday => 'Ayeri'; @override String get sendError => 'Nun se pudo unviar — comprueba la conexón'; + @override String get noLinks => 'Nun se permiten enllaces nos mensaxes'; } // Path: trust @@ -1624,6 +1625,7 @@ extension on TranslationsAst { 'chat.today' => 'Güei', 'chat.yesterday' => 'Ayeri', 'chat.sendError' => 'Nun se pudo unviar — comprueba la conexón', + 'chat.noLinks' => 'Nun se permiten enllaces nos mensaxes', 'trust.none' => 'Naide los avala entá', 'trust.count' => ({required Object n}) => 'Avalada por ${n}', 'trust.vouch' => 'Conozo a esta persona', diff --git a/apps/app_seeds/lib/i18n/strings_en.g.dart b/apps/app_seeds/lib/i18n/strings_en.g.dart index 613e78a..56a333b 100644 --- a/apps/app_seeds/lib/i18n/strings_en.g.dart +++ b/apps/app_seeds/lib/i18n/strings_en.g.dart @@ -1443,6 +1443,9 @@ class Translations$chat$en { /// en: 'Couldn't send — check your connection' String get sendError => 'Couldn\'t send — check your connection'; + + /// en: 'Links aren't allowed in messages' + String get noLinks => 'Links aren\'t allowed in messages'; } // Path: trust @@ -2544,6 +2547,7 @@ extension on Translations { 'chat.today' => 'Today', 'chat.yesterday' => 'Yesterday', 'chat.sendError' => 'Couldn\'t send — check your connection', + 'chat.noLinks' => 'Links aren\'t allowed in messages', 'trust.none' => 'No one vouches for them yet', 'trust.count' => ({required Object n}) => 'Vouched for by ${n}', 'trust.vouch' => 'I know this person', diff --git a/apps/app_seeds/lib/i18n/strings_es.g.dart b/apps/app_seeds/lib/i18n/strings_es.g.dart index a157b53..61ff03f 100644 --- a/apps/app_seeds/lib/i18n/strings_es.g.dart +++ b/apps/app_seeds/lib/i18n/strings_es.g.dart @@ -774,6 +774,7 @@ class _Translations$chat$es extends Translations$chat$en { @override String get today => 'Hoy'; @override String get yesterday => 'Ayer'; @override String get sendError => 'No se pudo enviar — revisa tu conexión'; + @override String get noLinks => 'No se permiten enlaces en los mensajes'; } // Path: trust @@ -1628,6 +1629,7 @@ extension on TranslationsEs { 'chat.today' => 'Hoy', 'chat.yesterday' => 'Ayer', 'chat.sendError' => 'No se pudo enviar — revisa tu conexión', + 'chat.noLinks' => 'No se permiten enlaces en los mensajes', 'trust.none' => 'Nadie los avala aún', 'trust.count' => ({required Object n}) => 'Avalada por ${n}', 'trust.vouch' => 'Conozco a esta persona', diff --git a/apps/app_seeds/lib/i18n/strings_pt.g.dart b/apps/app_seeds/lib/i18n/strings_pt.g.dart index 4590a46..dad90dc 100644 --- a/apps/app_seeds/lib/i18n/strings_pt.g.dart +++ b/apps/app_seeds/lib/i18n/strings_pt.g.dart @@ -771,6 +771,7 @@ class _Translations$chat$pt extends Translations$chat$en { @override String get today => 'Hoje'; @override String get yesterday => 'Ontem'; @override String get sendError => 'Não foi possível enviar — verifica a tua ligação'; + @override String get noLinks => 'Não são permitidos links nas mensagens'; } // Path: trust @@ -1622,6 +1623,7 @@ extension on TranslationsPt { 'chat.today' => 'Hoje', 'chat.yesterday' => 'Ontem', 'chat.sendError' => 'Não foi possível enviar — verifica a tua ligação', + 'chat.noLinks' => 'Não são permitidos links nas mensagens', 'trust.none' => 'Ainda ninguém os avaliza', 'trust.count' => ({required Object n}) => 'Avalizada por ${n}', 'trust.vouch' => 'Conheço esta pessoa', diff --git a/apps/app_seeds/lib/state/messages_cubit.dart b/apps/app_seeds/lib/state/messages_cubit.dart index 8e6966c..ee7a210 100644 --- a/apps/app_seeds/lib/state/messages_cubit.dart +++ b/apps/app_seeds/lib/state/messages_cubit.dart @@ -4,16 +4,13 @@ import 'package:commons_core/commons_core.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import '../domain/message_rules.dart'; import '../services/message_store.dart'; /// A 1:1 conversation with one peer. Holds the running message list plus send /// status. Transport-agnostic — depends on [MessageTransport], not the relay. class ChatState extends Equatable { - const ChatState({ - this.messages = const [], - this.sending = false, - this.error, - }); + const ChatState({this.messages = const [], this.sending = false, this.error}); /// Messages in this conversation, oldest first. Incoming ones arrive from the /// inbox; our own are appended optimistically on send (NIP-17 wraps don't come @@ -26,12 +23,11 @@ class ChatState extends Equatable { List? messages, bool? sending, String? Function()? error, - }) => - ChatState( - messages: messages ?? this.messages, - sending: sending ?? this.sending, - error: error != null ? error() : this.error, - ); + }) => ChatState( + messages: messages ?? this.messages, + sending: sending ?? this.sending, + error: error != null ? error() : this.error, + ); @override List get props => [messages, sending, error]; @@ -47,9 +43,9 @@ class MessagesCubit extends Cubit { required this.selfPubkey, MessageStore? store, Future Function()? onDispose, - }) : _store = store, - _onDispose = onDispose, - super(const ChatState()); + }) : _store = store, + _onDispose = onDispose, + super(const ChatState()); final MessageTransport? _transport; final MessageStore? _store; @@ -77,15 +73,12 @@ class MessagesCubit extends Cubit { Future start() async { final transport = _transport; if (transport != null) { - _sub = transport.inbox().listen( - (message) async { - if (message.fromPubkey != peerPubkey) return; // another conversation - if (!_seen.add(_key(message))) return; // already shown / re-delivered - await _store?.append(peerPubkey, message); - emit(state.copyWith(messages: [...state.messages, message])); - }, - onError: (Object e) => emit(state.copyWith(error: () => '$e')), - ); + _sub = transport.inbox().listen((message) async { + if (message.fromPubkey != peerPubkey) return; // another conversation + if (!_seen.add(_key(message))) return; // already shown / re-delivered + await _store?.append(peerPubkey, message); + emit(state.copyWith(messages: [...state.messages, message])); + }, onError: (Object e) => emit(state.copyWith(error: () => '$e'))); } final history = await _store?.history(peerPubkey); if (history != null && history.isNotEmpty) { @@ -102,7 +95,9 @@ class MessagesCubit extends Cubit { Future send(String text) async { final transport = _transport; final trimmed = text.trim(); - if (transport == null || trimmed.isEmpty) return; + // Links aren't allowed in messages (the UI warns; this is the backstop so + // nothing sends a URL programmatically). See message_rules.dart. + if (transport == null || trimmed.isEmpty || containsUrl(trimmed)) return; emit(state.copyWith(sending: true, error: () => null)); try { await transport.send(toPubkey: peerPubkey, text: trimmed); @@ -113,10 +108,7 @@ class MessagesCubit extends Cubit { ); _seen.add(_key(mine)); await _store?.append(peerPubkey, mine); - emit(state.copyWith( - sending: false, - messages: [...state.messages, mine], - )); + emit(state.copyWith(sending: false, messages: [...state.messages, mine])); } catch (e) { emit(state.copyWith(sending: false, error: () => '$e')); } diff --git a/apps/app_seeds/lib/ui/chat_screen.dart b/apps/app_seeds/lib/ui/chat_screen.dart index 735d39c..b6d1cca 100644 --- a/apps/app_seeds/lib/ui/chat_screen.dart +++ b/apps/app_seeds/lib/ui/chat_screen.dart @@ -8,6 +8,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../di/injector.dart'; import '../domain/chat_timeline.dart'; +import '../domain/message_rules.dart'; import '../i18n/strings.g.dart'; import '../services/message_store.dart'; import '../services/profile_cache.dart'; @@ -173,6 +174,13 @@ class _ChatScreenState extends State { final cubit = _messages; if (cubit == null || _input.text.trim().isEmpty) return; final text = _input.text; + // Links aren't allowed — warn and keep the text so it can be edited. + if (containsUrl(text)) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(context.t.chat.noLinks))); + return; + } _input.clear(); await cubit.send(text); } diff --git a/apps/app_seeds/test/domain/message_rules_test.dart b/apps/app_seeds/test/domain/message_rules_test.dart new file mode 100644 index 0000000..c50d561 --- /dev/null +++ b/apps/app_seeds/test/domain/message_rules_test.dart @@ -0,0 +1,31 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tane/domain/message_rules.dart'; + +void main() { + group('containsUrl', () { + test('flags explicit schemes and www', () { + expect(containsUrl('look https://evil.example/login'), isTrue); + expect(containsUrl('http://foo'), isTrue); + expect(containsUrl('go to www.example.org now'), isTrue); + }); + + test('flags bare domains with a common TLD', () { + expect(containsUrl('see tomate.com'), isTrue); + expect(containsUrl('my-shop.store has it'), isTrue); + expect(containsUrl('grab it at bit.ly'), isFalse); // ly not in the list + }); + + test('leaves ordinary seed talk alone', () { + expect(containsUrl('tengo 3.5kg de tomate rosa'), isFalse); + expect(containsUrl('variedad F1 vs polinización abierta'), isFalse); + expect(containsUrl('nos vemos a las 12.30'), isFalse); + expect(containsUrl('semillas de calabaza, ¿cambiamos?'), isFalse); + expect(containsUrl(''), isFalse); + }); + + test('is case-insensitive', () { + expect(containsUrl('HTTPS://EXAMPLE.COM'), isTrue); + expect(containsUrl('Example.COM'), isTrue); + }); + }); +} diff --git a/apps/app_seeds/test/state/messages_cubit_test.dart b/apps/app_seeds/test/state/messages_cubit_test.dart index 10ca735..d16d8c3 100644 --- a/apps/app_seeds/test/state/messages_cubit_test.dart +++ b/apps/app_seeds/test/state/messages_cubit_test.dart @@ -100,6 +100,15 @@ void main() { }, ); + test('a message containing a link is not sent', () async { + final transport = FakeMessageTransport(); + final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me); + await cubit.send('check https://evil.example/login'); + expect(transport.sent, isEmpty); + expect(cubit.state.messages, isEmpty); + await cubit.close(); + }); + test('empty/whitespace text is not sent', () async { final transport = FakeMessageTransport(); final cubit = MessagesCubit(transport, peerPubkey: peer, selfPubkey: me);