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
6 changes: 6 additions & 0 deletions packages/stream_video/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion packages/stream_video/lib/src/sfu/ws/sfu_ws.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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, () => '<factory> wsEndpoint: $wsEndpoint');
streamLog.i(tag, () => '<factory> sfuWsEndpoint: $sfuWsEndpoint');
streamLog.i(tag, () => '<factory> finalWsEndpoint: $finalWsEndpoint');
Expand Down
44 changes: 35 additions & 9 deletions packages/stream_video/lib/src/video_environment.dart
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,39 @@ 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',
switch ((osName, osVersion)) {
(final name, final version?) => 'os=$name $version',
(final name, null) => 'os=$name',
},
if (deviceModel case final model?) 'device_model=$model',
if (browserName case final name?) 'browser=$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<String?> 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.
///
/// 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();
}
}
54 changes: 54 additions & 0 deletions packages/stream_video/test/src/video_environment_test.dart
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
}
Loading