Skip to content
Merged
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,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.getValueOrNull('name'),
family: json.getValueOrNull('family'),
model: json.getValueOrNull('model'),
modelId: json.getValueOrNull('model_id'),
arch: json.getValueOrNull('arch'),
batteryLevel: json.getValueOrNull('battery_level'),
orientation: switch (json.getValueOrNull('orientation')) {
'portrait' => SentryOrientation.portrait,
'landscape' => SentryOrientation.landscape,
_ => null,
},
manufacturer: json.getValueOrNull('manufacturer'),
brand: json.getValueOrNull('brand'),
screenHeightPixels: json.getValueOrNull('screen_height_pixels'),
screenWidthPixels: json.getValueOrNull('screen_width_pixels'),
screenDensity: json.getValueOrNull('screen_density'),
screenDpi: json.getValueOrNull('screen_dpi'),
online: json.getValueOrNull('online'),
charging: json.getValueOrNull('charging'),
lowMemory: json.getValueOrNull('low_memory'),
simulator: json.getValueOrNull('simulator'),
memorySize: json.getValueOrNull('memory_size'),
freeMemory: json.getValueOrNull('free_memory'),
usableMemory: json.getValueOrNull('usable_memory'),
storageSize: json.getValueOrNull('storage_size'),
freeStorage: json.getValueOrNull('free_storage'),
externalStorageSize: json.getValueOrNull('external_storage_size'),
externalFreeStorage: json.getValueOrNull('external_free_storage'),
bootTime: json.getValueOrNull('boot_time'),
processorCount: json.getValueOrNull('processor_count'),
cpuDescription: json.getValueOrNull('cpu_description'),
processorFrequency: json.getValueOrNull('processor_frequency'),
deviceType: json.getValueOrNull('device_type'),
batteryStatus: json.getValueOrNull('battery_status'),
deviceUniqueIdentifier: json.getValueOrNull('device_unique_identifier'),
supportsVibration: json.getValueOrNull('supports_vibration'),
supportsAccelerometer: json.getValueOrNull('supports_accelerometer'),
supportsGyroscope: json.getValueOrNull('supports_gyroscope'),
supportsAudio: json.getValueOrNull('supports_audio'),
supportsLocationService: json.getValueOrNull('supports_location_service'),
unknown: json.notAccessed(),
);
}
Expand Down
88 changes: 88 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,88 @@
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> {
/// Generic, type-safe extraction with a few built-in coercions:
Copy link
Collaborator

Choose a reason for hiding this comment

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

❤️

/// - num -> int
/// - num -> double
/// - 0/1 -> bool
/// - String (ISO) -> DateTime
T? getValueOrNull<T>(String key) {
final value = this[key];
if (value == null) return null;

final convertedValue = _tryConvertValue<T>(key, value);
if (convertedValue != null) return convertedValue;

_logTypeMismatch(key, _expectedTypeFor<T>(), value.runtimeType.toString());
return null;
}

T? _tryConvertValue<T>(String key, Object value) {
// Direct hit.
if (value is T) return value as T;

// num -> int
if (T == int) {
if (value is num) return value.toInt() as T;
return null;
}

// num -> double
if (T == double) {
if (value is num) return value.toDouble() as T;
return null;
}

// 0/1 -> bool
if (T == bool) {
// if value is bool directly already handled above
if (value is num) {
if (value == 0) return false as T;
if (value == 1) return true as T;
}
return null;
}

// String(ISO8601) -> DateTime
if (T == DateTime) {
if (value is! String) {
_logTypeMismatch(
key, 'String (for DateTime)', value.runtimeType.toString());
return null;
}
final dt = DateTime.tryParse(value);
if (dt == null) {
_logParseError(key, 'DateTime', value);
return null;
}
return dt as T;
}

return null;
}

String _expectedTypeFor<T>() {
if (T == DateTime) {
return 'String (for DateTime)';
}
return T.toString();
}

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, Object 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