Skip to content

Commit f93b8fd

Browse files
committed
fix(parsing): Update location message validation for minimum required fields.
New deserializer that should reject location messages that don't pass the minimum field requirements. Also, hopefully we shouldn't crash when we get a location message that has some nulls in it. Fixes #2235
1 parent 69e3270 commit f93b8fd

32 files changed

Lines changed: 591 additions & 347 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## Version 2.5.9
4+
5+
### Bug fixes
6+
7+
- Location message validation now properly enforces minimum required fields (timestamp, latitude, longitude, and tracker ID or topic). Also, hopefully we don't crash on receiving a message with null values in (!) (#2235)
8+
39
## Version 2.5.8
410

511
### Bug fixes

project/app/src/main/java/org/owntracks/android/model/Parser.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ import org.owntracks.android.model.messages.MessageCmd
2121
import org.owntracks.android.model.messages.MessageConfiguration
2222
import org.owntracks.android.model.messages.MessageEncrypted
2323
import org.owntracks.android.model.messages.MessageLocation
24+
import org.owntracks.android.model.messages.MessageLocationDeserializer
2425
import org.owntracks.android.model.messages.MessageLwt
2526
import org.owntracks.android.model.messages.MessageStatus
2627
import org.owntracks.android.model.messages.MessageTransition
2728
import org.owntracks.android.model.messages.MessageUnknown
2829
import org.owntracks.android.model.messages.MessageWaypoint
2930
import org.owntracks.android.model.messages.MessageWaypointCollectionSerializer
3031
import org.owntracks.android.model.messages.MessageWaypoints
32+
import timber.log.Timber
3133

3234
@Singleton
3335
class Parser @Inject constructor(private val encryptionProvider: EncryptionProvider?) {
@@ -40,7 +42,7 @@ class Parser @Inject constructor(private val encryptionProvider: EncryptionProvi
4042
subclass(MessageCmd::class)
4143
subclass(MessageConfiguration::class, MessageConfiguration.MessageConfigurationSerializer)
4244
subclass(MessageEncrypted::class)
43-
subclass(MessageLocation::class)
45+
subclass(MessageLocation::class, MessageLocationDeserializer)
4446
subclass(MessageLwt::class)
4547
subclass(MessageStatus::class)
4648
subclass(MessageTransition::class)
@@ -132,6 +134,9 @@ class Parser @Inject constructor(private val encryptionProvider: EncryptionProvi
132134
fun fromJson(input: ByteArray): MessageBase =
133135
try {
134136
decrypt(fromUnencryptedJson(input))
137+
} catch (e: SerializationException) {
138+
Timber.w(e, "Error parsing message: %s", e.toString())
139+
MessageUnknown
135140
} catch (e: Exception) {
136141
MessageUnknown
137142
}

project/app/src/main/java/org/owntracks/android/model/messages/MessageLocation.kt

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import android.os.Build
66
import java.util.concurrent.TimeUnit
77
import kotlin.math.roundToInt
88
import kotlinx.datetime.Instant
9+
import kotlinx.serialization.ExperimentalSerializationApi
910
import kotlinx.serialization.KSerializer
1011
import kotlinx.serialization.SerialName
1112
import kotlinx.serialization.Serializable
@@ -20,12 +21,34 @@ import kotlinx.serialization.json.JsonElement
2021
import kotlinx.serialization.json.JsonPrimitive
2122
import kotlinx.serialization.json.JsonTransformingSerializer
2223
import kotlinx.serialization.json.doubleOrNull
24+
import kotlinx.serialization.json.intOrNull
25+
import kotlinx.serialization.json.jsonPrimitive
2326
import org.jetbrains.annotations.NotNull
2427
import org.owntracks.android.model.BatteryStatus
2528
import org.owntracks.android.net.WifiInfoProvider
2629
import org.owntracks.android.preferences.Preferences
2730
import org.owntracks.android.preferences.types.MonitoringMode
2831

32+
/** Accepts both integer and float JSON values for Int fields (some clients send `0.0`). */
33+
object LenientIntSerializer : JsonTransformingSerializer<Int>(Int.serializer()) {
34+
override fun transformDeserialize(element: JsonElement): JsonElement =
35+
if (element is JsonPrimitive && element.content != "null" && element.doubleOrNull != null) {
36+
JsonPrimitive(element.doubleOrNull!!.toInt())
37+
} else {
38+
element
39+
}
40+
41+
fun parseIntLenient(element: JsonElement?): Int =
42+
if (element == null) {
43+
0
44+
} else if (element is JsonPrimitive && element.doubleOrNull != null) {
45+
element.doubleOrNull!!.toInt()
46+
} else {
47+
element.jsonPrimitive.intOrNull ?: 0
48+
}
49+
}
50+
51+
@OptIn(ExperimentalSerializationApi::class)
2952
@SuppressLint("UnsafeOptInUsageError")
3053
@Serializable
3154
@SerialName(MessageLocation.TYPE)
@@ -40,9 +63,7 @@ open class MessageLocation(
4063

4164
@kotlinx.serialization.EncodeDefault(kotlinx.serialization.EncodeDefault.Mode.ALWAYS)
4265
@SerialName("created_at")
43-
override var createdAt:
44-
@kotlinx.serialization.Serializable(with = InstantEpochSecondsSerializer::class)
45-
Instant =
66+
override var createdAt: @Serializable(with = InstantEpochSecondsSerializer::class) Instant =
4667
messageWithCreatedAtImpl.createdAt
4768

4869
@kotlinx.serialization.EncodeDefault(kotlinx.serialization.EncodeDefault.Mode.ALWAYS)
@@ -57,19 +78,19 @@ open class MessageLocation(
5778

5879
@SerialName("bs") var batteryStatus: BatteryStatus? = null
5980

60-
@SerialName("acc") var accuracy = 0
81+
@SerialName("acc") @Serializable(with = LenientIntSerializer::class) var accuracy = 0
6182

62-
@SerialName("vac") var verticalAccuracy = 0
83+
@SerialName("vac") @Serializable(with = LenientIntSerializer::class) var verticalAccuracy = 0
6384

64-
@SerialName("lat") var latitude = 0.0
85+
@SerialName("lat") var latitude: Double = 0.0
6586

66-
@SerialName("lon") var longitude = 0.0
87+
@SerialName("lon") var longitude: Double = 0.0
6788

68-
@Serializable(with = LenientIntSerializer::class) @SerialName("alt") var altitude = 0
89+
@SerialName("alt") @Serializable(with = LenientIntSerializer::class) var altitude = 0
6990

70-
@SerialName("vel") var velocity = 0
91+
@SerialName("vel") @Serializable(with = LenientIntSerializer::class) var velocity = 0
7192

72-
@SerialName("cog") var bearing = 0
93+
@SerialName("cog") @Serializable(with = LenientIntSerializer::class) var bearing = 0
7394

7495
@SerialName("tst") var timestamp: Long = 0
7596

@@ -86,7 +107,9 @@ open class MessageLocation(
86107
@SerialName("tid") var trackerId: String? = null
87108

88109
override fun isValidMessage(): Boolean {
89-
return timestamp > 0
110+
// The deserializer validates that required fields are present in JSON
111+
// Constraints: timestamp > 0 and (trackerId is not empty OR visibleTopic is not empty)
112+
return timestamp > 0 && (!trackerId.isNullOrEmpty() || visibleTopic.isNotEmpty())
90113
}
91114

92115
override fun toString(): String =
@@ -101,19 +124,6 @@ open class MessageLocation(
101124
companion object {
102125
const val TYPE = "location"
103126

104-
/** Accepts both integer and float JSON values for Int fields (some clients send `0.0`). */
105-
object LenientIntSerializer : JsonTransformingSerializer<Int>(Int.serializer()) {
106-
override fun transformDeserialize(element: JsonElement): JsonElement =
107-
if (element is JsonPrimitive &&
108-
element.content != "null" &&
109-
element.doubleOrNull != null) {
110-
JsonPrimitive(element.doubleOrNull!!.toInt())
111-
} else {
112-
element
113-
}
114-
}
115-
116-
@SuppressLint("NewApi")
117127
@JvmStatic
118128
fun fromLocation(location: Location, sdk: Int = Build.VERSION.SDK_INT): MessageLocation =
119129
MessageLocation().apply {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package org.owntracks.android.model.messages
2+
3+
import kotlinx.serialization.ExperimentalSerializationApi
4+
import kotlinx.serialization.KSerializer
5+
import kotlinx.serialization.SerializationException
6+
import kotlinx.serialization.descriptors.SerialDescriptor
7+
import kotlinx.serialization.encoding.Decoder
8+
import kotlinx.serialization.encoding.Encoder
9+
import kotlinx.serialization.json.JsonDecoder
10+
import kotlinx.serialization.json.JsonObject
11+
import kotlinx.serialization.json.jsonPrimitive
12+
import kotlinx.serialization.json.longOrNull
13+
14+
object MessageLocationDeserializer : KSerializer<MessageLocation> {
15+
private val defaultSerializer = MessageLocation.serializer()
16+
override val descriptor: SerialDescriptor = defaultSerializer.descriptor
17+
18+
override fun serialize(encoder: Encoder, value: MessageLocation) {
19+
defaultSerializer.serialize(encoder, value)
20+
}
21+
22+
@OptIn(ExperimentalSerializationApi::class)
23+
override fun deserialize(decoder: Decoder): MessageLocation {
24+
if (decoder !is JsonDecoder) {
25+
throw SerializationException("Only JSON decoder is supported")
26+
}
27+
28+
val element = decoder.decodeJsonElement()
29+
require(element is JsonObject) { "Expected JSON object for MessageLocation" }
30+
31+
val errors = mutableListOf<String>()
32+
if (!element.containsKey("tst")) errors.add("missing 'tst' (timestamp)")
33+
if (!element.containsKey("lat")) errors.add("missing 'lat' (latitude)")
34+
if (!element.containsKey("lon")) errors.add("missing 'lon' (longitude)")
35+
if (!element.containsKey("tid") && !element.containsKey("topic")) {
36+
errors.add("missing both 'tid' and 'topic' (at least one required)")
37+
}
38+
39+
// Check for zero timestamp
40+
val tst = element["tst"]?.jsonPrimitive?.longOrNull
41+
if (tst == 0L) {
42+
errors.add("'tst' (timestamp) must be non-zero")
43+
}
44+
45+
if (errors.isNotEmpty()) {
46+
throw SerializationException("Invalid location message: ${errors.joinToString(", ")}")
47+
}
48+
49+
// Use the decoder's json instance to deserialize with the default serializer
50+
return decoder.json.decodeFromJsonElement(defaultSerializer, element)
51+
}
52+
}

project/app/src/test/java/org/owntracks/android/model/EncryptionProviderTest.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ class EncryptionProviderTest {
3333
latitude = 50.1182933
3434
longitude = -5.5407733
3535
accuracy = 5
36+
timestamp = System.currentTimeMillis() / 1000
37+
trackerId = "test"
3638
}
3739
val encrypted = parser.toJson(message)
3840

0 commit comments

Comments
 (0)