From cfd41bc3e15e2155acd8eb2b792d6166f3affb65 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Thu, 27 Aug 2026 23:20:28 +0530 Subject: [PATCH] feat(app): add safe backend URL override Failure-Class: none --- app/lib/backend/http/shared.dart | 35 +++++- app/lib/backend/preferences.dart | 4 + app/lib/env/backend_url_override.dart | 60 ++++++++++ app/lib/env/env.dart | 8 +- app/lib/main.dart | 6 +- app/lib/pages/settings/developer.dart | 21 ++++ .../providers/developer_mode_provider.dart | 18 +++ app/test/unit/backend_url_override_test.dart | 104 ++++++++++++++++++ 8 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 app/lib/env/backend_url_override.dart create mode 100644 app/test/unit/backend_url_override_test.dart diff --git a/app/lib/backend/http/shared.dart b/app/lib/backend/http/shared.dart index f894ff22f7e..5b07192a7c8 100644 --- a/app/lib/backend/http/shared.dart +++ b/app/lib/backend/http/shared.dart @@ -118,6 +118,11 @@ Future> buildHeaders({ String? method, bool forWebSocket = false, }) async { + final effectiveAuthCheck = shouldHonorRequestedOmiAuth( + requested: requireAuthCheck, + customBackendActive: Env.hasApiBaseUrlOverride, + url: url, + ); final headers = { 'X-Request-Start-Time': (DateTime.now().millisecondsSinceEpoch / 1000).toString(), 'X-App-Platform': PlatformManager.instance.platform, @@ -130,7 +135,7 @@ Future> buildHeaders({ if (shouldAttachAccountGenerationHeader( url: url, method: method, - requireAuthCheck: requireAuthCheck, + requireAuthCheck: effectiveAuthCheck, forWebSocket: forWebSocket, )) { final accountGeneration = AccountCutoverRuntime.instance.control.accountGeneration; @@ -141,7 +146,7 @@ Future> buildHeaders({ } } - if (requireAuthCheck) { + if (effectiveAuthCheck) { // Authenticated requests must never degrade into anonymous traffic. A // typed exception stops the request before it reaches the network. headers['Authorization'] = await getAuthHeader(expireTerminalSession: expireTerminalSession); @@ -162,8 +167,11 @@ String normalizeOmiApiUrlForHostMatch(String url) { } bool _isRequiredAuthCheck(String url) { - // Agent VM endpoints always hit prod even when app uses dev - if (url.contains('api.omi.me')) return true; + if (shouldAttachOmiCredentials(url)) return true; + // A runtime override is a separate trust boundary. Never send the user's + // Omi credential to it, even when it happens to share a path with the + // configured API base URL. + if (Env.hasApiBaseUrlOverride) return false; final base = Env.apiBaseUrl; if (base != null && base.isNotEmpty) { final normalizedUrl = normalizeOmiApiUrlForHostMatch(url); @@ -175,6 +183,25 @@ bool _isRequiredAuthCheck(String url) { return false; } +/// Omi credentials are scoped to Omi-owned product API authorities. Hostname +/// parsing avoids substring matches such as `api.omi.me.attacker.example`. +@visibleForTesting +bool shouldAttachOmiCredentials(String url) { + final uri = Uri.tryParse(url); + if (uri == null || !{'https', 'wss'}.contains(uri.scheme.toLowerCase())) return false; + return {'api.omi.me', 'api.omiapi.com'}.contains(uri.host.toLowerCase()); +} + +/// Central guard for callers that historically requested auth unconditionally. +/// A custom backend remains credential-free, while explicit calls to an +/// official Omi authority (such as the agent VM) retain authentication. +@visibleForTesting +bool shouldHonorRequestedOmiAuth({required bool requested, required bool customBackendActive, String? url}) { + if (!requested) return false; + if (!customBackendActive) return true; + return url != null && shouldAttachOmiCredentials(url); +} + const _mutatingHttpMethods = {'POST', 'PUT', 'PATCH', 'DELETE'}; /// `X-Account-Generation` is only for authenticated Omi API mutation traffic and diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index ce4f7ebbd49..e7bf2fa8f4c 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -217,6 +217,10 @@ class SharedPreferencesUtil { set deviceName(String value) => saveString('deviceName', value); + String get customBackendUrl => getString('customBackendUrl'); + + set customBackendUrl(String value) => saveString('customBackendUrl', value); + String get deviceName => getString('deviceName'); bool get deviceIsV2 => getBool('deviceIsV2'); diff --git a/app/lib/env/backend_url_override.dart b/app/lib/env/backend_url_override.dart new file mode 100644 index 00000000000..7587af589aa --- /dev/null +++ b/app/lib/env/backend_url_override.dart @@ -0,0 +1,60 @@ +import 'package:flutter/foundation.dart'; + +import 'package:omi/env/env.dart'; + +final class BackendUrlOverride { + const BackendUrlOverride._(this.url); + + final String url; + + factory BackendUrlOverride.parse(String input) { + final value = input.trim(); + final uri = Uri.tryParse(value); + if (uri == null || uri.host.isEmpty || (uri.scheme != 'http' && uri.scheme != 'https')) { + throw const FormatException('Enter a valid HTTP or HTTPS backend URL.'); + } + if (uri.userInfo.isNotEmpty || uri.hasFragment || uri.hasQuery) { + throw const FormatException('Backend URLs cannot contain credentials, queries, or fragments.'); + } + if (uri.scheme == 'http' && !_isPrivateHost(uri.host)) { + throw const FormatException('Public backend URLs must use HTTPS.'); + } + + final normalizedPath = uri.path.endsWith('/') ? uri.path : '${uri.path}/'; + return BackendUrlOverride._(uri.replace(path: normalizedPath).toString()); + } + + static bool restore(String persistedUrl, {bool runtimeAllowed = !kReleaseMode}) { + if (!runtimeAllowed) { + Env.clearApiBaseUrlOverride(); + return false; + } + final value = persistedUrl.trim(); + if (value.isEmpty) { + Env.clearApiBaseUrlOverride(); + return true; + } + try { + Env.overrideApiBaseUrl(BackendUrlOverride.parse(value).url); + return true; + } on FormatException { + Env.clearApiBaseUrlOverride(); + return false; + } + } + + static bool _isPrivateHost(String host) { + final normalized = host.toLowerCase(); + if (normalized == 'localhost' || normalized == 'host.docker.internal' || normalized == '::1') return true; + + final octets = normalized.split('.').map(int.tryParse).toList(); + if (octets.length != 4 || octets.any((octet) => octet == null || octet < 0 || octet > 255)) return false; + final first = octets[0]!; + final second = octets[1]!; + return first == 10 || + first == 127 || + (first == 172 && second >= 16 && second <= 31) || + (first == 192 && second == 168) || + (first == 100 && second >= 64 && second <= 127); + } +} diff --git a/app/lib/env/env.dart b/app/lib/env/env.dart index 72ba134549e..7e0c3254872 100644 --- a/app/lib/env/env.dart +++ b/app/lib/env/env.dart @@ -30,10 +30,16 @@ abstract class Env { _apiBaseUrlOverride = url; } - static void clearApiBaseUrlOverrideForTesting() { + static bool get hasApiBaseUrlOverride => _apiBaseUrlOverride != null; + + static void clearApiBaseUrlOverride() { _apiBaseUrlOverride = null; } + static void clearApiBaseUrlOverrideForTesting() { + clearApiBaseUrlOverride(); + } + static String? get posthogApiKey => _instance.posthogApiKey; // static String? get apiBaseUrl => 'https://omi-backend.ngrok.app/'; diff --git a/app/lib/main.dart b/app/lib/main.dart index 50b450f0752..5c21778549e 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -31,6 +31,7 @@ import 'package:omi/coordinators/provider_capture_external_actions.dart'; import 'package:omi/core/app_shell.dart'; import 'package:omi/env/dev_env.dart'; import 'package:omi/env/env.dart'; +import 'package:omi/env/backend_url_override.dart'; import 'package:omi/env/environment_profile.dart'; import 'package:omi/env/prod_env.dart'; import 'package:omi/firebase_options_local.dart' as local; @@ -160,6 +161,9 @@ Future _init() async { Env.validateProfilePairing(); validateApplicationStartupRouting(); + await SharedPreferencesUtil.init(); + BackendUrlOverride.restore(SharedPreferencesUtil().customBackendUrl); + FlutterForegroundTask.initCommunicationPort(); // Service manager @@ -182,8 +186,6 @@ Future _init() async { FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler); } - await SharedPreferencesUtil.init(); - // TestFlight remains a distribution/telemetry signal; production-family // builds always use the established production backend. if (F.env == Environment.prod) { diff --git a/app/lib/pages/settings/developer.dart b/app/lib/pages/settings/developer.dart index 2276db9f252..607dae43571 100644 --- a/app/lib/pages/settings/developer.dart +++ b/app/lib/pages/settings/developer.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:omi/utils/platform/platform_manager.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -501,6 +502,26 @@ class _DeveloperSettingsPageState extends State<_DeveloperSettingsPageView> { ), const SizedBox(height: 12), + if (!kReleaseMode) ...[ + _buildSectionHeader( + context.l10n.customBackendUrlTitle, + ), + _buildSectionContainer( + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: _buildTextField( + controller: provider.customBackendUrl, + label: context.l10n.backendUrlLabel, + hint: 'https://omi.example.com/', + keyboardType: TextInputType.url, + ), + ), + ], + ), + const SizedBox(height: 32), + ], + // Transcription Section GestureDetector( onTap: () async { diff --git a/app/lib/providers/developer_mode_provider.dart b/app/lib/providers/developer_mode_provider.dart index 35f9ed4a6e2..ee710f7b958 100644 --- a/app/lib/providers/developer_mode_provider.dart +++ b/app/lib/providers/developer_mode_provider.dart @@ -1,8 +1,10 @@ import 'package:omi/utils/platform/platform_manager.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:omi/backend/http/api/users.dart'; import 'package:omi/backend/preferences.dart'; +import 'package:omi/env/backend_url_override.dart'; import 'package:omi/app_globals.dart'; import 'package:omi/providers/base_provider.dart'; import 'package:omi/utils/alerts/app_snackbar.dart'; @@ -17,6 +19,7 @@ class DeveloperModeProvider extends BaseProvider { final TextEditingController webhookAudioBytesDelay = TextEditingController(); final TextEditingController webhookWsAudioBytes = TextEditingController(); final TextEditingController webhookDaySummary = TextEditingController(); + final TextEditingController customBackendUrl = TextEditingController(); bool conversationEventsToggled = false; bool transcriptsToggled = false; @@ -105,6 +108,7 @@ class DeveloperModeProvider extends BaseProvider { webhookOnTranscriptReceived.text = SharedPreferencesUtil().webhookOnTranscriptReceived; webhookAudioBytes.text = SharedPreferencesUtil().webhookAudioBytes; webhookAudioBytesDelay.text = SharedPreferencesUtil().webhookAudioBytesDelay; + customBackendUrl.text = SharedPreferencesUtil().customBackendUrl; followUpQuestionEnabled = SharedPreferencesUtil().devModeJoanFollowUpEnabled; transcriptionDiagnosticEnabled = SharedPreferencesUtil().transcriptionDiagnosticEnabled; autoCreateSpeakersEnabled = SharedPreferencesUtil().autoCreateSpeakersEnabled; @@ -155,6 +159,20 @@ class DeveloperModeProvider extends BaseProvider { setIsLoading(true); final prefs = SharedPreferencesUtil(); + if (!kReleaseMode) { + try { + final rawBackendUrl = customBackendUrl.text.trim(); + final normalizedBackendUrl = rawBackendUrl.isEmpty ? '' : BackendUrlOverride.parse(rawBackendUrl).url; + prefs.customBackendUrl = normalizedBackendUrl; + customBackendUrl.text = normalizedBackendUrl; + BackendUrlOverride.restore(normalizedBackendUrl); + } on FormatException catch (error) { + AppSnackbar.showSnackbarError(error.message); + setIsLoading(false); + return; + } + } + if (webhookAudioBytes.text.isNotEmpty && !isValidUrl(webhookAudioBytes.text)) { AppSnackbar.showSnackbarError( globalNavigatorKey.currentContext?.l10n.devModeInvalidAudioBytesWebhookUrl ?? 'Invalid audio bytes webhook URL', diff --git a/app/test/unit/backend_url_override_test.dart b/app/test/unit/backend_url_override_test.dart new file mode 100644 index 00000000000..246c7e5495c --- /dev/null +++ b/app/test/unit/backend_url_override_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:omi/backend/http/shared.dart'; +import 'package:omi/backend/preferences.dart'; +import 'package:omi/env/backend_url_override.dart'; +import 'package:omi/env/env.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + tearDown(Env.clearApiBaseUrlOverrideForTesting); + + group('BackendUrlOverride', () { + test('normalizes HTTPS endpoints with a trailing slash', () { + expect(BackendUrlOverride.parse(' https://omi.example.test/api ').url, 'https://omi.example.test/api/'); + }); + + test('allows cleartext only for local, private, and CGNAT hosts', () { + for (final url in [ + 'http://127.0.0.1:8000', + 'http://localhost:8000', + 'http://10.0.0.8:8000', + 'http://172.31.0.8:8000', + 'http://192.168.1.8:8000', + 'http://100.64.0.8:8000', + ]) { + expect(BackendUrlOverride.parse(url).url, endsWith('/'), reason: url); + } + }); + + test('rejects public cleartext, credentials, fragments, and unsupported schemes', () { + for (final url in [ + 'http://8.8.8.8:8000', + 'http://user:pass@127.0.0.1:8000', + 'https://example.test/#secret', + 'ftp://127.0.0.1/files', + ]) { + expect(() => BackendUrlOverride.parse(url), throwsFormatException, reason: url); + } + }); + }); + + test('persisted override restores at startup and clearing it removes the override', () async { + SharedPreferences.setMockInitialValues({}); + await SharedPreferencesUtil.init(); + SharedPreferencesUtil().customBackendUrl = 'https://omi.example.test/api/'; + await SharedPreferencesUtil.reload(); + + BackendUrlOverride.restore(SharedPreferencesUtil().customBackendUrl); + expect(Env.apiBaseUrl, 'https://omi.example.test/api/'); + + BackendUrlOverride.restore(''); + expect(Env.hasApiBaseUrlOverride, isFalse); + }); + + test('an invalid persisted override cannot break startup', () { + expect(BackendUrlOverride.restore('http://public.example.test'), isFalse); + expect(Env.hasApiBaseUrlOverride, isFalse); + }); + + test('release-mode restoration fails closed to the flavor backend', () { + expect( + BackendUrlOverride.restore('https://omi.example.test/api/', runtimeAllowed: false), + isFalse, + ); + expect(Env.hasApiBaseUrlOverride, isFalse); + }); + + group('backend auth isolation', () { + test('Omi credentials remain attached to official API hosts', () { + expect(shouldAttachOmiCredentials('https://api.omi.me/v1/users/me'), isTrue); + expect(shouldAttachOmiCredentials('wss://api.omi.me/v4/listen'), isTrue); + expect(shouldAttachOmiCredentials('https://api.omiapi.com/v1/users/me'), isTrue); + }); + + test('custom backends never receive Omi credentials', () { + expect(shouldAttachOmiCredentials('https://self-hosted.example.test/v1/users/me'), isFalse); + expect(shouldAttachOmiCredentials('ws://100.64.0.8:8000/v4/listen'), isFalse); + expect(shouldAttachOmiCredentials('https://api.omi.me.attacker.example/v1/users/me'), isFalse); + }); + + test('unconditional legacy callers are still isolated under an override', () { + expect(shouldHonorRequestedOmiAuth(requested: false, customBackendActive: true), isFalse); + expect(shouldHonorRequestedOmiAuth(requested: true, customBackendActive: true), isFalse); + expect( + shouldHonorRequestedOmiAuth( + requested: true, + customBackendActive: true, + url: 'https://self-hosted.example.test/v1/users/me', + ), + isFalse, + ); + expect( + shouldHonorRequestedOmiAuth( + requested: true, + customBackendActive: true, + url: 'https://api.omi.me/v1/agents', + ), + isTrue, + ); + }); + }); +}