Safe Web Services with Actix and Sentry

Jan Michael Auer /

Remember that time Mom told you that the internet is a dangerous place? No? Well, she did, but you weren’t listening.

Jokes aside, we can probably all agree that there are many potential security risks in web services, with all their APIs and user-contributed content. Yet, the internet is what defines our digital age, and barely any piece of technology can do without.

In the midst of this insecurity, Rust came along with its memory safety and zero-cost abstractions. Don’t you think this is the perfect language for creating high-performance web services without buffer overflows? We certainly do, and so did the creators of Actix, ”Rust’s powerful actor system and most fun web framework.”

In this blog post, we’re giving a quick introduction to creating a web server with Actix and monitoring it using Sentry.

Installing Actix

Actix distributes its web-related functionality in the actix-web crate. We’ve created sentry-actix, an extension to the sentry crate that integrates nicely with the different features of Actix web stack. You will probably also want to install failure for error handling as well as serde for parsing and serializing payloads.

In the end, your Cargo.toml will look something like this:

[dependencies]
actix-web = "0.7.14"
sentry = "0.12.0"
sentry-actix = "0.12.0"

failure = "0.1.3"
failure_derive = "0.1.3"
serde = "1.0.80"
serde_derive = "1.0.80"
serde_json = "1.0.33"

Creating a server

As in any Rust project, start by initializing the Sentry SDK with your DSN to capture errors and panics during startup. Even though Actix will catch panics that occur in your request handlers, the Sentry SDK will be useful if you need to read from configuration files or perform other operations first.

extern crate sentry;
extern crate sentry_actix;

fn main() {
    let _sentry = sentry::init("YOUR_DSN_HERE");
    sentry::integrations::panic::register_panic_handler();
}

To get started with Actix web, create a request handler function. It accepts an HttpRequest instance as its only parameter and returns a type that can be converted into HttpResponse:

extern crate actix_web;
extern crate failure;

use actix_web::{server, App, HttpRequest};
use failure::{err_msg, Error};

fn index(_req: &HttpRequest) -> Result<(), Error> {
    Err(err_msg("not implemented"))
}

Finally, create an App instance, and register the request handler as resource on a path and method. Sentry’s Actix integration provides the SentryMiddleware, which collects useful request information and reports all errors to Sentry when registered on the same application. Then, spawn a server and bind it to a port:

use sentry_actix::SentryMiddleware;

fn create_app() -> App {
    App::new()
        .middleware(SentryMiddleware::new())
        .resource("/", |r| r.f(index))
}

server::new(create_app)
    .bind("127.0.0.1:1337")?
    .run();

Running this server and opening http://127.0.0.1:1337 in your browser should show a beautifully styled error on your command line and create an issue in your Sentry project.

Custom extractors and errors

One of the reasons we found Actix so compelling was its great abstraction over resources.

Extractors are helpers that implement the FromRequest trait. In other words, they construct any object from a request and perform validation along the way. A handful of useful extractors are shipped with Actix web, such as Json which uses serde_json to deserialize a JSON request body:

use actix_web::Json;
use actix_web::http::Method;

#[derive(Deserialize)]
struct Item {
    title: String,
    #[serde(default)]
    description: String,
}

fn create(item: Json<Item>) -> Result<Item, Error> {
    println!("creating item: {}", item.title);
    Ok(item.into_inner())
}

// Inside server::new()
App::new()
    // ...
    .resource(|r| r.method(Method::POST).with(create))

If an error occurs during extraction, Actix web will instantly issue a corresponding error response. That way, only valid data ever reaches your request handlers. By default, SentryMiddleware will not send this error to Sentry since such an error is entirely expected. In fact, the middleware will only send server errors (5XX).

This becomes important when implementing your own extractors. Let’s say we want to load a Session by reading the X-Session-Token header. On error, we return a custom SessionError which indicates whether this was an internal server error or invalid session token. This requires manual implementation of the ResponseError trait:

use actix_web::{HttpResponse, ResponseError};

#[derive(Debug, Fail)]
enum SessionError {
    #[fail(display = "you are not logged in")]
    Missing,
    #[fail(display = "session expired")]
    Invalid,
    #[fail(display = "internal server error")]
    Internal,
}

impl ResponseError for SessionError {
    fn error_response(&self) -> HttpResponse {
        let response_builder = match *self {
            SessionError::Missing => HttpResponse::Unauthorized(),
            SessionError::Invalid => HttpResponse::Forbidden(),
            SessionError::Internal => HttpResponse::InternalServerError(),
        };
        response_builder.body(self.to_string())
    }
}

Based on this, we can create our custom extractor for a Session object. We return errors in various cases, but only SessionError::Internal will be logged to Sentry, since it corresponds to the generic 500 status code:

use actix_web::{FromRequest, HttpRequest};

struct Session {
    pub token: String,
    pub username: String,
}

impl<S> FromRequest<S> for Session {
    type Config = ();
    type Result = Result<Self, SessionError>;

    fn from_request(request: &HttpRequest<S>, _cfg: &Self::Config) -> Self::Result {
        let token = request.headers()
            .get("x-session-token")
            // Ignore a non UTF-8 header value
            .map(|value| value.to_str().ok())
            .ok_or(SessionError::Missing)?;
            
        let session = lookup_session(&token)
            .map_err(|_| SessionError::Internal)?;
            
        match session {
            Some(session) => Ok(session),
            None => Err(SessionError::Invalid),
        }
    }
}

Configuring context

One of the most loved features of Sentry is the ability to capture breadcrumbs and context. Our Actix integration scopes this information to the request so that concurrent requests do not overwrite each other. More precisely, it creates a new Hub for every request which you can access via Hub::from_request:

use sentry::Hub;
use sentry::protocol::User;
use sentry_actix::ActixWebHubExt;

let hub = Hub::from_request(&request);
hub.configure_scope(|scope| {
    scope.set_user(Some(User{
        username: session.username.clone(),
        ..User::default()
    }));
});

The middleware uses the same hub internally to capture exceptions. Conveniently, the returned hub is actually an Arc<sentry::Hub>, and it is both Send and Sync. This allows you to use this hub throughout chains of asynchronous operations:

use actix_web::actix::actix::ResponseFuture;
use sentry::Hub;
use sentry::protocol::Breadcrumb;

fn process_item(
  request: &HttpRequest,
  session: Session, 
  item: Json<Item>,
) -> ResponseFuture<Json<Item>, Error> {
  let hub = Hub::from_request(&request);
  
  let future = process_item_async()
      .and_then(move |processed| {
          hub.add_breadcrumb(Breadcrumb { /* ... */ });
          save_item_async(processed)
      });
      
  Box::new(future)
}

Next steps

If you’re interested in discovering more about Actix, explore advanced concepts like adding state to apps or interacting with databases in the Actix docs. Both Actix and Actix web are fully open-source and hosted on GitHub.

For an inside look at how Sentry uses Rust and Actix, check out our semaphore server, which is built on both the Actix actor system and the Actix web framework.