Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
39 changes: 31 additions & 8 deletions lib/core/claim_registry.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down Expand Up @@ -78,14 +97,16 @@ class ClaimRegistry {

Future<PublishedClaim?> 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);
Expand Down Expand Up @@ -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,
Expand Down
134 changes: 134 additions & 0 deletions lib/core/safe_url.dart
Original file line number Diff line number Diff line change
@@ -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<int>? _parseIpv4(String host) {
final parts = host.split('.');
if (parts.length != 4) return null;
final out = <int>[];
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<DateTime> _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();
}
30 changes: 27 additions & 3 deletions lib/core/social_platforms.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -126,14 +128,25 @@ class SocialMediaResolver {
'Mozilla/5.0 (compatible; SignataTrace/1.0; +https://signata.app)';

Future<ResolvedSocialMedia?> 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',
Expand Down Expand Up @@ -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) {
Expand Down
7 changes: 6 additions & 1 deletion lib/core/trace_store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -52,7 +53,11 @@ class TraceStore {

Future<WatchTarget> 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<WatchTarget>.from(await listWatchTargets());
final prior = existing.where((w) => w.url == normalized).toList();
if (prior.isNotEmpty) return prior.first;
Expand Down
34 changes: 22 additions & 12 deletions lib/core/url_tracer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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(),
Expand Down Expand Up @@ -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)',
Expand Down Expand Up @@ -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') ||
Expand Down
Loading
Loading