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.
This commit is contained in:
Michal Tajchert
2026-05-11 23:09:21 +02:00
committed by GitHub
parent 36960fe058
commit 96c893ec18
12 changed files with 734 additions and 50 deletions

View File

@@ -257,12 +257,6 @@ class _AppWrapperState extends State<AppWrapper> with WidgetsBindingObserver {
});
}
void _goToBackendConfig() {
setState(() {
_hasBackendUrl = false;
});
}
@override
Widget build(BuildContext context) {
if (_isCheckingConfig) {
@@ -314,9 +308,7 @@ class _AppWrapperState extends State<AppWrapper> with WidgetsBindingObserver {
return const SsoOnboardingScreen();
}
return LoginScreen(
onGoToSettings: _goToBackendConfig,
);
return const LoginScreen();
},
);
}

View File

@@ -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<String> _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<String, dynamic> json) {
return CustomProxyHeader(
name: json['name'] as String? ?? '',
value: json['value'] as String? ?? '',
);
}
Map<String, dynamic> 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<CustomProxyHeader> sanitize(List<CustomProxyHeader> headers) {
final byName = <String, CustomProxyHeader>{};
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);
}

View File

@@ -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<BackendConfigScreen> {
final _urlController = TextEditingController();
bool _isLoading = false;
bool _isTesting = false;
bool _hasLoadedConfig = false;
String? _errorMessage;
String? _successMessage;
List<CustomProxyHeader> _customHeaders = [];
@override
void initState() {
@@ -33,16 +38,27 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
}
Future<void> _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<CustomProxyHeader> 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<BackendConfigScreen> {
_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<BackendConfigScreen> {
'',
);
// 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<BackendConfigScreen> {
});
}
} finally {
ApiConfig.setCustomProxyHeaders(previousHeaders);
if (mounted) {
setState(() {
_isTesting = false;
@@ -125,6 +147,10 @@ class _BackendConfigScreenState extends State<BackendConfigScreen> {
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<BackendConfigScreen> {
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

View File

@@ -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<void>(
builder: (routeContext) => BackendConfigScreen(
onConfigSaved: () => Navigator.of(routeContext).pop(),
),
),
);
}
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final _formKey = GlobalKey<FormState>();
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<LoginScreen> {
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<LoginScreen> {
_emailController.dispose();
_passwordController.dispose();
_otpController.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
@@ -261,6 +293,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Email Field
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
textInputAction: TextInputAction.next,
@@ -291,6 +324,7 @@ class _LoginScreenState extends State<LoginScreen> {
// Password Field
TextFormField(
controller: _passwordController,
focusNode: _passwordFocus,
obscureText: _obscurePassword,
textInputAction: showOtp
? TextInputAction.next
@@ -442,7 +476,7 @@ class _LoginScreenState extends State<LoginScreen> {
// 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<LoginScreen> {
child: IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: 'Backend Settings',
onPressed: widget.onGoToSettings,
onPressed: () => widget._openSettings(context),
),
),
],

View File

@@ -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<SettingsScreen> {
bool _biometricSupported = false;
bool _biometricEnabled = false;
bool _isTogglingBiometric = false;
List<CustomProxyHeader> _customHeaders = [];
@override
void initState() {
@@ -34,6 +39,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_loadPreferences();
_loadAppVersion();
_loadBiometricState();
_loadCustomHeaders();
}
Future<void> _loadBiometricState() async {
@@ -93,6 +99,18 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _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<void> _handleClearLocalData(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
@@ -318,6 +336,79 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
Future<void> _showCustomHeadersDialog() async {
final formKey = GlobalKey<FormState>();
final latestHeaders = await CustomProxyHeadersService.instance.loadHeaders();
if (!mounted) return;
setState(() => _customHeaders = latestHeaders);
var draftHeaders = List<CustomProxyHeader>.from(latestHeaders);
final saved = await showDialog<bool>(
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<SettingsScreen> {
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),

View File

@@ -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<CustomProxyHeader> _customProxyHeaders = [];
static List<CustomProxyHeader> get customProxyHeaders =>
List.unmodifiable(_customProxyHeaders);
static void setCustomProxyHeaders(List<CustomProxyHeader> headers) {
_customProxyHeaders = CustomProxyHeader.sanitize(headers);
}
static Map<String, String> get customProxyHeaderMap {
return {
for (final header in _customProxyHeaders) header.name: header.value,
};
}
static Map<String, String> jsonHeaders() {
return {
...customProxyHeaderMap,
'Content-Type': 'application/json',
'Accept': 'application/json',
};
}
static Map<String, String> 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<String, String> 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

View File

@@ -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));

View File

@@ -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<List<CustomProxyHeader>> 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>()
.map((item) => CustomProxyHeader.fromJson(Map<String, dynamic>.from(item)))
.toList(),
);
} catch (_) {
return [];
}
}
Future<void> saveHeaders(List<CustomProxyHeader> headers) async {
const storage = FlutterSecureStorage();
final sanitized = CustomProxyHeader.sanitize(headers);
await storage.write(
key: storageKey,
value: jsonEncode(sanitized.map((header) => header.toJson()).toList()),
);
}
}

View File

@@ -0,0 +1,145 @@
import 'package:flutter/material.dart';
import '../models/custom_proxy_header.dart';
class CustomProxyHeadersEditor extends StatefulWidget {
final List<CustomProxyHeader> initialHeaders;
final ValueChanged<List<CustomProxyHeader>> onChanged;
const CustomProxyHeadersEditor({
super.key,
required this.initialHeaders,
required this.onChanged,
});
@override
State<CustomProxyHeadersEditor> createState() => _CustomProxyHeadersEditorState();
}
class _CustomProxyHeadersEditorState extends State<CustomProxyHeadersEditor> {
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();
}
}

View File

@@ -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',
);
});
});
}

View File

@@ -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',
});
});
}

View File

@@ -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);
});
}