Files
sure/mobile/lib/models/custom_proxy_header.dart
Michal Tajchert 96c893ec18 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.
2026-05-11 23:09:21 +02:00

91 lines
2.8 KiB
Dart

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