From f707b75060a466db99255a0d89cad159c9479c7d Mon Sep 17 00:00:00 2001 From: Wejdan Al Amri <280932308+WejdanBa-CS@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:43:18 +0300 Subject: [PATCH] Harden Trace and registry against SSRF and scan abuse. Add safe URL validation, trace rate limiting, and OWASP control docs aligned with Givy. Co-authored-by: Cursor --- SECURITY.md | 22 ++++++ lib/core/claim_registry.dart | 39 ++++++++-- lib/core/safe_url.dart | 134 +++++++++++++++++++++++++++++++++ lib/core/social_platforms.dart | 30 +++++++- lib/core/trace_store.dart | 7 +- lib/core/url_tracer.dart | 34 ++++++--- test/safe_url_test.dart | 66 ++++++++++++++++ test/trace_test.dart | 20 +++++ 8 files changed, 328 insertions(+), 24 deletions(-) create mode 100644 lib/core/safe_url.dart create mode 100644 test/safe_url_test.dart diff --git a/SECURITY.md b/SECURITY.md index 9ff4ace..c68b0b9 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -47,3 +47,25 @@ Good-faith research that avoids privacy violations and service disruption is app Android SHA-1 fingerprints in docs are **not secrets** (they appear in signed APKs) but keep upload keystore passwords local only. CI runs a basic secret pattern scan on every push. + +## OWASP-aligned controls (Cheat Sheet Series) + +| Control | Status | Notes | +|--------|--------|-------| +| **Authentication** | Strong | PBKDF2-SHA256, TOTP 2FA, lockout, secure session storage | +| **Cryptography** | Strong | Claim keys and watermarks stay on-device; recovery kit integrity checks | +| **Input validation** | Hardened | Trace URLs validated in `lib/core/safe_url.dart` — http(s) only, no private/metadata hosts, no embedded credentials | +| **SSRF (Trace / registry)** | Hardened | Block localhost, RFC1918, link-local, and CGNAT ranges before any outbound fetch; registry base must be **https** | +| **Rate limiting** | Hardened | Trace scans capped at 40/hour per device session (`TraceRateLimiter`) | +| **Logging / errors** | Good | Neutral auth errors; remote registry failures logged without leaking secrets | +| **Secrets** | Good | OAuth and keystore material via env / dart-define only; CI secret scan | +| **Transport** | Good | Registry requires HTTPS; Trace allows public http(s) media URLs only after host validation | + +### Trace URL policy + +- Allowed: public `http://` and `https://` media or social page URLs +- Blocked: `file://`, `ftp://`, `localhost`, `.local`, `127.0.0.0/8`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `169.254.0.0/16`, `100.64.0.0/10`, URLs with userinfo + +### Optional registry + +Set `SIGNATA_REGISTRY_URL` to a **public https** endpoint. Invalid or private URLs are ignored at configure time. Claim references in remote lookups are length-capped and URL-encoded. diff --git a/lib/core/claim_registry.dart b/lib/core/claim_registry.dart index 698c32c..b7ee933 100644 --- a/lib/core/claim_registry.dart +++ b/lib/core/claim_registry.dart @@ -13,6 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'auth.dart'; import 'local_data.dart'; +import 'safe_url.dart'; import 'trace_models.dart'; class ClaimRegistry { @@ -28,7 +29,25 @@ class ClaimRegistry { ); static void configure({String? remoteBaseUrl}) { - if (remoteBaseUrl != null) ClaimRegistry.remoteBaseUrl = remoteBaseUrl; + if (remoteBaseUrl == null) return; + final trimmed = remoteBaseUrl.trim(); + if (trimmed.isEmpty) { + ClaimRegistry.remoteBaseUrl = ''; + return; + } + final parsed = SafeUrl.parseRegistryBase(trimmed); + if (!parsed.isOk) { + debugPrint('Invalid registry URL ignored: ${parsed.error}'); + ClaimRegistry.remoteBaseUrl = ''; + return; + } + ClaimRegistry.remoteBaseUrl = parsed.uri!.toString().replaceAll(RegExp(r'/+$'), ''); + } + + static Uri? get _remoteBaseUri { + if (!hasRemote) return null; + final parsed = SafeUrl.parseRegistryBase(remoteBaseUrl); + return parsed.isOk ? parsed.uri : null; } static bool get hasRemote => remoteBaseUrl.trim().isNotEmpty; @@ -78,14 +97,16 @@ class ClaimRegistry { Future findByReference(String reference) async { final needle = reference.trim().toUpperCase(); - if (needle.isEmpty) return null; + if (needle.isEmpty || needle.length > 128) return null; for (final claim in await listLocal()) { if (claim.reference.toUpperCase() == needle) return claim; } - if (hasRemote) { + final base = _remoteBaseUri; + if (base != null) { try { - final uri = Uri.parse('${remoteBaseUrl.replaceAll(RegExp(r'/+$'), '')}' - '/claims/${Uri.encodeComponent(reference)}'); + final uri = base.replace( + path: '${base.path.replaceAll(RegExp(r'/+$'), '')}/claims/${Uri.encodeComponent(needle)}', + ); final response = await http.get(uri).timeout(const Duration(seconds: 12)); if (response.statusCode == 200) { final decoded = jsonDecode(response.body); @@ -128,10 +149,12 @@ class ClaimRegistry { note: note, ); - if (hasRemote) { + final base = _remoteBaseUri; + if (base != null) { try { - final uri = Uri.parse( - '${remoteBaseUrl.replaceAll(RegExp(r'/+$'), '')}/claims'); + final uri = base.replace( + path: '${base.path.replaceAll(RegExp(r'/+$'), '')}/claims', + ); final response = await http .post( uri, diff --git a/lib/core/safe_url.dart b/lib/core/safe_url.dart new file mode 100644 index 0000000..2c55674 --- /dev/null +++ b/lib/core/safe_url.dart @@ -0,0 +1,134 @@ +/// OWASP-oriented URL guards for Trace and optional claim registry. +library; + +/// Result of validating a user-supplied http(s) URL. +class SafeUrlResult { + const SafeUrlResult.ok(this.uri) : error = null; + const SafeUrlResult.err(this.error) : uri = null; + + final Uri? uri; + final String? error; + + bool get isOk => uri != null; +} + +/// Blocks private networks, metadata endpoints, and non-http(s) schemes (SSRF). +class SafeUrl { + SafeUrl._(); + + static const blockedHostnames = { + 'localhost', + 'localhost.localdomain', + 'metadata.google.internal', + }; + + /// Public http(s) URL suitable for Trace downloads. + static SafeUrlResult parseTraceUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return const SafeUrlResult.err('Enter a URL.'); + } + final uri = Uri.tryParse(trimmed); + if (uri == null) { + return const SafeUrlResult.err('Enter a valid http(s) URL.'); + } + if (uri.scheme != 'http' && uri.scheme != 'https') { + return const SafeUrlResult.err('Only http and https URLs are allowed.'); + } + if (uri.userInfo.isNotEmpty) { + return const SafeUrlResult.err('URLs with embedded credentials are not allowed.'); + } + final host = uri.host.toLowerCase(); + if (host.isEmpty) { + return const SafeUrlResult.err('Enter a valid http(s) URL.'); + } + if (blockedHostnames.contains(host) || host.endsWith('.local')) { + return const SafeUrlResult.err('That URL points to a private or local host.'); + } + if (_isPrivateOrReservedHost(host)) { + return const SafeUrlResult.err('Private or internal network URLs are not allowed.'); + } + return SafeUrlResult.ok(uri); + } + + /// Remote registry base must be https and not point at private networks. + static SafeUrlResult parseRegistryBase(String raw) { + final trimmed = raw.trim().replaceAll(RegExp(r'/+$'), ''); + if (trimmed.isEmpty) { + return const SafeUrlResult.err('Registry URL is empty.'); + } + final uri = Uri.tryParse(trimmed); + if (uri == null) { + return const SafeUrlResult.err('Enter a valid registry URL.'); + } + if (uri.scheme != 'https') { + return const SafeUrlResult.err('Registry URL must use https.'); + } + if (uri.userInfo.isNotEmpty) { + return const SafeUrlResult.err('Registry URL cannot include credentials.'); + } + final host = uri.host.toLowerCase(); + if (host.isEmpty) { + return const SafeUrlResult.err('Enter a valid registry URL.'); + } + if (blockedHostnames.contains(host) || host.endsWith('.local')) { + return const SafeUrlResult.err('Registry URL cannot point to a local host.'); + } + if (_isPrivateOrReservedHost(host)) { + return const SafeUrlResult.err('Registry URL cannot point to a private network.'); + } + return SafeUrlResult.ok(uri); + } + + static bool _isPrivateOrReservedHost(String host) { + if (host == '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd')) { + return true; + } + final ip = _parseIpv4(host); + if (ip == null) return false; + final a = ip[0]; + final b = ip[1]; + if (a == 127) return true; + if (a == 10) return true; + if (a == 0) return true; + if (a == 169 && b == 254) return true; + if (a == 192 && b == 168) return true; + if (a == 172 && b >= 16 && b <= 31) return true; + if (a == 100 && b >= 64 && b <= 127) return true; // CGNAT + return false; + } + + static List? _parseIpv4(String host) { + final parts = host.split('.'); + if (parts.length != 4) return null; + final out = []; + for (final part in parts) { + final n = int.tryParse(part); + if (n == null || n < 0 || n > 255) return null; + out.add(n); + } + return out; + } +} + +/// Simple in-process rate limiter for Trace scans (per device session). +class TraceRateLimiter { + TraceRateLimiter._(); + + static final TraceRateLimiter instance = TraceRateLimiter._(); + + static const maxScansPerHour = 40; + static const window = Duration(hours: 1); + + final List _events = []; + + bool allow() { + final now = DateTime.now().toUtc(); + _events.removeWhere((t) => now.difference(t) > window); + if (_events.length >= maxScansPerHour) return false; + _events.add(now); + return true; + } + + void resetForTests() => _events.clear(); +} diff --git a/lib/core/social_platforms.dart b/lib/core/social_platforms.dart index b7c5c74..ab06a62 100644 --- a/lib/core/social_platforms.dart +++ b/lib/core/social_platforms.dart @@ -6,6 +6,8 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; +import 'safe_url.dart'; + enum SocialPlatform { instagram, tiktok, x, other } class SocialPlatformInfo { @@ -126,14 +128,25 @@ class SocialMediaResolver { 'Mozilla/5.0 (compatible; SignataTrace/1.0; +https://signata.app)'; Future resolve(String rawUrl) async { - final url = rawUrl.trim(); + final parsed = SafeUrl.parseTraceUrl(rawUrl); + if (!parsed.isOk) { + final platform = SocialPlatformInfo.fromUrl(rawUrl); + if (platform == null) return null; + return ResolvedSocialMedia( + platform: platform, + pageUrl: rawUrl.trim(), + mediaUrl: null, + note: parsed.error, + ); + } + final url = parsed.uri!.toString(); final platform = SocialPlatformInfo.fromUrl(url); if (platform == null) return null; try { final response = await http .get( - Uri.parse(url), + parsed.uri!, headers: { 'User-Agent': _ua, 'Accept': 'text/html,application/xhtml+xml', @@ -178,10 +191,21 @@ class SocialMediaResolver { ); } + final absolute = _absolutize(mediaUrl, url); + final mediaParsed = SafeUrl.parseTraceUrl(absolute); + if (!mediaParsed.isOk) { + return ResolvedSocialMedia( + platform: platform, + pageUrl: url, + mediaUrl: null, + note: mediaParsed.error ?? 'Resolved media URL was blocked for security.', + ); + } + return ResolvedSocialMedia( platform: platform, pageUrl: url, - mediaUrl: _absolutize(mediaUrl, url), + mediaUrl: mediaParsed.uri!.toString(), note: null, ); } catch (error) { diff --git a/lib/core/trace_store.dart b/lib/core/trace_store.dart index e7e162f..fbc9620 100644 --- a/lib/core/trace_store.dart +++ b/lib/core/trace_store.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'package:shared_preferences/shared_preferences.dart'; import 'local_data.dart'; +import 'safe_url.dart'; import 'trace_models.dart'; class TraceStore { @@ -52,7 +53,11 @@ class TraceStore { Future addWatchTarget(String url, {String? label}) async { await _ensureMigrated(); - final normalized = url.trim(); + final parsed = SafeUrl.parseTraceUrl(url); + if (!parsed.isOk) { + throw ArgumentError(parsed.error ?? 'Invalid URL.'); + } + final normalized = parsed.uri!.toString(); final existing = List.from(await listWatchTargets()); final prior = existing.where((w) => w.url == normalized).toList(); if (prior.isNotEmpty) return prior.first; diff --git a/lib/core/url_tracer.dart b/lib/core/url_tracer.dart index 9b9dfdf..2cb5f80 100644 --- a/lib/core/url_tracer.dart +++ b/lib/core/url_tracer.dart @@ -10,6 +10,7 @@ import 'claim_crypto.dart'; import 'claim_registry.dart'; import 'image_watermark.dart'; import 'pdf_fingerprint.dart'; +import 'safe_url.dart'; import 'social_platforms.dart'; import 'trace_models.dart'; import 'trace_store.dart'; @@ -39,10 +40,16 @@ class UrlTracer { bool persist = true, bool addToWatchlist = false, }) async { - final url = rawUrl.trim(); - if (!_looksLikeHttpUrl(url)) { - throw ArgumentError('Enter a valid http(s) URL to a media file.'); + if (!TraceRateLimiter.instance.allow()) { + throw ArgumentError( + 'Too many trace scans this hour. Try again later.', + ); + } + final parsed = SafeUrl.parseTraceUrl(rawUrl); + if (!parsed.isOk) { + throw ArgumentError(parsed.error ?? 'Invalid URL.'); } + final url = parsed.uri!.toString(); var fetchUrl = url; String? socialNote; @@ -51,7 +58,13 @@ class UrlTracer { final resolved = await SocialMediaResolver.instance.resolve(url); socialNote = resolved?.note; if (resolved?.mediaUrl != null && resolved!.mediaUrl!.isNotEmpty) { - fetchUrl = resolved.mediaUrl!; + final mediaParsed = SafeUrl.parseTraceUrl(resolved.mediaUrl!); + if (mediaParsed.isOk) { + fetchUrl = mediaParsed.uri!.toString(); + } else { + socialNote = mediaParsed.error ?? + 'Resolved media URL was blocked for security.'; + } } else if (resolved != null) { final sighting = TraceSighting( id: _id(), @@ -85,8 +98,12 @@ class UrlTracer { Uint8List bytes; String? contentType; try { + final fetchUri = SafeUrl.parseTraceUrl(fetchUrl); + if (!fetchUri.isOk) { + throw Exception(fetchUri.error ?? 'Blocked URL.'); + } final response = await http.get( - Uri.parse(fetchUrl), + fetchUri.uri!, headers: { 'User-Agent': 'Mozilla/5.0 (compatible; SignataTrace/1.0; +https://signata.app)', @@ -254,13 +271,6 @@ class UrlTracer { return out; } - static bool _looksLikeHttpUrl(String url) { - final uri = Uri.tryParse(url); - return uri != null && - (uri.scheme == 'http' || uri.scheme == 'https') && - uri.host.isNotEmpty; - } - static bool _looksLikeDirectMedia(String url) { final path = Uri.tryParse(url)?.path.toLowerCase() ?? url.toLowerCase(); return path.endsWith('.png') || diff --git a/test/safe_url_test.dart b/test/safe_url_test.dart new file mode 100644 index 0000000..0e2d845 --- /dev/null +++ b/test/safe_url_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:signata/core/claim_registry.dart'; +import 'package:signata/core/safe_url.dart'; + +void main() { + group('SafeUrl.parseTraceUrl', () { + test('accepts public https URLs', () { + final result = SafeUrl.parseTraceUrl('https://cdn.example.com/a.png'); + expect(result.isOk, isTrue); + expect(result.uri!.host, 'cdn.example.com'); + }); + + test('rejects ftp and file schemes', () { + expect(SafeUrl.parseTraceUrl('ftp://x/y.png').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('file:///etc/passwd').isOk, isFalse); + }); + + test('rejects localhost and private IPs', () { + expect(SafeUrl.parseTraceUrl('http://127.0.0.1/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://localhost/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://192.168.1.1/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://10.0.0.1/x').isOk, isFalse); + expect(SafeUrl.parseTraceUrl('http://169.254.169.254/').isOk, isFalse); + }); + + test('rejects URLs with embedded credentials', () { + expect( + SafeUrl.parseTraceUrl('https://user:pass@example.com/x').isOk, + isFalse, + ); + }); + }); + + group('SafeUrl.parseRegistryBase', () { + test('accepts public https registry', () { + final result = SafeUrl.parseRegistryBase('https://registry.example.com'); + expect(result.isOk, isTrue); + }); + + test('rejects http and private hosts', () { + expect(SafeUrl.parseRegistryBase('http://registry.example.com').isOk, isFalse); + expect(SafeUrl.parseRegistryBase('https://127.0.0.1').isOk, isFalse); + }); + }); + + group('ClaimRegistry.configure', () { + test('ignores invalid registry URLs', () { + ClaimRegistry.configure(remoteBaseUrl: 'https://127.0.0.1'); + expect(ClaimRegistry.hasRemote, isFalse); + ClaimRegistry.configure(remoteBaseUrl: 'https://registry.example.com'); + expect(ClaimRegistry.hasRemote, isTrue); + ClaimRegistry.configure(remoteBaseUrl: ''); + }); + }); + + group('TraceRateLimiter', () { + test('allows scans under the hourly cap', () { + TraceRateLimiter.instance.resetForTests(); + for (var i = 0; i < TraceRateLimiter.maxScansPerHour; i++) { + expect(TraceRateLimiter.instance.allow(), isTrue); + } + expect(TraceRateLimiter.instance.allow(), isFalse); + TraceRateLimiter.instance.resetForTests(); + }); + }); +} diff --git a/test/trace_test.dart b/test/trace_test.dart index 1583f7b..1b87df1 100644 --- a/test/trace_test.dart +++ b/test/trace_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:signata/core/claim_crypto.dart'; import 'package:signata/core/claim_registry.dart'; +import 'package:signata/core/safe_url.dart'; import 'package:signata/core/trace_models.dart'; import 'package:signata/core/trace_store.dart'; import 'package:signata/core/url_tracer.dart'; @@ -14,6 +15,7 @@ void main() { setUp(() { SharedPreferences.setMockInitialValues({}); ClaimRegistry.configure(remoteBaseUrl: ''); + TraceRateLimiter.instance.resetForTests(); }); group('claim registry', () { @@ -70,6 +72,24 @@ void main() { ); }); + test('rejects private network urls', () async { + expect( + () => UrlTracer.instance.scanUrl('http://192.168.0.5/a.png', persist: false), + throwsA(isA()), + ); + expect( + () => UrlTracer.instance.scanUrl('http://127.0.0.1/a.png', persist: false), + throwsA(isA()), + ); + }); + + test('watchlist rejects blocked urls', () async { + expect( + () => TraceStore.instance.addWatchTarget('http://localhost/x'), + throwsA(isA()), + ); + }); + test('published claim JSON round-trips', () { final claim = PublishedClaim( id: 'c1',