Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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.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