You can read the original post in its original format on Rtask website by ThinkR here: Security blind spots in Shiny: why your app is more vulnerable than you think
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.
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 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.
“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 input content can be evaluated and introduce a security flaw. As soon as we start evaluating the content of an input, risks appear.
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:
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.
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.
Whilesystem("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.
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.
Here are some best practices to secure your Shiny applications:
htmltools::htmlEscape()
to escape HTML or JavaScript tags if you need to store them in a database.htmltools::htmlEscape("<script>alert('hello')</script>") [1] "<script>alert('hello')</script>"
eval(parse(text = input$template))
or strictly control user input.match.arg()
, switch()
, or conditional if
structures.sandbox
when evaluation is necessary..csv
or .txt
.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)
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!
This post is better presented on its original ThinkR website here: Security blind spots in Shiny: why your app is more vulnerable than you think