From 96c893ec189a413dd6e9e593ee6fa56b83c3c1c4 Mon Sep 17 00:00:00 2001 From: Michal Tajchert Date: Mon, 11 May 2026 23:09:21 +0200 Subject: [PATCH] Mobile: custom proxy headers + small login UX fixes (#1748) * Add mobile custom proxy headers * Clear login placeholders on focus Email/password fields ship with example values pre-filled. Tapping the field now clears the placeholder so users don't have to delete it manually. Skips clearing if the user has already edited the value. * Push Configuration as a route from Sign in Opening Configuration from the Sign in screen now uses Navigator.push instead of toggling a state flag, so Android back returns to Sign in instead of quitting the app. Saving the URL auto-pops the route. * Address PR review on custom proxy headers - Test Connection no longer leaves global ApiConfig headers mutated; unsaved edits are restored in a finally block after the probe. - _loadSavedUrl / _loadCustomHeaders wrap storage reads in try/catch and always finish initialization with sensible defaults. - Sanitization is now a single CustomProxyHeader.sanitize() reused by ApiConfig.setCustomProxyHeaders and CustomProxyHeadersService. - Brief comment on redactedValue explaining the length-obscuring design. * Harden custom proxy header validation and load path - validateValue now rejects ASCII control characters (CR/LF/tab/etc.) to prevent header-injection via crafted values. - loadHeaders moves the secure-storage read inside the try block so platform exceptions are caught the same way JSON parse errors are. --- mobile/lib/main.dart | 10 +- mobile/lib/models/custom_proxy_header.dart | 90 +++++++++++ mobile/lib/screens/backend_config_screen.dart | 83 ++++++++-- mobile/lib/screens/login_screen.dart | 42 ++++- mobile/lib/screens/settings_screen.dart | 116 ++++++++++++++ mobile/lib/services/api_config.dart | 48 +++++- mobile/lib/services/auth_service.dart | 31 +--- .../custom_proxy_headers_service.dart | 47 ++++++ .../widgets/custom_proxy_headers_editor.dart | 145 ++++++++++++++++++ .../test/models/custom_proxy_header_test.dart | 47 ++++++ .../services/api_config_headers_test.dart | 78 ++++++++++ .../custom_proxy_headers_service_test.dart | 47 ++++++ 12 files changed, 734 insertions(+), 50 deletions(-) create mode 100644 mobile/lib/models/custom_proxy_header.dart create mode 100644 mobile/lib/services/custom_proxy_headers_service.dart create mode 100644 mobile/lib/widgets/custom_proxy_headers_editor.dart create mode 100644 mobile/test/models/custom_proxy_header_test.dart create mode 100644 mobile/test/services/api_config_headers_test.dart create mode 100644 mobile/test/services/custom_proxy_headers_service_test.dart diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 8bb8457a0..bc1b4afc6 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -257,12 +257,6 @@ class _AppWrapperState extends State with WidgetsBindingObserver { }); } - void _goToBackendConfig() { - setState(() { - _hasBackendUrl = false; - }); - } - @override Widget build(BuildContext context) { if (_isCheckingConfig) { @@ -314,9 +308,7 @@ class _AppWrapperState extends State with WidgetsBindingObserver { return const SsoOnboardingScreen(); } - return LoginScreen( - onGoToSettings: _goToBackendConfig, - ); + return const LoginScreen(); }, ); } diff --git a/mobile/lib/models/custom_proxy_header.dart b/mobile/lib/models/custom_proxy_header.dart new file mode 100644 index 000000000..900bd2486 --- /dev/null +++ b/mobile/lib/models/custom_proxy_header.dart @@ -0,0 +1,90 @@ +class CustomProxyHeader { + static final RegExp _headerNamePattern = RegExp(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$"); + // Reject ASCII control bytes in values to block CR/LF header injection. + static final RegExp _headerValueControlChars = RegExp(r'[\x00-\x1F\x7F]'); + static const Set _reservedNames = { + 'accept', + 'authorization', + 'content-type', + 'x-api-key', + }; + + final String name; + final String value; + + CustomProxyHeader({ + required String name, + required String value, + }) : name = name.trim(), + value = value.trim(); + + factory CustomProxyHeader.fromJson(Map json) { + return CustomProxyHeader( + name: json['name'] as String? ?? '', + value: json['value'] as String? ?? '', + ); + } + + Map toJson() => { + 'name': name, + 'value': value, + }; + + String get normalizedName => name.toLowerCase(); + + // Length is intentionally obscured: short values get a fixed 4-bullet mask + // and longer values get a fixed 6-bullet prefix + last 4 chars. Keeping the + // last 4 lets users sanity-check what they entered without leaking length. + String get redactedValue { + if (value.isEmpty) return ''; + if (value.length <= 4) return '••••'; + return '••••••${value.substring(value.length - 4)}'; + } + + /// Drops headers with empty/invalid name or value, then dedupes by + /// case-insensitive name (last write wins). Single source of truth used by + /// both `ApiConfig.setCustomProxyHeaders` and the persistence service. + static List sanitize(List headers) { + final byName = {}; + for (final header in headers) { + if (!header.isComplete) continue; + if (validateName(header.name) != null) continue; + if (validateValue(header.value) != null) continue; + byName[header.normalizedName] = header; + } + return byName.values.toList(growable: false); + } + + bool get isComplete => name.isNotEmpty && value.isNotEmpty; + + static String? validateName(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return 'Header name is required'; + if (!_headerNamePattern.hasMatch(trimmed)) { + return 'Use a valid HTTP header name'; + } + if (_reservedNames.contains(trimmed.toLowerCase())) { + return 'This header is managed by the app'; + } + return null; + } + + static String? validateValue(String value) { + if (value.trim().isEmpty) return 'Header value is required'; + if (_headerValueControlChars.hasMatch(value)) { + return 'Header value contains control characters'; + } + return null; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is CustomProxyHeader && + name == other.name && + value == other.value; + } + + @override + int get hashCode => Object.hash(name, value); +} diff --git a/mobile/lib/screens/backend_config_screen.dart b/mobile/lib/screens/backend_config_screen.dart index edc50d774..b5cbf5519 100644 --- a/mobile/lib/screens/backend_config_screen.dart +++ b/mobile/lib/screens/backend_config_screen.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:http/http.dart' as http; +import '../models/custom_proxy_header.dart'; import '../services/api_config.dart'; +import '../services/custom_proxy_headers_service.dart'; +import '../widgets/custom_proxy_headers_editor.dart'; class BackendConfigScreen extends StatefulWidget { final VoidCallback? onConfigSaved; @@ -17,8 +20,10 @@ class _BackendConfigScreenState extends State { final _urlController = TextEditingController(); bool _isLoading = false; bool _isTesting = false; + bool _hasLoadedConfig = false; String? _errorMessage; String? _successMessage; + List _customHeaders = []; @override void initState() { @@ -33,16 +38,27 @@ class _BackendConfigScreenState extends State { } Future _loadSavedUrl() async { - final prefs = await SharedPreferences.getInstance(); - final savedUrl = prefs.getString('backend_url'); - final urlToShow = (savedUrl != null && savedUrl.isNotEmpty) - ? savedUrl - : ApiConfig.baseUrl; - - if (mounted) { - setState(() { - _urlController.text = urlToShow; - }); + String urlToShow = ApiConfig.baseUrl; + List headers = const []; + try { + final prefs = await SharedPreferences.getInstance(); + final savedUrl = prefs.getString('backend_url'); + headers = await CustomProxyHeadersService.instance.loadHeaders(); + if (savedUrl != null && savedUrl.isNotEmpty) { + urlToShow = savedUrl; + } + } catch (e, stack) { + // Swallow storage failures so the screen still becomes interactive with + // sensible defaults; the user can re-enter and re-save. + debugPrint('BackendConfigScreen: failed to load saved config: $e\n$stack'); + } finally { + if (mounted) { + setState(() { + _urlController.text = urlToShow; + _customHeaders = headers; + _hasLoadedConfig = true; + }); + } } } @@ -55,6 +71,7 @@ class _BackendConfigScreenState extends State { _successMessage = null; }); + final previousHeaders = ApiConfig.customProxyHeaders; try { // Normalize base URL by removing trailing slashes final normalizedUrl = _urlController.text.trim().replaceAll( @@ -62,10 +79,14 @@ class _BackendConfigScreenState extends State { '', ); + // Apply the unsaved edits only for the duration of this probe so the + // test reflects what the user is about to save. Restored in `finally`. + ApiConfig.setCustomProxyHeaders(_customHeaders); + // Check /sessions/new page to verify it's a Sure backend final sessionsUrl = Uri.parse('$normalizedUrl/sessions/new'); final sessionsResponse = await http - .get(sessionsUrl, headers: {'Accept': 'text/html'}) + .get(sessionsUrl, headers: ApiConfig.htmlHeaders()) .timeout( const Duration(seconds: 10), onTimeout: () { @@ -98,6 +119,7 @@ class _BackendConfigScreenState extends State { }); } } finally { + ApiConfig.setCustomProxyHeaders(previousHeaders); if (mounted) { setState(() { _isTesting = false; @@ -125,6 +147,10 @@ class _BackendConfigScreenState extends State { final prefs = await SharedPreferences.getInstance(); await prefs.setString('backend_url', normalizedUrl); + // Save custom proxy headers + await CustomProxyHeadersService.instance.saveHeaders(_customHeaders); + ApiConfig.setCustomProxyHeaders(_customHeaders); + // Update ApiConfig ApiConfig.setBaseUrl(normalizedUrl); @@ -334,6 +360,41 @@ class _BackendConfigScreenState extends State { validator: _validateUrl, onFieldSubmitted: (_) => _saveAndContinue(), ), + const SizedBox(height: 24), + ExpansionTile( + tilePadding: EdgeInsets.zero, + leading: const Icon(Icons.http_outlined), + title: const Text('Custom proxy headers'), + subtitle: Text( + _customHeaders.isEmpty + ? 'Optional headers for a reverse proxy or auth gateway' + : '${_customHeaders.length} configured', + ), + children: [ + const SizedBox(height: 8), + if (_hasLoadedConfig) + CustomProxyHeadersEditor( + initialHeaders: _customHeaders, + onChanged: (headers) { + setState(() => _customHeaders = headers); + }, + ) + else + const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(), + ), + ), + const SizedBox(height: 8), + Text( + 'Headers are sent by the app with API requests. External browser SSO pages may not receive them.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), const SizedBox(height: 16), // Test Connection Button diff --git a/mobile/lib/screens/login_screen.dart b/mobile/lib/screens/login_screen.dart index 3c97f457e..efff02d6f 100644 --- a/mobile/lib/screens/login_screen.dart +++ b/mobile/lib/screens/login_screen.dart @@ -5,21 +5,40 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:flutter_svg/flutter_svg.dart'; import '../providers/auth_provider.dart'; import '../services/api_config.dart'; +import 'backend_config_screen.dart'; class LoginScreen extends StatefulWidget { final VoidCallback? onGoToSettings; const LoginScreen({super.key, this.onGoToSettings}); + void _openSettings(BuildContext context) { + if (onGoToSettings != null) { + onGoToSettings!(); + return; + } + Navigator.of(context).push( + MaterialPageRoute( + builder: (routeContext) => BackendConfigScreen( + onConfigSaved: () => Navigator.of(routeContext).pop(), + ), + ), + ); + } + @override State createState() => _LoginScreenState(); } class _LoginScreenState extends State { final _formKey = GlobalKey(); - final _emailController = TextEditingController(text: 'user@example.com'); - final _passwordController = TextEditingController(text: 'Password1!'); + static const _emailPlaceholder = 'user@example.com'; + static const _passwordPlaceholder = 'Password1!'; + final _emailController = TextEditingController(text: _emailPlaceholder); + final _passwordController = TextEditingController(text: _passwordPlaceholder); final _otpController = TextEditingController(); + final _emailFocus = FocusNode(); + final _passwordFocus = FocusNode(); bool _obscurePassword = true; late final TapGestureRecognizer _signUpTapRecognizer; @@ -27,6 +46,17 @@ class _LoginScreenState extends State { void initState() { super.initState(); _signUpTapRecognizer = TapGestureRecognizer()..onTap = _openSignUpPage; + _emailFocus.addListener(() => _clearPlaceholderOnFocus( + _emailFocus, _emailController, _emailPlaceholder)); + _passwordFocus.addListener(() => _clearPlaceholderOnFocus( + _passwordFocus, _passwordController, _passwordPlaceholder)); + } + + void _clearPlaceholderOnFocus( + FocusNode node, TextEditingController controller, String placeholder) { + if (node.hasFocus && controller.text == placeholder) { + controller.clear(); + } } @override @@ -35,6 +65,8 @@ class _LoginScreenState extends State { _emailController.dispose(); _passwordController.dispose(); _otpController.dispose(); + _emailFocus.dispose(); + _passwordFocus.dispose(); super.dispose(); } @@ -261,6 +293,7 @@ class _LoginScreenState extends State { // Email Field TextFormField( controller: _emailController, + focusNode: _emailFocus, keyboardType: TextInputType.emailAddress, autocorrect: false, textInputAction: TextInputAction.next, @@ -291,6 +324,7 @@ class _LoginScreenState extends State { // Password Field TextFormField( controller: _passwordController, + focusNode: _passwordFocus, obscureText: _obscurePassword, textInputAction: showOtp ? TextInputAction.next @@ -442,7 +476,7 @@ class _LoginScreenState extends State { // Backend URL info InkWell( - onTap: widget.onGoToSettings, + onTap: () => widget._openSettings(context), borderRadius: BorderRadius.circular(8), child: Container( padding: const EdgeInsets.all(12), @@ -494,7 +528,7 @@ class _LoginScreenState extends State { child: IconButton( icon: const Icon(Icons.settings_outlined), tooltip: 'Backend Settings', - onPressed: widget.onGoToSettings, + onPressed: () => widget._openSettings(context), ), ), ], diff --git a/mobile/lib/screens/settings_screen.dart b/mobile/lib/screens/settings_screen.dart index 1806d7b5e..676926153 100644 --- a/mobile/lib/screens/settings_screen.dart +++ b/mobile/lib/screens/settings_screen.dart @@ -11,6 +11,10 @@ import '../services/biometric_service.dart'; import '../services/preferences_service.dart'; import '../services/user_service.dart'; import 'log_viewer_screen.dart'; +import '../models/custom_proxy_header.dart'; +import '../services/api_config.dart'; +import '../services/custom_proxy_headers_service.dart'; +import '../widgets/custom_proxy_headers_editor.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -27,6 +31,7 @@ class _SettingsScreenState extends State { bool _biometricSupported = false; bool _biometricEnabled = false; bool _isTogglingBiometric = false; + List _customHeaders = []; @override void initState() { @@ -34,6 +39,7 @@ class _SettingsScreenState extends State { _loadPreferences(); _loadAppVersion(); _loadBiometricState(); + _loadCustomHeaders(); } Future _loadBiometricState() async { @@ -93,6 +99,18 @@ class _SettingsScreenState extends State { } } + Future _loadCustomHeaders() async { + try { + final headers = await CustomProxyHeadersService.instance.loadHeaders(); + if (mounted) { + setState(() => _customHeaders = headers); + } + } catch (e, stack) { + debugPrint('SettingsScreen: failed to load custom headers: $e\n$stack'); + // Keep the existing _customHeaders state so the screen remains usable. + } + } + Future _handleClearLocalData(BuildContext context) async { final confirmed = await showDialog( context: context, @@ -318,6 +336,79 @@ class _SettingsScreenState extends State { } } + Future _showCustomHeadersDialog() async { + final formKey = GlobalKey(); + final latestHeaders = await CustomProxyHeadersService.instance.loadHeaders(); + if (!mounted) return; + + setState(() => _customHeaders = latestHeaders); + var draftHeaders = List.from(latestHeaders); + + final saved = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Custom proxy headers'), + content: SingleChildScrollView( + child: Form( + key: formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + CustomProxyHeadersEditor( + initialHeaders: draftHeaders, + onChanged: (headers) => draftHeaders = headers, + ), + const SizedBox(height: 12), + Text( + 'Headers are sent by the app with API requests. External browser SSO pages may not receive them.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + if (formKey.currentState?.validate() != true) return; + Navigator.pop(context, true); + }, + child: const Text('Save'), + ), + ], + ); + }, + ); + + if (saved != true) return; + + try { + await CustomProxyHeadersService.instance.saveHeaders(draftHeaders); + ApiConfig.setCustomProxyHeaders(draftHeaders); + if (!mounted) return; + setState(() => _customHeaders = draftHeaders); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Custom proxy headers saved')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to save custom proxy headers: $e'), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -482,6 +573,31 @@ class _SettingsScreenState extends State { const Divider(), + const Padding( + padding: EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + 'Connection', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.grey, + ), + ), + ), + + ListTile( + leading: const Icon(Icons.http_outlined), + title: const Text('Custom proxy headers'), + subtitle: Text( + _customHeaders.isEmpty + ? 'Optional headers for a reverse proxy or auth gateway' + : '${_customHeaders.length} configured', + ), + onTap: _showCustomHeadersDialog, + ), + + const Divider(), + // Data Management Section const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), diff --git a/mobile/lib/services/api_config.dart b/mobile/lib/services/api_config.dart index 441618a7c..a012786d0 100644 --- a/mobile/lib/services/api_config.dart +++ b/mobile/lib/services/api_config.dart @@ -1,5 +1,8 @@ import 'package:shared_preferences/shared_preferences.dart'; +import '../models/custom_proxy_header.dart'; +import 'custom_proxy_headers_service.dart'; + class ApiConfig { // Base URL for the API - can be changed to point to different environments // For local development, use: http://10.0.2.2:3000 (Android emulator) @@ -32,14 +35,53 @@ class ApiConfig { _apiKeyValue = null; } + // Custom proxy headers + static List _customProxyHeaders = []; + + static List get customProxyHeaders => + List.unmodifiable(_customProxyHeaders); + + static void setCustomProxyHeaders(List headers) { + _customProxyHeaders = CustomProxyHeader.sanitize(headers); + } + + static Map get customProxyHeaderMap { + return { + for (final header in _customProxyHeaders) header.name: header.value, + }; + } + + static Map jsonHeaders() { + return { + ...customProxyHeaderMap, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }; + } + + static Map htmlHeaders() { + return { + ...customProxyHeaderMap, + 'Accept': 'text/html', + }; + } + /// Returns the correct auth headers based on the current auth mode. /// In API key mode, uses X-Api-Key header. /// In token mode, uses Authorization: Bearer header. static Map getAuthHeaders(String token) { if (_isApiKeyAuth && _apiKeyValue != null) { - return {'X-Api-Key': _apiKeyValue!, 'Accept': 'application/json'}; + return { + ...customProxyHeaderMap, + 'X-Api-Key': _apiKeyValue!, + 'Accept': 'application/json', + }; } - return {'Authorization': 'Bearer $token', 'Accept': 'application/json'}; + return { + ...customProxyHeaderMap, + 'Authorization': 'Bearer $token', + 'Accept': 'application/json', + }; } /// Initialize the API configuration by loading the backend URL from storage @@ -51,6 +93,7 @@ class ApiConfig { if (savedUrl != null && savedUrl.isNotEmpty) { _baseUrl = savedUrl; + _customProxyHeaders = await CustomProxyHeadersService.instance.loadHeaders(); return true; } @@ -58,6 +101,7 @@ class ApiConfig { // go straight to login while still letting users override it later. _baseUrl = _defaultBaseUrl; await prefs.setString(_backendUrlKey, _defaultBaseUrl); + _customProxyHeaders = await CustomProxyHeadersService.instance.loadHeaders(); return true; } catch (e) { // If initialization fails, keep the default URL diff --git a/mobile/lib/services/auth_service.dart b/mobile/lib/services/auth_service.dart index e31c34554..e39ae6254 100644 --- a/mobile/lib/services/auth_service.dart +++ b/mobile/lib/services/auth_service.dart @@ -36,10 +36,7 @@ class AuthService { final response = await http.post( url, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, + headers: ApiConfig.jsonHeaders(), body: jsonEncode(body), ).timeout(const Duration(seconds: 30)); @@ -145,10 +142,7 @@ class AuthService { final response = await http.post( url, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, + headers: ApiConfig.jsonHeaders(), body: jsonEncode(body), ).timeout(const Duration(seconds: 30)); @@ -227,10 +221,7 @@ class AuthService { final response = await http.post( url, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, + headers: ApiConfig.jsonHeaders(), body: jsonEncode({ 'refresh_token': refreshToken, 'device': deviceInfo, @@ -301,6 +292,7 @@ class AuthService { final response = await http.get( url, headers: { + ...ApiConfig.customProxyHeaderMap, 'X-Api-Key': apiKey, 'Accept': 'application/json', }, @@ -398,10 +390,7 @@ class AuthService { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_exchange'); final response = await http.post( url, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, + headers: ApiConfig.jsonHeaders(), body: jsonEncode({'code': code}), ).timeout(const Duration(seconds: 30)); @@ -463,10 +452,7 @@ class AuthService { final url = Uri.parse('${ApiConfig.baseUrl}/api/v1/auth/sso_link'); final response = await http.post( url, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, + headers: ApiConfig.jsonHeaders(), body: jsonEncode({ 'linking_code': linkingCode, 'email': email, @@ -523,10 +509,7 @@ class AuthService { final response = await http.post( url, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - }, + headers: ApiConfig.jsonHeaders(), body: jsonEncode(body), ).timeout(const Duration(seconds: 30)); diff --git a/mobile/lib/services/custom_proxy_headers_service.dart b/mobile/lib/services/custom_proxy_headers_service.dart new file mode 100644 index 000000000..26fd7f37b --- /dev/null +++ b/mobile/lib/services/custom_proxy_headers_service.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +import '../models/custom_proxy_header.dart'; + +class CustomProxyHeadersService { + static const String storageKey = 'custom_proxy_headers'; + + static CustomProxyHeadersService? _instance; + + CustomProxyHeadersService._(); + + static CustomProxyHeadersService get instance { + _instance ??= CustomProxyHeadersService._(); + return _instance!; + } + + Future> loadHeaders() async { + const storage = FlutterSecureStorage(); + try { + final raw = await storage.read(key: storageKey); + if (raw == null || raw.isEmpty) return []; + + final decoded = jsonDecode(raw); + if (decoded is! List) return []; + + return CustomProxyHeader.sanitize( + decoded + .whereType() + .map((item) => CustomProxyHeader.fromJson(Map.from(item))) + .toList(), + ); + } catch (_) { + return []; + } + } + + Future saveHeaders(List headers) async { + const storage = FlutterSecureStorage(); + final sanitized = CustomProxyHeader.sanitize(headers); + await storage.write( + key: storageKey, + value: jsonEncode(sanitized.map((header) => header.toJson()).toList()), + ); + } +} diff --git a/mobile/lib/widgets/custom_proxy_headers_editor.dart b/mobile/lib/widgets/custom_proxy_headers_editor.dart new file mode 100644 index 000000000..29dcc95ed --- /dev/null +++ b/mobile/lib/widgets/custom_proxy_headers_editor.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +import '../models/custom_proxy_header.dart'; + +class CustomProxyHeadersEditor extends StatefulWidget { + final List initialHeaders; + final ValueChanged> onChanged; + + const CustomProxyHeadersEditor({ + super.key, + required this.initialHeaders, + required this.onChanged, + }); + + @override + State createState() => _CustomProxyHeadersEditorState(); +} + +class _CustomProxyHeadersEditorState extends State { + late List<_HeaderDraft> _drafts; + + @override + void initState() { + super.initState(); + _drafts = widget.initialHeaders + .map((header) => _HeaderDraft(name: header.name, value: header.value)) + .toList(); + } + + void _notifyChanged() { + widget.onChanged( + _drafts + .map((draft) => CustomProxyHeader(name: draft.name.text, value: draft.value.text)) + .where((header) => header.isComplete) + .toList(), + ); + } + + void _addHeader() { + setState(() => _drafts.add(_HeaderDraft())); + } + + void _removeHeader(int index) { + setState(() { + final draft = _drafts.removeAt(index); + draft.dispose(); + }); + _notifyChanged(); + } + + @override + void dispose() { + for (final draft in _drafts) { + draft.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < _drafts.length; index++) ...[ + _HeaderRow( + draft: _drafts[index], + onChanged: _notifyChanged, + onRemove: () => _removeHeader(index), + ), + const SizedBox(height: 12), + ], + OutlinedButton.icon( + onPressed: _addHeader, + icon: const Icon(Icons.add), + label: const Text('Add header'), + ), + ], + ); + } +} + +class _HeaderRow extends StatelessWidget { + final _HeaderDraft draft; + final VoidCallback onChanged; + final VoidCallback onRemove; + + const _HeaderRow({ + required this.draft, + required this.onChanged, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + children: [ + TextFormField( + controller: draft.name, + decoration: const InputDecoration( + labelText: 'Header name', + hintText: 'X-Auth-Token', + ), + validator: (value) => CustomProxyHeader.validateName(value ?? ''), + onChanged: (_) => onChanged(), + ), + const SizedBox(height: 8), + TextFormField( + controller: draft.value, + decoration: const InputDecoration( + labelText: 'Header value', + ), + obscureText: true, + validator: (value) => CustomProxyHeader.validateValue(value ?? ''), + onChanged: (_) => onChanged(), + ), + ], + ), + ), + IconButton( + tooltip: 'Remove header', + icon: const Icon(Icons.delete_outline), + onPressed: onRemove, + ), + ], + ); + } +} + +class _HeaderDraft { + final TextEditingController name; + final TextEditingController value; + + _HeaderDraft({String name = '', String value = ''}) + : name = TextEditingController(text: name), + value = TextEditingController(text: value); + + void dispose() { + name.dispose(); + value.dispose(); + } +} diff --git a/mobile/test/models/custom_proxy_header_test.dart b/mobile/test/models/custom_proxy_header_test.dart new file mode 100644 index 000000000..6943f4fca --- /dev/null +++ b/mobile/test/models/custom_proxy_header_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sure_mobile/models/custom_proxy_header.dart'; + +void main() { + group('CustomProxyHeader', () { + test('serializes trimmed header name and value', () { + final header = CustomProxyHeader(name: ' X-Auth-Id ', value: ' abc '); + + expect(header.name, 'X-Auth-Id'); + expect(header.value, 'abc'); + expect(header.toJson(), { + 'name': 'X-Auth-Id', + 'value': 'abc', + }); + expect(CustomProxyHeader.fromJson(header.toJson()), header); + }); + + test('rejects empty, malformed, and reserved names', () { + expect(CustomProxyHeader.validateName(''), isNotNull); + expect(CustomProxyHeader.validateName('Bad Header'), isNotNull); + expect(CustomProxyHeader.validateName('Bad:Header'), isNotNull); + expect(CustomProxyHeader.validateName('Authorization'), isNotNull); + expect(CustomProxyHeader.validateName('X-Api-Key'), isNotNull); + expect(CustomProxyHeader.validateName('Accept'), isNotNull); + expect(CustomProxyHeader.validateName('Content-Type'), isNotNull); + }); + + test('allows custom header names with hyphens', () { + expect(CustomProxyHeader.validateName('X-Auth-Id'), isNull); + expect(CustomProxyHeader.validateName('X-Auth-Secret'), isNull); + }); + + test('rejects values containing control characters (header injection)', () { + expect(CustomProxyHeader.validateValue('abc\r\nInjected: 1'), isNotNull); + expect(CustomProxyHeader.validateValue('abc\tdef'), isNotNull); + expect(CustomProxyHeader.validateValue('abc\x7Fdef'), isNotNull); + expect(CustomProxyHeader.validateValue('plain value with spaces'), isNull); + }); + + test('redacts values for display', () { + expect( + CustomProxyHeader(name: 'X-Auth-Secret', value: '1234567890').redactedValue, + '••••••7890', + ); + }); + }); +} diff --git a/mobile/test/services/api_config_headers_test.dart b/mobile/test/services/api_config_headers_test.dart new file mode 100644 index 000000000..cde001b22 --- /dev/null +++ b/mobile/test/services/api_config_headers_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:sure_mobile/models/custom_proxy_header.dart'; +import 'package:sure_mobile/services/api_config.dart'; + +void main() { + setUp(() async { + SharedPreferences.setMockInitialValues({}); + ApiConfig.clearApiKeyAuth(); + ApiConfig.setBaseUrl(ApiConfig.defaultBaseUrl); + ApiConfig.setCustomProxyHeaders([]); + }); + + test('adds custom proxy headers to token auth headers', () { + ApiConfig.setCustomProxyHeaders([ + CustomProxyHeader(name: 'X-Auth-Id', value: 'id'), + CustomProxyHeader(name: 'X-Auth-Secret', value: 'secret'), + ]); + + expect(ApiConfig.getAuthHeaders('token'), { + 'X-Auth-Id': 'id', + 'X-Auth-Secret': 'secret', + 'Authorization': 'Bearer token', + 'Accept': 'application/json', + }); + }); + + test('adds custom proxy headers to unauthenticated json headers', () { + ApiConfig.setCustomProxyHeaders([ + CustomProxyHeader(name: 'X-Mobile-Bypass', value: 'pass'), + ]); + + expect(ApiConfig.jsonHeaders(), { + 'X-Mobile-Bypass': 'pass', + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }); + }); + + test('drops headers with reserved names', () { + ApiConfig.setCustomProxyHeaders([ + CustomProxyHeader(name: 'Accept', value: 'text/plain'), + CustomProxyHeader(name: 'Authorization', value: 'should-be-dropped'), + CustomProxyHeader(name: 'X-Api-Key', value: 'should-be-dropped-too'), + CustomProxyHeader(name: 'Content-Type', value: 'application/xml'), + CustomProxyHeader(name: 'X-Auth-Id', value: 'id'), + ]); + + final result = ApiConfig.customProxyHeaders; + expect(result.length, 1); + expect(result.first.name, 'X-Auth-Id'); + }); + + test('deduplicates headers by normalized name keeping the last value', () { + ApiConfig.setCustomProxyHeaders([ + CustomProxyHeader(name: 'X-Auth-Id', value: 'first'), + CustomProxyHeader(name: 'x-auth-id', value: 'second'), + CustomProxyHeader(name: 'X-Auth-Id', value: 'third'), + ]); + + final result = ApiConfig.customProxyHeaders; + expect(result.length, 1); + expect(result.first.name, 'X-Auth-Id'); + expect(result.first.value, 'third'); + }); + + test('app managed headers win over custom headers', () { + ApiConfig.setCustomProxyHeaders([ + CustomProxyHeader(name: 'Accept', value: 'text/plain'), + CustomProxyHeader(name: 'X-Auth-Id', value: 'id'), + ]); + + expect(ApiConfig.htmlHeaders(), { + 'X-Auth-Id': 'id', + 'Accept': 'text/html', + }); + }); +} diff --git a/mobile/test/services/custom_proxy_headers_service_test.dart b/mobile/test/services/custom_proxy_headers_service_test.dart new file mode 100644 index 000000000..f64f26823 --- /dev/null +++ b/mobile/test/services/custom_proxy_headers_service_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:sure_mobile/models/custom_proxy_header.dart'; +import 'package:sure_mobile/services/custom_proxy_headers_service.dart'; + +void main() { + setUp(() { + FlutterSecureStorage.setMockInitialValues({}); + }); + + test('saves and loads custom proxy headers', () async { + final service = CustomProxyHeadersService.instance; + final headers = [ + CustomProxyHeader(name: 'X-Auth-Id', value: 'id'), + CustomProxyHeader(name: 'X-Auth-Secret', value: 'secret'), + ]; + + await service.saveHeaders(headers); + + expect(await service.loadHeaders(), headers); + }); + + test('drops incomplete and duplicate headers, keeping the last value', () async { + final service = CustomProxyHeadersService.instance; + + await service.saveHeaders([ + CustomProxyHeader(name: 'X-Auth-Id', value: 'old'), + CustomProxyHeader(name: '', value: 'ignored'), + CustomProxyHeader(name: 'X-Auth-Id', value: 'new'), + CustomProxyHeader(name: 'X-Empty', value: ''), + ]); + + expect(await service.loadHeaders(), [ + CustomProxyHeader(name: 'X-Auth-Id', value: 'new'), + ]); + }); + + test('returns an empty list for invalid stored json', () async { + const storage = FlutterSecureStorage(); + await storage.write( + key: CustomProxyHeadersService.storageKey, + value: 'not json', + ); + + expect(await CustomProxyHeadersService.instance.loadHeaders(), isEmpty); + }); +}