DEV Community

Dimitri Merejkowsky
Dimitri Merejkowsky

Posted on • Originally published at dmerej.info on

Is Rust worth learning? Part 1: logs and secrets

Originally published on my blog

A language that doesn’t affect the way you think about programming, is not worth knowing. – Alan J. Perlis - Yale University

To see if Rust is worth trying, then, let me tell you a story - based on real events.

Logs and secrets

Let’s say you are writing a Java application that needs to make HTTP calls on an external API.

To do this, you have a client class that implements a call method.

public class Client {
  private void authenticate(String appSecret) {
    //
  }

  private void doRequest(String url) {
    //
  }

  public void call(String appSecret, String url) {
    authenticate(appSecret);
    doRequest(url);
  }
}
Enter fullscreen mode Exit fullscreen mode

Note that you need do send an app secret along the url to make the call, hence the separate authenticate() method.

You also have a Config record to hold the configuration of the application:

public record Config(String appSecret, String url) {
}
Enter fullscreen mode Exit fullscreen mode

Finally, you have a main class that reads the values from the environment, builds a Config and a Client instances and uses it to make calls:

public class App {
  public static void main(String[] args) {
    String appSecret = System.getenv("APP_SECRET");
    String url = System.getenv("API_URL");
    Config config = new Config(appSecret, url);

    Client client = new Client();
    client.call(config.appSecret(), config.url());
  }
}
Enter fullscreen mode Exit fullscreen mode

Since logs are important, you also add a call to logger.info() when making the call:

public void call(String appSecret, String url) {
  authenticate(appSecret);
  logger.info("Making the call"); // <- new
  doRequest(url)
}
Enter fullscreen mode Exit fullscreen mode

A vulnerability happens

You push the code into production, and some time later you get assigned to the following bug in the issue tracker:

BUG: APP_SECRET found in the logs
SEVERITY : critical
PRIORITY : high

The logs sent by the application at startup contains the
APP_SECRET

...
INFO: config: Config[appSecret=s3cret, url=https://api.dev]
...
INFO: Making the call

Enter fullscreen mode Exit fullscreen mode

So you take a look at the code base, and sure enough, you find the problem: someone from an other team added a log containing the contents of the Config class:

// SomewhereElse.java

logger.info("config:" + config.toString());

Enter fullscreen mode Exit fullscreen mode

Sure, the bug is easy to fix. You can just remove the call to logger.info.

But after thinking about it more, you decide to also override the toString() method of the Config struct so that the app secret is always redacted:

public record Config(...) {
+ @Override
+ public String toString() {
+ return String.format("Config[appSecret:REDACTED, url:%s]", this.url);
+ }
}
Enter fullscreen mode Exit fullscreen mode

There. You can mark the bug as “fixed” and move to something else, like take a look at this new programming language called Rust …

A few hours later, here’s what you learned.

Ownership

First, Rust has this notion of ownership: every piece of data must have exactly one owner. By default, data is moved and can’t be used after the move.

Here’s an example:

struct StringOwner {
   inner: String, // <- note: fields are private by default
}

fn main() {
  let s = String::from("hello"); // Creates a new string

  let owner = StringOwner { inner: s }; // Move the string into
                                        // the 'StringOwner' struct

  println!("{}", s); // Compile error: cannot use the string after it's moved
}

Enter fullscreen mode Exit fullscreen mode

Second, if you don’t want to move the value, you have to “borrow” it.

fn borrow_the_string(s: &String) { // < Note the '&'
   //
}

fn main() {
  let s = String::from("hello"); // Creates a new string

  borrow_the_string(&s); // Note the '&'

  println!("{}", s); // OK
}
Enter fullscreen mode Exit fullscreen mode

This is a immutable (or shared) borrow: you won’t be able to modify the string.

You’ve also learned about mutable (or exclusive) borrows. They would allow you to modify the string, but they are not relevant for this story.

The trait system

You also learned that classes don’t exist in Rust. Instead, Rust uses traits, and adds methods to structs using impl blocks:

/* in stuff.rs */

// This says that the Stuffer trait
// contains a method named do_stuff
pub trait Stuffer {
   fn do_stuff(&self);
}

struct Foo {
  // ...
}

// This says that Foo implements the Stuffer trait,
// and than compilation will fail if do_stuff() is not present
// or has not the proper signature
impl Stuffer for Foo {
  fn do_stuff(&self) {
    // Implementation here
  }
}
Enter fullscreen mode Exit fullscreen mode

The trait must be in the scope where you are using it:

use stuff::Foo;

let foo = Foo::new():
foo.do_stuff(); // Error: Stuffer trait is not in scope


use stuff::Foo;
use stuff::Stuffer; // <- new

let foo = Foo::new():
foo.do_stuff(); // OK: Stuffer trait is in scope
Enter fullscreen mode Exit fullscreen mode

A rewrite

So, feeling adventurous, you decide to try and re-implement your application in Rust, just to feel like how the code would look like and if you can find a better way of handling the security issue you had to fix in Java.

You start with the Config class:

struct Config {
  url: String,
  app_secret: String,
}
Enter fullscreen mode Exit fullscreen mode

Then you add the Client class:

struct Client {
  // ...
}

impl Client {
  fn authenticate(&self, app_secret: String) {
    // ...
  }

  fn do_request(&self, url: String) {
    // ...
  }

  fn call(app_secret: String, url: String) {
      self.authenticate(app_secret);
      info!("{}", "Making the call");
      self.do_request(url);
  }
}
Enter fullscreen mode Exit fullscreen mode

And finally, the main() function:

fn main() {

    let app_secret = std::env::var("APP_SECRET").unwrap();
    let url = std::env::var("API_URL").unwrap();

    let config = Config { app_secret, url };

    let client = Client;
    client.call(config.app_secret, config.url);
}
Enter fullscreen mode Exit fullscreen mode

“Well, that was easy” you think. “I wonder what people mean when they’re talking about ‘fighting the borrow checker’”.

Then you try to use client.call a second time:

fn main() {

    let client = Client;
    client.call(config.app_secret, config.url);
    // ...
    client.call(config.app_secret, config.url); // <- new
}
Enter fullscreen mode Exit fullscreen mode
error[E0382]: use of moved value: `config.app_secret`
  --> src/main.rs:27:17
   |
23 | client.call(config.app_secret, config.url);
   | ----------------- value moved here
...
27 | client.call(config.app_secret, config.url);
   | ^^^^^^^^^^^^^^^^^ value used here after move

Enter fullscreen mode Exit fullscreen mode

“Oh, right”, I need to “borrow” the app_secret and the url in the client if I want to be able to keep using the config struct.

So you change the code to be like this instead:

  impl Client {
- fn call(&self, app_secret: String, url: String) {
+ fn call(&self, app_secret: &str, url: &str) {
          //
      }

  }

- client.call(config.app_secret, config.url);
+ client.call(&config.app_secret, &config.url);

Enter fullscreen mode Exit fullscreen mode

There. Now the client borrows the app secret and the url and the code compiles and runs fine.

Feeling confident, you try and reproduce the logging issue.

First, you add a #[derive(Debug)] annotation on top of the Config struct:

+ #[derive(Debug)]
  struct Config {

  }
Enter fullscreen mode Exit fullscreen mode

And then you add a log displaying the contents of the config struct:

  fn main() {
    let config = Config { app_secret, url };
+ info!("config: {:?}", &config);
   }
}

Enter fullscreen mode Exit fullscreen mode

This code works because Rust knows how to print debug representations of strings and booleans and the derive(Debug) annotation can automatically generates the code to print a debug representation of the Config struct.

Indeed, the code compile, and, sure enough, the secret shows up in the log:

[INFO] config: Config { app_secret: "s3cret", url: "https://api.dev" }
[INFO] Making the call to https://api.dev

Enter fullscreen mode Exit fullscreen mode

OK, you’ve reproduced the problem. But can you find a better solution this time?

Time to think

You’ve heard good things about Rust type system, so you wonder: “Would it be possible to solve the problem by adding a new type?”.

You start with a SecretString struct:

struct SecretString {
    inner: String,
}

impl SecretString {
    fn new(inner: String) -> Self {
        Self { inner }
    }
}
Enter fullscreen mode Exit fullscreen mode

The constructor takes ownership of the string, which is a good thing, because it means the inner value can no longer be accessed once the SecretString struct is created:

let secret_value = std::env::var("APP_SECRET").unwrap();
let app_secret = SecretString::new(secret_value):

dbg!(secret_value); // < does not compile!
Enter fullscreen mode Exit fullscreen mode

Then you change the type of app_secret in Config from String to SecretString:

struct Config {
- app_secret: String,
+ app_secret: SecretString,
}
Enter fullscreen mode Exit fullscreen mode

Which means you have to get the inner value when authenticating the client:

impl Client {
- fn call(&self, app_secret: &str, url: &str) {
- self.authenticate(&app_secret);
+ fn call(&self, app_secret: &SecretString, url: &str) {
+ self.authenticate(app_secret.inner);
+ }

}
Enter fullscreen mode Exit fullscreen mode

You are then faced with 2 compile errors:

First, the Debug macro no longer works:

error[E0277]: `SecretString` doesn't implement `std::fmt::Debug`
  --> src/main.rs:26:5
   |
24 | #[derive(Debug)]
   | ----- in this derive macro expansion
25 | struct Config {
26 | app_secret: SecretString,
   | ^^^^^^^^^^^^^^^^^^^^^^^^ `SecretString` cannot be formatted using `{:?}`
   |
   = help: the trait `std::fmt::Debug` is not implemented for `SecretString`
Enter fullscreen mode Exit fullscreen mode

To fix this, you implement the debug trait yourself:

use std::fmt::Debug;
// ^-- new - remember: traits needs to be in scope
// in order to use them

impl Debug for SecretString {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "REDACTED")
    }
}
Enter fullscreen mode Exit fullscreen mode

Second, you can’t use the inner field directly in the call() method:

error[E0616]: field `inner` of struct `SecretString` is private
  --> src/client.rs:13:39
   |
13 | let secret_value = app_secret.inner;
   | ^^^^^ private field

Enter fullscreen mode Exit fullscreen mode

So you add a public method to access the inner value:

impl SecretString {
    pub fn expose_value(&self) -> &str {
        &self.inner
    }
}

Enter fullscreen mode Exit fullscreen mode

And you fix the client code:

pub fn call(&self, app_secret: &SecretString, url: &str) {
    let secret_value = app_secret.expose_value(); // <- new!
    self.authenticate(&secret_value);
}

Enter fullscreen mode Exit fullscreen mode

Going further

Then you think back about the rule about traits having to be in scope, and you decide to move the expose_value method into a trait:

pub trait ExposeSecret {
    fn expose_value(&self) -> &str;
}

impl ExposeSecret for SecretString {
// ^-- used to be `impl SecretString`

    fn expose_value(&self) -> &str {
        &self.inner
    }
}
Enter fullscreen mode Exit fullscreen mode

And then the compiler says:

error[E0599]: no method named `expose_value` found for reference
`&SecretString` in the current scope
  --> src/client.rs:13:39
   |
13 | let secret_value = app_secret.expose_value();
   | ^^^^^^^^^^^^

   = help: items from traits can only be used if the trait is in scope
   = help: the following trait is implemented but not in scope; perhaps add a
           `use` for it:

use crate::secrets::ExposeSecret;
Enter fullscreen mode Exit fullscreen mode

So you comply:

use crate::secrets::ExposeSecret; // <- new!
use crate::secrecy::SecretString;
Enter fullscreen mode Exit fullscreen mode

There - the code compiles, and it’s now pretty hard to leak the app secret:

Indeed, if a value has been wrapped in the SecretString struct, anyone attempting to access it must:

  • call a method that sounds dangerous (expose_value())
  • import a trait that also sounds dangerous (secrets::ExposeSecret)

It’s also very easy to edit the code for safety: just list the usages of the ExposeSecret trait.

Conclusion

I hope you found this post interesting: it shows how you can use some unique features of the Rust programming language to tackle an old issue in a novel way.

I mentioned at the beginning that the this post is based on true stories, so here are my two sources of inspiration:

  • The issue of a secret value exposed by mistake is based on an actual security vulnerability I reported to the Miniflux maintainers. You can see the details in the miniflux repository.
  • The SecretString struct is based on a real crate, called secrecy.

Thanks for taking this journey with me, and if you’re a reading this while trying to fix the log4j security vulnerability, you have my sincere sympathy :)

Cheers!


I'd love to hear what you have to say, so please feel free to leave a comment below, or check out my contact page for more ways to get in touch with me.

Discussion (0)