Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
- Offload `captureEnvelope` to background isolate for Cocoa and Android [#3232](https://github.com/getsentry/sentry-dart/pull/3232)
- Add `sentry.replay_id` to flutter logs ([#3257](https://github.com/getsentry/sentry-dart/pull/3257))

### Fixes

- Fix unsafe json access in `sentry_device` ([#3309](https://github.com/getsentry/sentry-dart/pull/3309))

## 9.7.0

### Features
Expand Down
87 changes: 41 additions & 46 deletions packages/dart/lib/src/protocol/sentry_device.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:meta/meta.dart';
import '../sentry_options.dart';
import '../utils/type_safe_map_access.dart';
import 'access_aware_map.dart';

/// If a device is on portrait or landscape mode
Expand Down Expand Up @@ -179,52 +180,46 @@ class SentryDevice {
factory SentryDevice.fromJson(Map<String, dynamic> data) {
final json = AccessAwareMap(data);
return SentryDevice(
name: json['name'],
family: json['family'],
model: json['model'],
modelId: json['model_id'],
arch: json['arch'],
batteryLevel:
(json['battery_level'] is num ? json['battery_level'] as num : null)
?.toDouble(),
orientation: json['orientation'] == 'portrait'
? SentryOrientation.portrait
: json['orientation'] == 'landscape'
? SentryOrientation.landscape
: null,
manufacturer: json['manufacturer'],
brand: json['brand'],
screenHeightPixels: json['screen_height_pixels']?.toInt(),
screenWidthPixels: json['screen_width_pixels']?.toInt(),
screenDensity: json['screen_density'],
screenDpi: json['screen_dpi'],
online: json['online'],
charging: json['charging'],
lowMemory: json['low_memory'],
simulator: json['simulator'],
memorySize: json['memory_size'],
freeMemory: json['free_memory'],
usableMemory: json['usable_memory'],
storageSize: json['storage_size'],
freeStorage: json['free_storage'],
externalStorageSize: json['external_storage_size'],
externalFreeStorage: json['external_free_storage'],
bootTime: json['boot_time'] != null
? DateTime.tryParse(json['boot_time'])
: null,
processorCount: json['processor_count'],
cpuDescription: json['cpu_description'],
processorFrequency: (json['processor_frequency'] is num)
? (json['processor_frequency'] as num).toDouble()
: null,
deviceType: json['device_type'],
batteryStatus: json['battery_status'],
deviceUniqueIdentifier: json['device_unique_identifier'],
supportsVibration: json['supports_vibration'],
supportsAccelerometer: json['supports_accelerometer'],
supportsGyroscope: json['supports_gyroscope'],
supportsAudio: json['supports_audio'],
supportsLocationService: json['supports_location_service'],
name: json.getString('name'),
family: json.getString('family'),
model: json.getString('model'),
modelId: json.getString('model_id'),
arch: json.getString('arch'),
batteryLevel: json.getDouble('battery_level'),
orientation: switch (json.getString('orientation')) {
'portrait' => SentryOrientation.portrait,
'landscape' => SentryOrientation.landscape,
_ => null,
},
manufacturer: json.getString('manufacturer'),
brand: json.getString('brand'),
screenHeightPixels: json.getInt('screen_height_pixels'),
screenWidthPixels: json.getInt('screen_width_pixels'),
screenDensity: json.getDouble('screen_density'),
screenDpi: json.getInt('screen_dpi'),
online: json.getBool('online'),
charging: json.getBool('charging'),
lowMemory: json.getBool('low_memory'),
simulator: json.getBool('simulator'),
memorySize: json.getInt('memory_size'),
freeMemory: json.getInt('free_memory'),
usableMemory: json.getInt('usable_memory'),
storageSize: json.getInt('storage_size'),
freeStorage: json.getInt('free_storage'),
externalStorageSize: json.getInt('external_storage_size'),
externalFreeStorage: json.getInt('external_free_storage'),
bootTime: json.getDateTime('boot_time'),
processorCount: json.getInt('processor_count'),
cpuDescription: json.getString('cpu_description'),
processorFrequency: json.getDouble('processor_frequency'),
deviceType: json.getString('device_type'),
batteryStatus: json.getString('battery_status'),
deviceUniqueIdentifier: json.getString('device_unique_identifier'),
supportsVibration: json.getBool('supports_vibration'),
supportsAccelerometer: json.getBool('supports_accelerometer'),
supportsGyroscope: json.getBool('supports_gyroscope'),
supportsAudio: json.getBool('supports_audio'),
supportsLocationService: json.getBool('supports_location_service'),
unknown: json.notAccessed(),
);
}
Expand Down
86 changes: 86 additions & 0 deletions packages/dart/lib/src/utils/type_safe_map_access.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import 'package:meta/meta.dart';
import '../sentry.dart';
import '../protocol/sentry_level.dart';

/// Extension providing type-safe value extraction from JSON maps
@internal
extension TypeSafeMapExtension on Map<String, dynamic> {
/// Type-safe string extraction
String? getString(String key) {
final value = this[key];
if (value == null) return null;
if (value is String) return value;

_logTypeMismatch(key, 'String', value.runtimeType.toString());
return null;
}

/// Type-safe int extraction with num support
int? getInt(String key) {
final value = this[key];
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();

_logTypeMismatch(key, 'int', value.runtimeType.toString());
return null;
}

/// Type-safe double extraction with num support
double? getDouble(String key) {
final value = this[key];
if (value == null) return null;
if (value is double) return value;
if (value is num) return value.toDouble();

_logTypeMismatch(key, 'double', value.runtimeType.toString());
return null;
}

/// Type-safe bool extraction with support for 0/1 as false/true
bool? getBool(String key) {
final value = this[key];
if (value == null) return null;
if (value is bool) return value;

// Handle numeric 0 and 1 as boolean values
if (value is num) {
if (value == 0) return false;
if (value == 1) return true;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the reason for this is when using FFI objective_c conversions bool are represented as 1 or 0 nums


_logTypeMismatch(key, 'bool', value.runtimeType.toString());
return null;
}

/// Type-safe DateTime extraction from string
DateTime? getDateTime(String key) {
final value = this[key];
if (value == null) return null;
if (value is! String) {
_logTypeMismatch(
key, 'String (for DateTime)', value.runtimeType.toString());
return null;
}

final dateTime = DateTime.tryParse(value);
if (dateTime == null) {
_logParseError(key, 'DateTime', value);
}
return dateTime;
}

void _logTypeMismatch(String key, String expected, String actual) {
Sentry.currentHub.options.log(
SentryLevel.warning,
'Type mismatch in JSON deserialization: key "$key" expected $expected but got $actual',
);
}

void _logParseError(String key, String expected, dynamic value) {
Sentry.currentHub.options.log(
SentryLevel.warning,
'Parse error in JSON deserialization: key "$key" could not be parsed as $expected from value "$value"',
);
}
}
148 changes: 148 additions & 0 deletions packages/dart/test/protocol/sentry_device_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,154 @@ void main() {
null,
);
});

test('orientation handles portrait', () {
final map = {'orientation': 'portrait'};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.orientation, SentryOrientation.portrait);
});

test('orientation handles landscape', () {
final map = {'orientation': 'landscape'};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.orientation, SentryOrientation.landscape);
});

test('orientation returns null for invalid enum value', () {
final map = {'orientation': 'invalid'};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.orientation, isNull);
});

test('orientation returns null for non-string value', () {
final map = {'orientation': 123};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.orientation, isNull);
});

test('bootTime parses valid ISO8601 string', () {
final dateTime = DateTime(2023, 10, 15, 12, 30, 45);
final map = {'boot_time': dateTime.toIso8601String()};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.bootTime, isNotNull);
expect(sentryDevice.bootTime!.year, 2023);
expect(sentryDevice.bootTime!.month, 10);
expect(sentryDevice.bootTime!.day, 15);
});

test('bootTime returns null for invalid date string', () {
final map = {'boot_time': 'not a date'};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.bootTime, isNull);
});

test('bootTime returns null for non-string value', () {
final map = {'boot_time': 12345};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.bootTime, isNull);
});

test('string fields return null for non-string values', () {
final map = {
'name': 123,
'family': true,
'model': ['array'],
'arch': {'object': 'value'},
};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.name, isNull);
expect(sentryDevice.family, isNull);
expect(sentryDevice.model, isNull);
expect(sentryDevice.arch, isNull);
});

test('int fields return null for non-numeric values', () {
final map = {
'screen_height_pixels': 'not a number',
'screen_width_pixels': true,
'screen_dpi': ['array'],
'processor_count': {'object': 'value'},
};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.screenHeightPixels, isNull);
expect(sentryDevice.screenWidthPixels, isNull);
expect(sentryDevice.screenDpi, isNull);
expect(sentryDevice.processorCount, isNull);
});

test('double fields return null for non-numeric values', () {
final map = {
'screen_density': 'not a number',
'processor_frequency': true,
};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.screenDensity, isNull);
expect(sentryDevice.processorFrequency, isNull);
});

test('bool fields return null for non-boolean values', () {
final map = {
'online': 'true',
'simulator': 'false',
};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.online, isNull);
expect(sentryDevice.simulator, isNull);
});

test('bool fields accept numeric 0 and 1 as false and true', () {
final map = {
'charging': 1,
'low_memory': 0,
'online': 1.0,
'simulator': 0.0,
};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.charging, true);
expect(sentryDevice.lowMemory, false);
expect(sentryDevice.online, true);
expect(sentryDevice.simulator, false);
});

test('bool fields return null for other numeric values', () {
final map = {
'charging': 2,
'low_memory': -1,
'online': 0.5,
};
final sentryDevice = SentryDevice.fromJson(map);
expect(sentryDevice.charging, isNull);
expect(sentryDevice.lowMemory, isNull);
expect(sentryDevice.online, isNull);
});

test('mixed valid and invalid data deserializes partially', () {
final map = {
'name': 'valid name',
'family': 123, // invalid
'battery_level': 75.5,
'orientation': 'invalid', // invalid enum
'online': true,
'charging': 'not a bool', // invalid
'screen_height_pixels': 1920,
'screen_width_pixels': 'not a number', // invalid
'boot_time': 'not a date', // invalid
};
final sentryDevice = SentryDevice.fromJson(map);

// Valid fields should deserialize correctly
expect(sentryDevice.name, 'valid name');
expect(sentryDevice.batteryLevel, 75.5);
expect(sentryDevice.online, true);
expect(sentryDevice.screenHeightPixels, 1920);

// Invalid fields should be null
expect(sentryDevice.family, isNull);
expect(sentryDevice.orientation, isNull);
expect(sentryDevice.charging, isNull);
expect(sentryDevice.screenWidthPixels, isNull);
expect(sentryDevice.bootTime, isNull);
});
});

test('copyWith keeps unchanged', () {
Expand Down
Loading
Loading