When storing or reading data from a disk, or when making API calls, does your app's sole validation of data involve ensuring that the JSON can be decoded?
How do you receive email addresses in your JSON? Do you use a simple string variable?
There is a high likelihood that your current JSON is not as secure as it could be. When combined with other vulnerabilities in libraries and operating systems, a wide range of undesirable outcomes, from crashes to compromised user devices, may arise due to insecure JSON handling.
Typical “Client” struct
Let's envision a scenario where we need to obtain a list of prospective clients from an API, which includes their names, ages, and email addresses.
[{"name":"John Doe","age":35,"email":"john@doe.test"}]
It would be typical to expect the following associated code:
struct Client: Codable {
let name: String
let age: Int
let email: String
}
func decodeClientList(data: Data) throws -> [Client] {
return try JSONDecoder().decode([Client].self, from: data)
}
Unfortunately, this allows for various inputs that don't make sense in our context. For instance, does it make sense for a client's age to be 150,000 years old? Yet, your JSON permits it. Similarly, the email address could be set to "Hello" in the JSON, and the decoder wouldn't raise any issues.
You may need to perform validation on the Client data after it has been decoded. Additionally, remember to re-validate the data when retrieving it from CoreData later on or when accessing it through your caching layer.
Instead, you could declare your Codable in such a way that validation is performed while decoding.
Age
Let’s begin by defining the minimum and maximum age for your clients.
Any “age” value higher than 150 is likely a mistake and shouldn’t be allowed.
Negative values are clearly out of the question (you could use UInt..)
You might even want to set a minimum value, let’s go with 1 for now
struct Age: Codable {
let value: Int
enum Errors: Error {
case outOfRange
}
static let minimumAge = 1
static let maximumAge = 150
@discardableResult
static func evaluated(_ age: Int) throws -> Int {
guard (minimumAge...maximumAge).contains(age) else {
throw Errors.outOfRange
}
return age
}
init(from decoder: Decoder) throws {
let age = try decoder
.singleValueContainer()
.decode(Int.self)
self.value = try Age.evaluated(age)
}
}
In the code above, we have created a struct that contains an integer value representing age, and we have defined the errors that will be thrown if the value does not meet our criteria. We then proceed to create a static function that evaluates the value against our criteria.
The purpose of using a static function for evaluation outside the decoder is that it can be reused in other initializers or anywhere within our app where we need to determine if a value meets the criteria.
Finally for the Age struct, we define the initializer, that extracts the value, evaluates and stores it.
Name
After taking some time to reflect on client names, I realize that I'm not aware of anyone having a name longer than 100 characters, and certainly no one with a name exceeding 500 characters. It's also safe to assume that no one has an "@" symbol or a backslash in their name. Defining some rules, we obtain:
Can contain alphabetical characters (from various alphabets and languages)
Can contain spaces but only between the names
Should contain at least 2 characters
Should be no longer than 100
struct Name: Codable {
let value: String
enum Errors: Error {
case tooShortOrTooLong
case invalidCharacters
}
static let minimumTrimmedLength = 2
static let maximumTrimmedLength = 100
static let allowedCharacters: CharacterSet = .alphanumerics
.union(.whitespaces)
@discardableResult
static func evaluated(_ name: String) throws -> String {
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard (1...100).contains(trimmed.count) else {
throw Errors.tooShortOrTooLong
}
let disallowedCharacters = allowedCharacters.inverted
guard trimmed.rangeOfCharacter(from: disallowedCharacters) == nil else {
throw Errors.invalidCharacters
}
return trimmed
}
init(from decoder: Decoder) throws {
let string = try decoder
.singleValueContainer()
.decode(String.self)
self.value = try Name.evaluated(string)
}
}
We begin the same way by defining the criteria and possible evaluation errors. Followed by the static function to perform the evaluation and the initializer.
Emails are one of the hardest to validate by syntax alone, and this can clearly be seen by the sheer amount of RegEx that exists, all different from one another, to perform the same task.. all succeeding or failing to various degrees. Ref: https://stackoverflow.com/questions/156430/is-regular-expression-recognition-of-an-email-address-hard
If you are looking for a strong Email syntax validation library, consider usingSwiftEmailValidatoravailable for free on GitHub.
import SwiftEmailValidator
struct Email: Codable {
let value: String
enum Errors: Error {
case incorrectlyFormatted
}
@discardableResult
static func evaluated(_ string: String) throws -> String {
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
guard EmailSyntaxValidator.correctlyFormatted(trimmed) else {
throw incorrectlyFormatted
}
return trimmed
}
init(from decoder: Decoder) throws {
let string = try decoder
.singleValueContainer()
.decode(String.self)
self.value = try Email.evaluated(string)
}
}
Secure “Client” struct
We can now go in our original Client struct and update the struct to use our new Codable values:
struct Client: Codable {
let name: Name
let age: Age
let email: Email
}
func decodeClientList(data: Data) throws -> [Client] {
return try JSONDecoder().decode([Client].self, from: data)
}
Conclusion
It is fairly easy to greatly improve our handling of JSON data by defining custom data types with their own evaluation rules and initializers.
Remember that validation rules need to be evaluated against:
Your business requirements
Intended demographics
RFC documents
Other libraries with which you interact (maximum variable length, CoreData SQL injections, …)
Other restrictions imposed by the database administrator, API, etc…
Finally, always define unit tests to validate that your code works as intended.