Y = Code

Software development and computer science

Secure Web Applications in Rust

Rust Lesson & 5 Useful Derive Macros

June 21, 2024

There are many incredible programming languages out there, but none are as special to me as Rust.  From the performance, to the compile time checks, and to the strict memory safety. If you want software systems that people can trust, I can think of no better starting point.

On February 26th, 2024 the White House published a press release about the importance of memory safety in computer software. According to the letter, improving memory safety is critical in reducing the attack surface of digital systems. By adopting memory-safe programming languages, we stand to prevent "entire classes" of software vulnerabilities.

“Some of the most infamous cyber events in history – the Morris worm of 1988, the Slammer worm of 2003, the Heartbleed vulnerability in 2014, the Trident exploit of 2016, the Blastpass exploit of 2023 – were headline-grabbing cyberattacks that caused real-world damage to the systems that society relies on every day. Underlying all of them is a common root cause: memory safety vulnerabilities. For thirty-five years, memory safety vulnerabilities have plagued the digital ecosystem, but it doesn’t have to be this way,” says Anjana Rajan, Assistant National Cyber Director for Technology Security

Ensuring that software is memory safe and secure by design, is imperative to protecting digital systems, and defending national security.

Why Rust Rocks

The design of Rust emphasizes memory safety. Rust has a feature called a "borrow-checker" that enforces strict memory rules before your code is allowed to compile. These rules effectively prevent data-races, buffer overflows, buffer under-runs, null pointer dereferences, and use-after-free errors. Each of those issues have been culprits in major cyber-security attacks. Additionally, Rust provides all of this while enabling low level performance optimizations equal to C++. Furthermore, its unique implementation of enums make error handling both performant and easy to implement, so that developers will be more likely to handle system errors thoroughly, instead of allowing systems to crash when unexpected events happen. Code that is capable of crashing, in Rust, is often explicit in showing that it can crash. With strict development standards, you can eliminate those conditions.

Enums combined with RAII from C++, also permit yet another security win. In Rust, you can design your application so that invalid state cannot be represented in your code. This concept is called Type Driven Design, and it can eliminate data injection vulnerabilities by making them logically impossible to exist within in your code. However, you do have to be diligent enough to use it where it matters.

Caution: Remember, even the most secure software can be compromised if external memory access is possible. Security is more than just your code. The environment your code runs in is also important. A prime example of this was Spectre and Meltdown.

Additionally, Rust's strongly typed macros allow you to programmatically reduce boilerplate code while keeping all of these previously listed features. In fact, you can create your own syntax and sub-programming languages within Rust, using its powerful macro system. Combining these two, you can even extend Rust's strong compile time checks into other domains. An example of this is how SQLx's macros allow you to write SQL queries in Rust, which are then checked for correctness against your SQL database schema. This provides yet another level of enforced code correctness which can also prevent unexpected software vulnerabilities.

Ultimately, any code that is written without some system in place to check it, is a liability. Rust makes it possible to check your code at a level that was previously inaccessible to most developers.

For Rust Beginners

If you are still new to Rust, then the remainder of this post may be challenging to parse. For people who are wholly new to Rust, I highly recommend the official Rust documentation, or The Rust Programming Language book. Other helpful resources are:

A Lesson In Macros

In my efforts to develop Rust server software at my company Iron Oxide Labs, I discovered a series of Derive Macro libraries that were invaluable in accelerating my development efforts. Here are my top 5 most useful Rust derive macro libraries for web applications:

Derive New

"Derive-new" is very useful in web applications for defining simple constructors for your entity structs. If all you need is a simple new method that uses all of your fields as parameters, then derive-new will take care of it.

For this example, your main.rs should contain:

use derive_new::new;

type Password = String;

#[allow(dead_code)]
#[derive(Debug, new)]
struct User {
    id: i64,
    username: String,
    email: String,
    password: Password,
}

fn main() {
  let password:Password = "123".into();
  let user = User::new(
      1,
      "someuser100".into(),
      "someuser100@protomail.com".into(),
      password
    );
  println!("User {user:#?}");
}

And your cargo.toml should contain:

[package]
name = "secure-web-applictions-in-rust"
version = "0.1.0"
edition = "2021"

[dependencies]
derive-new = "0.6.0"

You can also specify defaults, use it with generics, and it also works with macros. Regardless, without the macro that same code becomes:

#[allow(dead_code)]
#[derive(Debug)]
struct User {
    // properties here
}

impl User {
    pub fn new(
        id: i64,
        username: String,
        email: String,
        password: Password
    ) -> Self {
        Self {
            id,
            username,
            email,
            password,
        }
    }
}

Derive Getters

Also helps with entity structs by removing the tedium of defining getter code. With a quick cargo add derive-getters you can write:

use derive_getters::Getters;
use derive_new::new;

type Password = String;

#[derive(Debug, Getters, new)]
struct User {
    id: i64,
    username: String,
    email: String,
    // Let's keep the password a secret
    #[allow(dead_code)]
    #[getter(skip)]
    password: Password,
}

fn main() {
    let password: Password = "123".into();
    let user = User::new(
        1,
        "someuser100".into(),
        "someuser100@protomail.com".into(),
        password,
    );
    println!(
        "User id {}, username {}, email {}",
        user.id(),
        user.username(),
        user.email()
    );
}

Without the macros you would need:

impl User {
    pub fn new(
        id: i64,
        username: String,
        email: String,
        password: Password
    ) -> Self {
        Self {
            id,
            username,
            email,
            password,
        }
    }
    pub fn id(&self) -> i64 {
        self.id
    }
    pub fn email(&self) -> String {
        self.email
    }
    pub fn id(&self) -> Password {
        self.password
    } 
}

Derive Builder

Just cargo add derive-builder, and now you can take the powerful Builder design pattern, and trivialize it as a derive macro. For example:

#[derive(Debug, Clone, Eq, Getters, Builder)]
struct User {
    id: i64,
    username: String,
    email: String,
    // Let's keep the password a secret
    #[allow(dead_code)]
    #[getter(skip)]
    password: Password,
}

Which can be used with:

let user = UserBuilder::default()
    .id(1)
    .username("someuser100".into())
    .email("someuser100@protomail.com".into())
    .password(password)
    .build()?;

Without the macro, you would have to implement a self consuming builder by hand, with setters for each value. Doing this would be very tedious. Regardless, by combining getters and builders, you can have an easy-to-use entity struct that is also immutable. Immutability is very useful for concurrency, and ensuring program correctness. If a value cannot be inadvertently changed, then that is one less possibility that another developer will have to think about while reading through your code.

Strum

Strum makes using macros as traditional macros, much easier in Rust. That adds quality of life features you may be accustomed to in other programming languages such as Java, Go, C#, Python, Swift, Kotlin, and TypeScript, such as the ability to iterate over your enums, and the ability to associate constant values with them.

For example, say that you want to add a language feature to this user entity for i18n. To do that, you will be well served by using an enum with strum. Simply run cargo add strum strum-macros (don't forget the "s") and then write:

use strum::{EnumProperty, IntoEnumIterator};
use strum_macros::{Display, EnumIter, EnumProperty};

#[derive(Debug, Display, Clone, PartialEq, Eq, EnumIter, EnumProperty)]
pub enum Language {
    #[strum(props(lang = "en"))]
    English,
    #[strum(props(lang = "zh"))]
    Chinese,
    #[strum(props(lang = "es"))]
    Spanish,
    #[strum(props(lang = "fr"))]
    French,
    #[strum(props(lang = "de"))]
    German,
    #[strum(props(lang = "ja"))]
    Japanese,
    #[strum(props(lang = "pt"))]
    Portuguese,
    #[strum(props(lang = "ru"))]
    Russian,
    #[strum(props(lang = "ar"))]
    Arabic,
    #[strum(props(lang = "ko"))]
    Korean,
}
impl TryFrom<&str> for Language {
    type Error = String;

    fn try_from(value: &str) ->
    	Result<Self, Self::Error> {
        Self::iter()
            .find(|l| {
                l.get_str("lang") == Some(value)
            })
            .ok_or_else(|| {
                "Language not found".to_string()
            })
    }
}

Using that code, you can get an instance of Language with:

Language::try_from("en")?

Now you have a collection of languages that have ISO 639 language codes associated with them. You also can use the try_from method to conveniently convert a string of that ISO code to a Language enum.

ThisError

You may have noticed the custom error added in the TryFrom impl, that error can be made much more robust with minimal boilerplate code using the ThisError derive macros. Just cargo add thiserror and add the code:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum LanguageError {
    #[error("Language Not Found")]
    NotFound
}

impl TryFrom<&str> for Language {
    type Error = LanguageError;

    fn try_from(value: &str) ->
    	Result<Self, Self::Error> {
        Self::iter()
            .find(|l| {
                l.get_str("lang") == Some(value)
            })
            .ok_or(Self::Error::NotFound)
    }
}

Since error handling is such a big part of writing reliable software that fails gracefully instead of panicking and crashing, having a good error defining library is very helpful. This library has many advanced features and I highly recommend exploring them all.

Type Driven Design

Type Driven Design is a powerful concept with an incredible potential for ensuring program correctness and resistance to injection attacks. By enforcing strict data validation at the data type level, you can ensure that only validated data is passed around internally within your application. Using this paradigm in a language such as Rust, with its strong compile time type checking, is possibly one of the best "cheat codes" in Rust language development.

One idea within the type driven design concept is the idiom of "Parse Don't Validate." With parse, don't validate, you do not pass around the potentially dangerous raw data that you received from the user. Instead, you attempt to sanitize your input, by parsing it into a well-defined data type. If the input data is valid, then your parse method returns an instance of your custom data type. Now, everywhere that your custom type is used, you know that the data is already validated and safe to use. If however the data is not valid, then parsing it fails immediately, and now you can respond to the error at the point of entry. Throwing errors close to the original cause of the error, grants you the best opportunity to add precise context to the error event.

Type Driven Design, however, goes even further than validation. By designing the structure of your application to disallow invalid states, you can prevent data corruption caused not only by invalid inputs, but also by misbehaving code. Fully applying that concept is quite complex and exceeds the scope of this post. However, you can read more about it on Alexis King's blog post.

For our example, we will attempt to use the "parse don't validate" idiom on the Language enum. Luckily, we already have most of the functionality for this in place, all we really need is a little bit of syntax sugar to make it clear what we are doing here. To do this, we simply replace impl TryFrom<&str> with impl FromStr.

use std::FromStr;

impl FromStr for Language {
    type Err = LanguageError;

    fn from_str(s: &str) ->
    	Result<Self, Self::Err> {
        Self::iter()
            .find(|l| {
                l.get_str("lang") == Some(s)
            })
            .ok_or(Self::Err::NotFound)
    }
}

Now you can get an instance of Language with

"en".parse()?

Much nicer than the old way, right?

assert_eq!("en".parse()?, Language::try_from("en")?)

Technically, both are still "parse, don't validate," but the syntax is nice.

Bonus Lesson

If you've been following along, you may have realized that there is much more in this code that can benefit from the parse don't validate idiom. And if you're thinking what I'm thinking, the first one is the password!

Okay, there's a good chance you said email, or even username? B grades are still passing...

As we should know, passwords should never be stored as plain text in a database, as that makes them easy targets for hackers. Since our focus is cyber-security, we cannot finish this exercise with such a glaring omission. We will apply password hashing and salting, with the wonderful argon2 library. To facilitate this upgrade, we will replace the Password type with a struct to encode the salt and hash. First we'll cargo add argon2 and then write:

#[derive(Clone)]
struct Password {
    salt: [u8; 16],
    hash: [u8; 32],
}

impl fmt::Debug for Password {
    fn fmt(
        &self,
        f: &mut fmt::Formatter<'_>
    ) -> fmt::Result {
        // Let's keep the password a secret
        f.debug_struct("Password").finish()
    }
}

This is as simple as it gets, we have a 16 byte salt and a 32 byte hash. Since the sizes of these are known, we can stick to fixed arrays and profit from the performance boost. To avoid leaking secrets accidentally, we define a debug method that doesn't expose the inner contents. Now, since Argon2 is a complex object, we'll avoid creating more instances of it than necessary. To retain an instance of it, we'll define a PasswordCypher object that will handle constructing Password objects and trying password text against them. Also, to handle the error condition, we'll rename and expand on the error enum from before.

#[derive(Debug, Error)]
pub enum ApplicationError {
    #[error("Language Not Found")]
    LanguageNotFound,
    #[error("Password Hashing Failed: {0}")]
    PasswordHash(String),
}

#[derive(Clone, Default, new)]
struct PasswordCypher<'a> {
    argon: Argon2<'a>,
}

impl<'a> PasswordCypher<'a> {
    pub fn encode(
        &self,
        raw_text: &str
    ) -> Result<Password, ApplicationError> {
        let mut salt = [0; 16];
        let mut hash = [0; 32];
        OsRng::fill(&mut OsRng, &mut salt);
        self.argon
            .hash_password_into(
                raw_text.as_bytes(),
                &salt,
                &mut hash
        	)
            .map_err(|e| {
                ApplicationError
                	::PasswordHash(e.to_string())
        	})?;
        Ok(Password { salt, hash })
    }
    pub fn try_password(
        &self,
        password: &Password,
        raw_text: &str,
    ) -> Result<bool, ApplicationError> {
        let argon = Argon2::default();
        let mut hash = [0; 32];
        argon
            .hash_password_into(
                raw_text.as_bytes(),
                &password.salt,
                &mut hash)
            .map_err(|e| {
                ApplicationError
                	::PasswordHash(e.to_string())
        	})?;
        Ok(password.hash == hash)
    }
}

Then we'll update the user struct with a method to "try" the password.

impl User {
    pub fn try_password_with(
        &self,
        cypher: &PasswordCypher,
        raw_password: &str,
    ) -> Result<bool, ApplicationError> {
        cypher.try_password(&self.password, raw_password)
    }
}

Implementing the code will look like the following:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cypher = PasswordCypher::default();
    let user = UserBuilder::default()
        .id(1)
        .username("someuser100".into())
        .email("someuser100@protomail.com".into())
        .password(cypher.encode("123")?)
        .language("en".parse()?)
        .build()?;
    println!(
        "User id {}, username {}, email {}, language {}",
        user.id(),
        user.username(),
        user.email(),
        user.language(),
    );
    assert!(user.try_password_with(&cypher, "123")?);
    assert!(!user.try_password_with(&cypher, "1234")?);
}

You can view the final source for this project on my GitHub: Secure Web Applications In Rust

Conclusion

By using these five derived macro crates, type driven design, and the Rust programming language, we can make robust web applications which are highly resilient to memory errors, data injection vulnerabilities, and even programming errors. This of course, does not make our software perfect or impervious to malicious actors. Cyber-security is a moving target with new threats emerging by the day, some may even circumvent everything that you have read here and change all the paradigms again. Until then, this is a good starting point.

Moreover, these tools demonstrate that with the power of Rust's type system, and macros, building secure software can be both performant on the CPU, and efficient with your time as a developer.

Exercises On Your Own

As we left this project with some obvious work to do, this is an opportunity to brainstorm how you would improve this code. Using the type driven design, and the parse don't validate idiom, you can further enhance this code's security and robustness, for both the username and email fields. Take some time to think about what values are valid, and which values are not. Imagine what a hacker might try in their attempt to break your system. By thinking in these ways, you may find that there are many rules which can be applied to improve the security of this application.

Additional Resources

Cyber-Security

Rust