Secure JSON handling in Swift

Secure JSON handling in Swift

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.


Email

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.