Pierrot s’est fait pirater

Sécuriser ses applications Shiny

2026-06-16

Bonjour

Il y a 3 ans


Aux 🔗 Rencontres R 2023, Pierrot maquettait son application Shiny.

La cuisine se dessine.

Il y a 1 an


Aux 🔗 Rencontres R 2025, Pierrot testait son application Shiny.

La cuisine est solide : tiroirs testés, modules emboßtés, repas cuisiné.

Pierrot 2026

Pierrot est fier

Son application est maquettée.

Elle est testée.

Elle est en production chez son client.

Tout va bien.


 jusqu’à ce matin

Le coup de fil

📞 “Pierrot, on a un gros problùme avec l’app
”

> Vous avez été piratés.

La cuisine cambriolée

La belle cuisine de Pierrot


La porte forcée

Les tiroirs vidés

Le coffre-fort ouvert

Les craintes de Pierrot


Ce qu’il avait prĂ©vu

✅ Mal montĂ©e

✅ Pas robuste

✅ Mal testĂ©e

Ce qu’il avait oubliĂ©

❓ CambriolĂ©e

Pierrot n’avait pas pensĂ© à



 la sĂ©curitĂ© 🔐

La sécurité fait partie du métier

Au mĂȘme titre que :

✅ Les tests

✅ La documentation


Pas rĂ©servĂ©e aux experts en cybersĂ©curitĂ©. C’est notre mĂ©tier de dev.

Bonne nouvelle : Shiny est bien foutu

🔗 shiny::runApp("security/apps/reprex1")

Inputs ≈ strings.

On tape :

<script>alert()</script>

✅ Pas d’évaluation = safe.

Mauvaise nouvelle

🔗 shiny::runApp("security/apps/reprex2")

Une seule diff : HTML(input$message) au lieu de input$message.

On tape :

<script>alert()</script>

đŸ’„ le script s’exĂ©cute.

🐛 Faille XSS

L’injection de code

Le principe : injecter du code malveillant qui sera ensuite exĂ©cutĂ© par l’application.


3 grandes familles

🐛 XSS (Cross-Site Scripting) : cĂŽtĂ© navigateur (client)

🐛 Command Injection : cĂŽtĂ© serveur

🐛 SQL Injection : cĂŽtĂ© base de donnĂ©es


Le point commun : exploiter l’absence de validation des donnĂ©es utilisateur.

Command Injection

Command Injection

Du code R exécuté cÎté serveur

Une saisie utilisateur depuis l’interface



ÉvaluĂ©e par l’application


➜ N’importe qui peut exĂ©cuter du code R sur votre serveur.

Lecture de fichiers, accĂšs au systĂšme, exfiltration de secrets

Command Injection — Reprex safe

🔗 shiny::runApp("security/apps/reprex3")

glue("Ton choix est : {input$xyz}")

On tape :

Hello {1+1}

L’input reste une string, jamais Ă©valuĂ©e comme du code.

✅ Pas d’évaluation = safe.

La feature évolue


🔗 shiny::runApp("security/apps/reprex4")

glue(input$xyz)

L’input est traitĂ© comme du code Ă  interprĂ©ter, plus comme du contenu.

On rajoute les accolades :

Hello {1+1}

→ Hello 2.

đŸ’„ L’input est Ă©valuĂ© comme du code.

Démo offensive

🔗 shiny::runApp("security/apps/reprex4")

{system("ls /")}
{readLines("/etc/passwd")}
{system("whoami")}
{system("rm -rf /")}
{system("useradd hacker")}


oups 🙊

Pourquoi c’est sournois

glue(input$template) ≈ eval(parse(text = input$template))


Mais glue() est plus discret car il Ă©value automatiquement ce qu’il y a entre {}.


đŸ„‡ Bonne pratique #1

Figer le template :

glue("{input$template}")

“Mais j’ai un selectInput !”

🔗 shiny::runApp("security/apps/reprex5")

selectInput → 3 choix uniquement
 cĂŽtĂ© UI.

Console navigateur :

Shiny.setInputValue("color", "{1+1}")

đŸ’„ selectInput contournĂ©.

đŸ„‡ Bonne pratique #2

Valider cÎté serveur

input_color <- match.arg(
  input$color,
  choices = c("red", "green", "blue")
)

Ou via switch() / if selon le besoin.


Toute donnĂ©e venant du client doit ĂȘtre validĂ©e serveur-side. Toujours.

Insecure Deserialization

🔗 shiny::runApp("security/apps/reprex6")

Un .rds gĂ©nĂ©rĂ© par l’attaquant :

evil <- function() {
  system("touch /tmp/hacked")
}
saveRDS(evil, "evil.rds")

Du code s’exĂ©cute Ă  l’utilisation de l’objet, pas Ă  readRDS().

đŸ’„ Code attaquant exĂ©cutĂ© cĂŽtĂ© serveur.

đŸ„‡ Bonne pratique #3

Privilégier des formats simples

✅ .csv, .txt, .json — du contenu, pas du code sĂ©rialisĂ©.

đŸ›Ąïž Si vraiment besoin d’un .rds : sandbox.

(callr, processx en environnement restreint, container
)

RÚgle n°1

Ne fais jamais confiance Ă  l’entrĂ©e utilisateur, quel que soit le contexte.

SQL Injection

SQL Injection

🔗 shiny::runApp("security/apps/reprex7")

paste0("SELECT 
 WHERE id = ", input$x)

Tape 1 ➜ Robert ✅

Tape 1 OR 1=1 ➜ toute la table đŸ’„

Démos offensives

1 OR 1=1

Fuite : on récupÚre toutes les lignes de la table.


1; DROP TABLE users

Destruction : selon le driver, la table disparaĂźt.

“Mais j’ai un selectInput !” (bis)

🔗 shiny::runApp("security/apps/reprex8")

selectInput → 5 IDs uniquement
 cĂŽtĂ© UI.

Console navigateur :

Shiny.setInputValue("user_input", "1 OR 1=1")

đŸ’„ MĂȘme fuite, malgrĂ© le selectInput.

đŸ„‡ Bonne pratique #4

❌ Avant

rv$query <- paste0(
  "SELECT * FROM users WHERE id = ",
  input$user_input
)
rv$result <- dbGetQuery(
  con, rv$query
)

✅ Avec sqlInterpolate

rv$query <- sqlInterpolate(
  con,
  "SELECT * FROM users WHERE id = ?id",
  id = input$user_input
)
rv$result <- dbGetQuery(
  con, rv$query
)

MĂȘme attaque 1 OR 1=1 ➜ requĂȘte vide, table intacte.

đŸ„‡ Bonne pratique #4

❌ Avant

rv$query <- paste0(
  "SELECT * FROM users WHERE id = ",
  input$user_input
)
rv$result <- dbGetQuery(
  con, rv$query
)

✅ Ou avec glue_sql

rv$query <- glue_sql(
  "SELECT * FROM users WHERE id = {input$user_input}",
  .con = con
)
rv$result <- dbGetQuery(
  con, rv$query
)

XSS

XSS : Cross-Site Scripting

Du code JS exécuté cÎté navigateur

L’attaquant injecte du code dans une page



stockĂ© cĂŽtĂ© serveur ou simplement reflĂ©té 

➜ exĂ©cutĂ© chez tous les visiteurs qui consultent la page.

XSS : Un mur de commentaires

🔗 shiny::runApp("security/apps/reprex11")

Pierrot poste un message :

Salut tout le monde !

Tout fonctionne ✅

XSS : Un attaquant arrive

🔗 shiny::runApp("security/apps/reprex11")

Un autre poste :

<script>alert(...)</script>

đŸ’„ Alert chez tous les visiteurs.

Et à chaque rechargement : ça repart.

XSS : Le vrai vol

🔗 shiny::runApp("security/apps/reprex11")

L’attaquant raffine :

<script>
  ...createElement("button")...
  fakeBtn.onclick = ...
  document.body.prepend(fakeBtn);
</script>

đŸ’„ Faux bouton de connexion pour tous.

➜ Vol silencieux des credentials.

đŸ„‡ Bonne pratique #5

Échapper le contenu utilisateur avec htmltools::htmlEscape()

❌ Avant

glue::glue(
  "<p><strong>{row$pseudo}</strong>",
  " : {row$message}</p>"
)

✅ Aprùs

glue::glue(
  "<p><strong>{htmlEscape(row$pseudo)}</strong>",
  " : {htmlEscape(row$message)}</p>"
)

Le <script> devient du texte affiché, pas exécuté.

À appliquer sur tout contenu utilisateur : pseudo et message.

La cuisine sécurisée de Pierrot

La cuisine sécurisée de Pierrot

Les 3 portes Ă  fermer

🐛 Command Injection : jamais Ă©valuer un input

🐛 SQL Injection : sqlInterpolate() ou glue_sql(), toujours

🐛 XSS : htmlEscape() tout ce qui s’affiche


🔐 Et la rùgle n°1 : ne jamais faire confiance à l’utilisateur

Et tout ce qu’on n’a pas eu le temps de voir

🔑 L’authentification : shinymanager, Posit Connect, OAuth
 C’est un autre sujet, mais essentiel.


📩 Les dĂ©pendances Ă  jour : pas de Dependabot natif en R.

Mise à jour réguliÚre, surveillance manuelle des annonces upstream des paquets critiques.

Pour aller plus loin

S’entraüner

🔗 connect.thinkr.fr/hackthisshiny/

Lectures

🔗 shiny.posit.co/r/articles/build/sql-injections/

🔗 OWASP — XSS Prevention Cheat Sheet

🔗 vuejs.org/guide/best-practices/security

Merci

Des questions ?

🔗 arthurdata.github.io/rencontresR2026


Pierrot vous remercie.