Skip to content
Open
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
35 changes: 31 additions & 4 deletions app/lib/backend/http/shared.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dart:async';

Check warning on line 1 in app/lib/backend/http/shared.dart

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

app/lib/backend/http/shared.dart is 886 lines; consider splitting files over 800 lines.

Check warning on line 1 in app/lib/backend/http/shared.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/backend/http/shared.dart is 886 lines; consider splitting files over 800 lines.

Check warning on line 1 in app/lib/backend/http/shared.dart

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

app/lib/backend/http/shared.dart is 886 lines; consider splitting files over 800 lines.
import 'dart:convert';
import 'dart:io';

Expand Down Expand Up @@ -118,6 +118,11 @@
String? method,
bool forWebSocket = false,
}) async {
final effectiveAuthCheck = shouldHonorRequestedOmiAuth(
requested: requireAuthCheck,
customBackendActive: Env.hasApiBaseUrlOverride,
url: url,
);
final headers = <String, String>{
'X-Request-Start-Time': (DateTime.now().millisecondsSinceEpoch / 1000).toString(),
'X-App-Platform': PlatformManager.instance.platform,
Expand All @@ -130,7 +135,7 @@
if (shouldAttachAccountGenerationHeader(
url: url,
method: method,
requireAuthCheck: requireAuthCheck,
requireAuthCheck: effectiveAuthCheck,
forWebSocket: forWebSocket,
)) {
final accountGeneration = AccountCutoverRuntime.instance.control.accountGeneration;
Expand All @@ -141,7 +146,7 @@
}
}

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);
Expand All @@ -162,8 +167,11 @@
}

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);
Expand All @@ -175,6 +183,25 @@
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
Expand Down
4 changes: 4 additions & 0 deletions app/lib/backend/preferences.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dart:async';

Check warning on line 1 in app/lib/backend/preferences.dart

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

app/lib/backend/preferences.dart is 963 lines; consider splitting files over 800 lines.

Check warning on line 1 in app/lib/backend/preferences.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/backend/preferences.dart is 963 lines; consider splitting files over 800 lines.

Check warning on line 1 in app/lib/backend/preferences.dart

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

app/lib/backend/preferences.dart is 963 lines; consider splitting files over 800 lines.
import 'dart:convert';
import 'dart:io' show Platform;

Expand Down Expand Up @@ -217,6 +217,10 @@

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');
Expand Down
60 changes: 60 additions & 0 deletions app/lib/env/backend_url_override.dart
Original file line number Diff line number Diff line change
@@ -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);
}
}
8 changes: 7 additions & 1 deletion app/lib/env/env.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down
6 changes: 4 additions & 2 deletions app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -160,6 +161,9 @@ Future _init() async {
Env.validateProfilePairing();
validateApplicationStartupRouting();

await SharedPreferencesUtil.init();
BackendUrlOverride.restore(SharedPreferencesUtil().customBackendUrl);

FlutterForegroundTask.initCommunicationPort();

// Service manager
Expand All @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions app/lib/pages/settings/developer.dart
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import 'dart:io';

Check warning on line 1 in app/lib/pages/settings/developer.dart

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

app/lib/pages/settings/developer.dart is 1899 lines; consider splitting files over 800 lines.

Check warning on line 1 in app/lib/pages/settings/developer.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Large changed file

app/lib/pages/settings/developer.dart is 1899 lines; consider splitting files over 800 lines.

Check warning on line 1 in app/lib/pages/settings/developer.dart

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Large changed file

app/lib/pages/settings/developer.dart is 1899 lines; consider splitting files over 800 lines.

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';

Expand Down Expand Up @@ -435,7 +436,7 @@
}

@override
Widget build(BuildContext context) {

Check warning on line 439 in app/lib/pages/settings/developer.dart

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Long function

Widget build(BuildContext context) is 1273 lines; consider extracting focused helpers over 150 lines.

Check warning on line 439 in app/lib/pages/settings/developer.dart

View workflow job for this annotation

GitHub Actions / Hygiene

Long function

Widget build(BuildContext context) is 1273 lines; consider extracting focused helpers over 150 lines.

Check warning on line 439 in app/lib/pages/settings/developer.dart

View workflow job for this annotation

GitHub Actions / PR Metadata Preflight

Long function

Widget build(BuildContext context) is 1273 lines; consider extracting focused helpers over 150 lines.
return GestureDetector(
onTap: () => FocusScope.of(context).unfocus(),
child: Consumer<DeveloperModeProvider>(
Expand Down Expand Up @@ -501,6 +502,26 @@
),
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 {
Expand Down
18 changes: 18 additions & 0 deletions app/lib/providers/developer_mode_provider.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down
104 changes: 104 additions & 0 deletions app/test/unit/backend_url_override_test.dart
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
}
Loading