Security blind spots in Shiny: why your app is more vulnerable than you think

Shiny Security
Author : Arthur Bréant
Categories : database, development, javascript, security, shiny, sql
Tags : security, shiny, sql
Date :

Buckle up, we’re talking about security…

While developing the Signature.py application, ThinkR recently faced a security issue – in Python – that deserves our full attention.

Versioned on Github, the Signature.py code is analyzed by Dependabot. This is a robot that automatically scans projects for obsolete or vulnerable dependencies and can potentially suggest corrective measures.

Dependabot alerted us to a security vulnerability related to the use of jinja2, and this led us to a broader reflection: are we, as R and Shiny application developers, also concerned by these security issues?

The answer is a resounding YES.

What exactly are we talking about?

The impact of the problem identified in our Python application was potentially serious: unintentional code execution on the backend, potentially accessing the entire machine infrastructure. Fortunately, Signature.py only manipulates character strings, and updating jinja2 was able to fix the alert.

But what about the R ecosystem? Are Shiny developers also exposed to these types of vulnerabilities?

Code injection: an omnipresent danger

Code injection is a common security vulnerability that involves injecting malicious code into a page or application. This code is then executed, creating the security breach. There are several ways to inject code into an application, and Shiny is unfortunately not immune to these risks.

Vulnerabilities in Shiny

The good news

Shiny is well-designed”! By default, inputs in Shiny return (almost) only character strings. As long as the input content is not evaluated, we are relatively safe.

For example, this simple application that simply displays the content entered by the user is secure:

library(shiny)
ui <- fluidPage(
  textInput("template", "Enter something"),
  textOutput("result")
)
server <- function(input, output, session) {
  output$result <- renderText({
    paste("Your choice is", input$template)
  })
}
shinyApp(ui, server)

The bad news

The input content can be evaluated and introduce a security flaw. As soon as we start evaluating the content of an input, risks appear.

The three major families of injection in web applications

1. XSS (Cross-Site Scripting): client-side

JavaScript or CSS code is injected and executed in another user’s browser. In Shiny, this can happen when we render HTML directly from user input without properly escaping it.

For example, this application is vulnerable:

library(shiny)
ui <- fluidPage(
  textInput("text", "Enter something"),
  uiOutput("result")
)
server <- function(input, output, session) {
  output$result <- renderUI({
    HTML(input$text)
  })
}
shinyApp(ui, server)

In the text input, a malicious user could enter <script>alert('hello')</script> or even more dangerous code like:

<script>
  const fakeBtn = document.createElement("button");
  fakeBtn.innerText = "🔐 Login required";
  fakeBtn.style = "display:block; margin:10px 0; padding:10px; background-color:red; color:white;";
  fakeBtn.onclick = function() {
    alert("Enter your credentials!");
  };
  document.body.prepend(fakeBtn);
</script>

In the following vulnerable application, you can copy/paste the entirety of this code snippet into the application below, with the <script> tags to see the threat.

You can write a first comment and then copy/paste the code to see the vulnerability.
No worries here, we don’t store the information in a database 😉

library(shiny)
ui <- fluidPage(
  h2("💬 User comments"),
  textInput("pseudo", "Your username:", "Guest"),
  textAreaInput("message", "Your message:", rows = 3),
  actionButton("envoyer", "Send"),
  tags$hr(),
  h3("💬 Received comments:"),
  uiOutput("commentaires")
)
server <- function(input, output, session) {
  commentaires <- reactiveVal(
    data.frame(
      pseudo = character(), 
      message = character()
    )
  )
  observeEvent(input$envoyer, {
    new_entry <- data.frame(
      pseudo = input$pseudo,
      message = input$message
    )
    commentaires(rbind(commentaires(), new_entry))
  })
  output$commentaires <- renderUI({
    coms <- commentaires()
    if (nrow(coms) == 0) {
      return(NULL)
    }
    HTML(paste0(
      apply(coms, 1, function(row) {
        glue::glue("<p><strong>{row[['pseudo']]}</strong> : {row[['message']]}</p>")
      }),
      collapse = "\n"
    ))
  })
}
shinyApp(ui, server)

There are two main types of XSS:

  • Reflected XSS: The malicious code is immediately executed.
  • Stored XSS: The malicious code is stored in a database and executed when other users access the page.

The threat of Stored XSS is particularly concerning. In the example above, this malicious JavaScript code adds a fake login button that will appear for all future users of the application. Imagine the scenario: an attacker injects this code into your application, and all subsequent users see a red button requesting a login. They click on it, enter their credentials… and this information can be easily retrieved by the attacker.

2. Command Injection: server-side

The code is executed directly on the server. This can happen in Shiny when we directly evaluate the content of an input.

Consider this seemingly harmless application:

library(shiny)
library(glue)
ui <- fluidPage(
  textInput("template", "Enter something"),
  textOutput("result")
)
server <- function(input, output, session) {
  output$result <- renderText({
    glue(input$template)
  })
}
shinyApp(ui, server)

This application is actually vulnerable because glue() automatically evaluates what is between {}. A malicious user could enter {system("ls /", intern = TRUE)} or worse {system("rm -rf /")}.

system() is an R function that executes shell commands.
While system("ls /", intern = TRUE) presents a relative risk as it only displays the contents of the root directory of your computer, {system("rm -rf /")} is a potentially destructive command. This command will try to delete all files on the system! Handle with caution 😉

The use of glue() seems harmless, yet glue::glue(input$template) amounts to the same thing as eval(parse(text = input$template)). The eval(parse(text = ...)) duo is an operation that will try to evaluate text. The developer’s use of these functions is clear: they are trying to evaluate text. However, the use of the glue() function is more insidious here.

3. SQL Injection: database-side

SQL code is executed in the database. This can happen when we build SQL queries from user inputs without properly escaping them.

For example, this application is vulnerable:

library(shiny)
library(DBI)
library(RSQLite)
con <- dbConnect(RSQLite::SQLite(), ":memory:")
dbExecute(
  conn = con, 
  "CREATE TABLE users (
    id INTEGER, name TEXT
  )"
)
dbExecute(
  conn = con, 
  "INSERT INTO users (id, name) 
  VALUES 
    (1, 'Arthur'), 
    (2, 'Adrien'), 
    (3, 'Lucas'), 
    (4, 'Lily'), 
    (5, 'Margot')"
)
ui <- fluidPage(
  h2("SQL Injection Test"),
  textInput(
    inputId = "user_input",
    label = "Select only 1 ID to find your individual",
    value = 1
  ),
  actionButton(
    inputId = "submit",
    label = "Submit"
  ),
  tableOutput(
    outputId = "result"
  )
)
server <- function(input, output, session) {
  rv <- reactiveValues()
  observeEvent(input$submit, {
    req(input$user_input)
    rv$query <- paste0(
      "SELECT * FROM users WHERE id = ", 
      input$user_input
    )
    rv$result <- dbGetQuery(con, rv$query)
  })
  output$result <- renderTable({
    req(rv$result)
    rv$result
  })
}
shinyApp(ui, server)

A user could enter 1 OR 1=1 to retrieve all data from the table, or even 1; DROP TABLE users; to delete the table.

How to protect yourself?

Golden rule: Never trust user input, regardless of context

Here are some best practices to secure your Shiny applications:

  1. For XSS: Use htmltools::htmlEscape() to escape HTML or JavaScript tags if you need to store them in a database.
htmltools::htmlEscape("<script>alert('hello')</script>")
[1] "&lt;script&gt;alert('hello')&lt;/script&gt;"
  1. For Command Injections:
    • Avoid using eval(parse(text = input$template)) or strictly control user input.
    • Only manipulate character strings without evaluating them.
    • Filter and secure user choices with match.arg(), switch(), or conditional if structures.
    • Use a sandbox when evaluation is necessary.
    • For uploaded files, prefer simple formats like .csv or .txt.
  2. For SQL Injections:
    • Use sqlInterpolate() rather than constructing queries with paste():
# Vulnerable
query_vuln <- paste0("
  SELECT * 
  FROM users 
  WHERE id = ", input$user_input
)
# Secure
query_str <- "
  SELECT * 
  FROM users 
  WHERE id = ?id
"
query <- sqlInterpolate(con, query_str, id = input$user_input)
query
# <SQL> 
#  SELECT * 
#  FROM users 
#  WHERE id = 1
dbGetQuery(con, query)

Conclusion

Security is a crucial aspect of web application development, including Shiny applications. Never underestimate the risks related to unvalidated or unescaped user inputs. By following a few simple best practices, you can significantly improve the security of your applications.

Don’t hesitate to contact us if you would like to discuss the security of your Shiny applications!

You can also find all our upcoming training sessions to discover Shiny application development here!


Comments


Also read