Slack and Plumber, Part Two

[This article was first published on R Views, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

This is the final entry in a three-part series about the plumber package. The first post introduces plumber as an R package for building REST API endpoints in R. The second post builds a working example of a plumber API that powers a Slack slash command. In this final entry, we will secure the API created in the previous post so that it only responds to authenticated requests, and deploy it using RStudio Connect.

As a reminder, this API is built on top of simulated customer call data. The slash command we create will allow users to view a customer status report within Slack. This status report contains customer name, total calls, date of birth, and a plot of call history for the past 20 weeks. The simulated data, along with the script used to create it, can be found in the GitHub repository for this example.

Setup

Successfully following this example assumes you have created a Slack account and you have followed the instructions for creating an app. The Plumber API as it currently exists is described in detail in the previous post.

This API can be run through the UI as previously described, or by running plumber::plumb("plumber.R")$run(port = 5762) from the directory containing the API defined in plumber.R. As it stands now, this API could be deployed and used by Slack. However, it’s important to remember that we have no control over the request that Slack makes to the API. Because of this, we can’t rely on RStudio Connect’s built-in API authentication mechanism to secure the API because there is no way to submit a key with the request. Our options are either to expose the API with no security, meaning anyone can access the endpoints we’ve defined, or to find some other mechanism for securing the API so that it only responds to authorized requests.

API Security Patterns

The plumber documentation provides a good introduction to API security for the R user:

The majority of R programmers have not been trained to give much attention to the security of the code that they write. This is for good reason since running R code on your own machine with no external input gives little opportunity for attackers to leverage your R code to do anything malicious. However, as soon as you expose an API on a network, your concerns and thought process must adapt accordingly.

API security can be challenging to address. As it stands today, it is the developer’s responsibility to provide proper security on API endpoints, though in the future, there may be additional security features added to plumber or available via other R packages.

As mentioned in the plumber documentation, there are a number of things to consider when designing API security. For example, if the API is deployed on an internal network, securing the API may not be as important as it would be if the API was publicly exposed on the internet. When an API needs to be secured, there are several potential attack vectors that need to be handled. In this specific example, we are exposing a public endpoint that provides access to sensitive customer data. If we are unable to authenticate incoming requests, then we risk exposing sensitive data. To prevent this data from falling into the wrong hands, we will focus on verifying incoming requests so that the API only responds to requests made from Slack.

There are several different methods for authenticating requests made to API endpoints. One common method is the use of API keys, which are cryptographically secure values sent with the request to verify the identity of the client. However, in this case, we have no control over the request Slack sends, so we cannot include such a key in the request. Thankfully, Slack has provided an alternative authentication method using signed secrets. Full details can be read in the Slack documentation, but in essence, each Slack application is assigned a unique secret value that, when used in connection with other request details, can be used to verify that an incoming request is indeed coming from Slack and not an unknown third party.

Securing the API

In order to secure our API so that only requests from Slack are honored, we first need to obtain the signing secret for our application. This value can be found in the Basic Information section of the Slack application settings. Now, it is important to remember that this is called a signing secret for a reason: it should not be shared with anyone. To avoid exposing this secret, we can save it as an environment variable. We add this in a current R session by using Sys.setenv(SLACK_SIGNING_SECRET = <our signing secret>), or we can add it to our .Renviron file so that it is set for every R session. Once this is done, we can access this value in R using Sys.getenv("SLACK_SIGNING_SECRET"). Now we are ready to create a function to verify if incoming requests are from Slack.

Slack provides the following three-step process for verifying requests:

  • Your app receives a request from Slack
  • Your app computes a signature based on the request
  • You make sure the computed signature matches the signature on the request

In order to verify all incoming requests, we can define an additional filter for our API that follows the above recipe.

#* Verify incoming requests
#* @filter verify
function(req, res) {
  # Forward requests coming to swagger endpoints
  if (grepl("swagger", tolower(req$PATH_INFO))) return(forward())

  # Check for X_SLACK_REQUEST_TIMESTAMP header
  if (is.null(req$HTTP_X_SLACK_REQUEST_TIMESTAMP)) {
    res$status <- 401
  }

  # Build base string
  base_string <- paste(
    "v0",
    req$HTTP_X_SLACK_REQUEST_TIMESTAMP,
    req$postBody,
    sep = ":"
  )

  # Slack Signing secret is available as environment variable
  # SLACK_SIGNING_SECRET
  computed_request_signature <- paste0(
    "v0=",
    openssl::sha256(base_string, Sys.getenv("SLACK_SIGNING_SECRET"))
  )

  # If the computed request signature doesn't match the signature provided in the
  # request, set status of response to 401
  if (!identical(req$HTTP_X_SLACK_SIGNATURE, computed_request_signature)) {
    res$status <- 401
  } else {
    res$status <- 200
  }

  if (res$status == 401) {
    list(
      text = "Error: Invalid request"
    )
  } else {
    forward()
  }
}

There are a lot of moving pieces to this filter, but essentially we are following the process outlined by Slack for verifying requests. We also allow Swagger endpoints to be served without verification so that the Swagger UI can still be generated for our API.

Once this filter is in place, all incoming requests will be verified. However, this will create issues with our /plot/history/ endpoint since it is called using a standard GET request without any Slack authentication. To ensure that this endpoint is able to be utilized as we want, we’ll make some small updates to the endpoint and add #* @preempt verify to the plumber comments before the function. This prevents the verify filter from applying to this endpoint.

Now, this prevents the Slack authentication process from applying to our plot endpoint. However, this endpoint, if left unsecured, provides unfiltered access to sensitive customer data. We need an effective way to secure this endpoint so that it only responds to requests generated from Slack.

Since the only thing we control in the request to this endpoint is the URL, we can update our endpoint so that an encrypted parameter is passed as part of the URL. This parameter is a combination of the current datetime and the customer ID that is then encrypted using our Slack signing secret. We can use the encrypt_string() function from the safer package to securely encrypt this string. The following example illustrates this process.

current_time <- Sys.time()
customer_id <- 89
parameter_string <- paste(current_time, customer_id, sep = ";")
safer::encrypt_string(parameter_string, Sys.getenv("SLACK_SIGNING_SECRET"))
## [1] "m7NfMZfpY1n5EuivjuiFQsyKopT68HiX+NIgk5S+VBlDHrVqzRM="

Once we have created this encrypted value, we pass it to the URL of our plot endpoint. Then, within the plot endpoint, we decrypt the string, extract the customer ID, and check to see if the current time is within five seconds of the time encoded in the string. If more than five seconds have passed, we consider the request to be unauthorized. To help with this process, we define two helper functions:

encrypt_string <- function(string) {
  urltools::url_encode(safer::encrypt_string(paste(Sys.time(), string, sep = ";"),
                                             key = Sys.getenv("SLACK_SIGNING_SECRET")))
}

plot_auth <- function(endpoint, time_limit = 5) {
  # Save current time to compare against endpoint time value
  current_time <- Sys.time()

  # Try to decrypt endpoint and extract user id
  tryCatch({
    # Decrypt endpoint using SLACK_SIGNING_SECRET
    decrypted_endpoint <- safer::decrypt_string(endpoint,
                                                key = Sys.getenv("SLACK_SIGNING_SECRET"))
    # Split endpoint on ;
    endpoint_split <- unlist(strsplit(decrypted_endpoint, split = ";"))
    # Convert time
    endpoint_time <- as.POSIXct(endpoint_split[1])
    # Calculate time difference
    time_diff <- difftime(current_time, endpoint_time, units = "secs")

    # If more than 5 seconds have passed since the request was generated, then
    # error
    if (time_diff > time_limit) {
      "Unauthorized"
    } else {
      endpoint_split[2]
    }
  },
  error = function(e) "Unauthorized"
  )
}

Once these helper functions are in place, we can update our /plot/history endpoint as follows:

#* Plot customer weekly calls
#* @png
#* @param cust_secret encrypted value calculated in /status endpoint
#* @response 400 No customer with the given ID was found.
#* @preempt verify
#* @get /plot/history
function(res, cust_secret) {
  # Authenticate that request came from /status
  cust_id <- plot_auth(cust_secret)

  # Return unauthorized error if cust_id is "Unauthorized"
  if (cust_id == "Unauthorized") {
    res$status <- 401
    stop("Unauthorized request")
  } else if (!cust_id %in% sim_data$id) {
    res$status <- 400
    stop("Customer id" , cust_id, " not found.")
  }

  # Filter data to customer id provided
  plot_data <- dplyr::filter(sim_data, id == cust_id)

  # Customer name (id)
  customer_name <- paste0(unique(plot_data$name), " (", unique(plot_data$id), ")")

  # Create plot
  history_plot <- plot_data %>%
    ggplot(aes(x = time, y = calls, col = calls)) +
    ggalt::geom_lollipop(show.legend = FALSE) +
    theme_light() +
    labs(
      title = paste("Weekly calls for", customer_name),
      x = "Week",
      y = "Calls"
    )

  # print() is necessary to render plot properly
  print(history_plot)
}

Now we need to make one small change to our /status endpoint so that we properly build the appropriate URL for our image. We construct the list response to the /status endpoint as follows, where image_url has been updated.

    attachments = list(
      list(
        color = customer_status,
        title = paste0("Status update for ", customer_name, " (", customer_id, ")"),
        fallback = paste0("Status update for ", customer_name, " (", customer_id, ")"),
        # History plot

        image_url = paste0(base_url,
                           "/plot/history?cust_secret=",
                           encrypt_string(customer_id)),
        # Fields provide a way of communicating semi-tabular data in Slack
        fields = list(
          list(
            title = "Total Calls",
            value = sum(customer_data$calls),
            short = TRUE
          ),
          list(
            title = "DoB",
            value = unique(customer_data$dob),
            short = TRUE
          )
        )
      )
    )

Just like that, we have a secure API!

All Together Now

Now, given the authorization pieces we have implemented, it is a bit more difficult to test our API since our endpoints will only respond to authorized requests. However, we can use the free version of Postman to test our API. An in-depth look at the capabilities of Postman is beyond the scope of this post, so hopefully a gif will suffice. Further details about using Postman in this context can be found in the GitHub repository for this example.

It appears that everything is working as expected! Our endpoints fail when the authorization criteria are not met, and otherwise they succeed. Notice that the plot endpoint works when initially called, but when a subsequent call is made it fails since more than five seconds have passed since the /status endpoint was invoked.

Now, the final step in this process is publishing this API so that Slack can properly interact with it. The easiest way to do this is to publish the API to RStudio Connect. Once published, Slack can be updated to point the Slash command to our nice, newly secured API.

Conclusion

This brings us to the conclusion of this series. We’ve discovered the power of plumber in exposing R to downstream consumers via RESTful API endpoints. We built a Slack app powered entirely by R and plumber, and now we have secured the underlying API so that it only responds to authorized requests. As we have seen, plumber provides a powerful and flexible framework for exposing R functions as APIs. These APIs can be safely secured so that only authorized requests are permitted.

James Blair is a solutions engineer at RStudio who focuses on tools, technologies, and best practices for using R in the enterprise.

To leave a comment for the author, please follow the link and comment on their blog: R Views.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Never miss an update!
Subscribe to R-bloggers to receive
e-mails with the latest R posts.
(You will not see this message again.)

Click here to close (This popup will not appear again)