From 042e761a15ebe78de2365d4baa219ac9c819d984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Thu, 23 Jul 2026 09:48:39 +0200 Subject: [PATCH 1/3] sanitize header string --- .../lib/src/video_environment.dart | 30 ++++++++--- .../test/src/video_environment_test.dart | 54 +++++++++++++++++++ 2 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 packages/stream_video/test/src/video_environment_test.dart diff --git a/packages/stream_video/lib/src/video_environment.dart b/packages/stream_video/lib/src/video_environment.dart index 56339f5c2..95dcacdb7 100644 --- a/packages/stream_video/lib/src/video_environment.dart +++ b/packages/stream_video/lib/src/video_environment.dart @@ -68,13 +68,31 @@ extension VideoEnvironmentHeader on VideoEnvironment { /// Builds the `X-Stream-Client` header value. String get xStreamClientHeader => [ 'stream-video-flutter-v$sdkVersion', - if (appName case final name?) 'app=$name', - if (appVersion case final version?) 'app_version=$version', + if (appName case final name?) 'app=${_sanitizeHeaderValue(name)}', + if (appVersion case final version?) + 'app_version=${_sanitizeHeaderValue(version)}', switch ((osName, osVersion)) { - (final name, final version?) => 'os=$name $version', - (final name, null) => 'os=$name', + (final name, final version?) => + 'os=${_sanitizeHeaderValue(name)} ${_sanitizeHeaderValue(version)}', + (final name, null) => 'os=${_sanitizeHeaderValue(name)}', }, - if (deviceModel case final model?) 'device_model=$model', - if (browserName case final name?) 'browser=$name', + if (deviceModel case final model?) + 'device_model=${_sanitizeHeaderValue(model)}', + if (browserName case final name?) 'browser=${_sanitizeHeaderValue(name)}', ].join('|'); + + /// Removes characters that are not valid in an HTTP header field value. + /// + /// Keeps printable US-ASCII characters (`0x20`–`0x7E`) and drops the `|` + /// separator used to join the header segments so individual values cannot + /// break the header structure. + static String _sanitizeHeaderValue(String value) { + final buffer = StringBuffer(); + for (final codeUnit in value.codeUnits) { + if (codeUnit >= 0x20 && codeUnit <= 0x7E && codeUnit != 0x7C) { + buffer.writeCharCode(codeUnit); + } + } + return buffer.toString().trim(); + } } diff --git a/packages/stream_video/test/src/video_environment_test.dart b/packages/stream_video/test/src/video_environment_test.dart new file mode 100644 index 000000000..10d6eca9c --- /dev/null +++ b/packages/stream_video/test/src/video_environment_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_video/src/video_environment.dart'; + +void main() { + group('VideoEnvironment.xStreamClientHeader', () { + test('builds header from all fields', () { + const environment = VideoEnvironment( + sdkVersion: '1.4.1', + osName: 'android', + osVersion: '16', + appName: 'MyApp', + appVersion: '2.5.1', + deviceModel: 'Nothing A142', + ); + + expect( + environment.xStreamClientHeader, + 'stream-video-flutter-v1.4.1|app=MyApp|app_version=2.5.1|' + 'os=android 16|device_model=Nothing A142', + ); + }); + + test('strips non-ASCII characters from the app name', () { + const environment = VideoEnvironment( + sdkVersion: '1.4.1', + osName: 'android', + osVersion: '16', + appName: 'aąbęcódńfśłżźć', + appVersion: '2.5.1', + deviceModel: 'Nothing A142', + ); + + final header = environment.xStreamClientHeader; + + expect(header.contains('app=abcdf'), isTrue); + expect(header.contains('ó'), isFalse); + // The resulting header value must be valid US-ASCII. + expect(header.codeUnits.every((c) => c >= 0x20 && c <= 0x7E), isTrue); + }); + + test('drops the "|" separator from field values', () { + const environment = VideoEnvironment( + sdkVersion: '1.4.1', + osName: 'android', + appName: 'My|App', + ); + + expect( + environment.xStreamClientHeader, + 'stream-video-flutter-v1.4.1|app=MyApp|os=android', + ); + }); + }); +} From 2c51eac1ccadcc11d3f57ac66838e48b823c78fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Thu, 23 Jul 2026 10:01:15 +0200 Subject: [PATCH 2/3] changelog --- packages/stream_video/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/stream_video/CHANGELOG.md b/packages/stream_video/CHANGELOG.md index aa9c26323..97e9e2c5a 100644 --- a/packages/stream_video/CHANGELOG.md +++ b/packages/stream_video/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +### 🐞 Fixed + +- Fixed a `FormatException` when sending requests if the application name (or other device/app info) contains non-ASCII characters. Values included in the `X-Stream-Client` header are now sanitized to valid header characters. + ## 1.4.2 ### ✅ Added From 8abf416dd9a59649a60568979bbb6a94a7f1dee4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20Braz=CC=87ewicz?= Date: Tue, 28 Jul 2026 21:26:17 +0200 Subject: [PATCH 3/3] tweaks --- .../coordinator/open_api/coordinator_ws.dart | 2 +- .../stream_video/lib/src/sfu/ws/sfu_ws.dart | 2 +- .../lib/src/video_environment.dart | 32 ++++++++++++------- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/stream_video/lib/src/coordinator/open_api/coordinator_ws.dart b/packages/stream_video/lib/src/coordinator/open_api/coordinator_ws.dart index 4571bee95..6d535d005 100644 --- a/packages/stream_video/lib/src/coordinator/open_api/coordinator_ws.dart +++ b/packages/stream_video/lib/src/coordinator/open_api/coordinator_ws.dart @@ -34,7 +34,7 @@ String _buildUrl(String baseUrl, String apiKey) { return '$baseUrl' '?api_key=$apiKey' '&stream-auth-type=jwt' - '&X-Stream-Client=$xStreamClientHeader'; + '&X-Stream-Client=${Uri.encodeQueryComponent(xStreamClientHeader)}'; } class CoordinatorWebSocket extends StreamWebSocket implements HealthListener { diff --git a/packages/stream_video/lib/src/sfu/ws/sfu_ws.dart b/packages/stream_video/lib/src/sfu/ws/sfu_ws.dart index c6c4d0f7e..734008325 100644 --- a/packages/stream_video/lib/src/sfu/ws/sfu_ws.dart +++ b/packages/stream_video/lib/src/sfu/ws/sfu_ws.dart @@ -48,7 +48,7 @@ class SfuWebSocket extends StreamWebSocket implements HealthListener { .toString(); } final finalWsEndpoint = - '$sfuWsEndpoint?X-Stream-Client=$xStreamClientHeader&attempt=${++sessionSeq}&cid=$cid&user_id=$userId&api_key=$apiKey&user_session_id=$sessionId'; + '$sfuWsEndpoint?X-Stream-Client=${Uri.encodeQueryComponent(xStreamClientHeader)}&attempt=${++sessionSeq}&cid=$cid&user_id=$userId&api_key=$apiKey&user_session_id=$sessionId'; streamLog.i(tag, () => ' wsEndpoint: $wsEndpoint'); streamLog.i(tag, () => ' sfuWsEndpoint: $sfuWsEndpoint'); streamLog.i(tag, () => ' finalWsEndpoint: $finalWsEndpoint'); diff --git a/packages/stream_video/lib/src/video_environment.dart b/packages/stream_video/lib/src/video_environment.dart index 95dcacdb7..376945f97 100644 --- a/packages/stream_video/lib/src/video_environment.dart +++ b/packages/stream_video/lib/src/video_environment.dart @@ -68,18 +68,26 @@ extension VideoEnvironmentHeader on VideoEnvironment { /// Builds the `X-Stream-Client` header value. String get xStreamClientHeader => [ 'stream-video-flutter-v$sdkVersion', - if (appName case final name?) 'app=${_sanitizeHeaderValue(name)}', - if (appVersion case final version?) - 'app_version=${_sanitizeHeaderValue(version)}', - switch ((osName, osVersion)) { - (final name, final version?) => - 'os=${_sanitizeHeaderValue(name)} ${_sanitizeHeaderValue(version)}', - (final name, null) => 'os=${_sanitizeHeaderValue(name)}', - }, - if (deviceModel case final model?) - 'device_model=${_sanitizeHeaderValue(model)}', - if (browserName case final name?) 'browser=${_sanitizeHeaderValue(name)}', - ].join('|'); + _headerSegment('app', [appName]), + _headerSegment('app_version', [appVersion]), + _headerSegment('os', [osName, osVersion]), + _headerSegment('device_model', [deviceModel]), + _headerSegment('browser', [browserName]), + ].nonNulls.join('|'); + + /// Builds a single `key=value` header segment from [parts], joined by a space. + /// + /// Returns `null` when every part is null or sanitizes to an empty string, so + /// the header never contains a dangling `key=` segment. + static String? _headerSegment(String key, Iterable parts) { + final value = parts.nonNulls + .map(_sanitizeHeaderValue) + .where((part) => part.isNotEmpty) + .join(' '); + + if (value.isEmpty) return null; + return '$key=$value'; + } /// Removes characters that are not valid in an HTTP header field value. ///