Navigateur et mobile
Cette page regroupe les protections côté client : en-têtes HTTP et politique de sécurité du contenu (CSP) du frontend web, configuration du site statique de l'Office de Tourisme, et stockage sécurisé de l'application mobile. Les détails d'implémentation propres à chaque application sont dans Sécurité du navigateur, Authentification et stockage sécurisé et Accessibilité et vie privée.
Frontend web
Les en-têtes sont déclarés dans la fonction headers() de frontend-web/next.config.mjs et appliqués à toutes les routes (/:path*).
En-têtes
| En-tête | Valeur | Environnement |
|---|---|---|
X-Content-Type-Options |
nosniff |
Tous |
X-Frame-Options |
DENY |
Tous |
Referrer-Policy |
strict-origin-when-cross-origin |
Tous |
Permissions-Policy |
camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=() |
Tous |
Strict-Transport-Security |
max-age=63072000; includeSubDomains (2 ans) |
Tous ; ignoré par le navigateur en HTTP |
Cross-Origin-Opener-Policy |
same-origin |
Tous |
Content-Security-Policy |
Voir ci-dessous | Production uniquement (NODE_ENV=production) |
Next.js est aussi configuré avec poweredByHeader: false (pas d'en-tête X-Powered-By) et productionBrowserSourceMaps: false (pas de source map publique). La CI vérifie qu'aucun fichier .map ni aucun motif de secret ne figure dans .next/static.
Politique CSP
La politique est construite à partir de NEXT_PUBLIC_API_URL et NEXT_PUBLIC_WS_URL, dont seule l'origine est retenue :
| Directive | Valeur |
|---|---|
default-src |
'self' |
script-src |
'self' 'unsafe-inline' |
style-src |
'self' 'unsafe-inline' |
img-src |
'self' data: blob: https: et l'origine de l'API |
font-src |
'self' data: |
connect-src |
'self', origines HTTP et WebSocket de l'API et du serveur temps réel |
object-src |
'none' |
base-uri |
'none' |
frame-ancestors |
'none' |
form-action |
'self' |
manifest-src |
'self' |
worker-src |
'self' blob: |
upgrade-insecure-requests |
Ajoutée quand l'API est en HTTPS |
connect-src limite les destinations des appels réseau : un script injecté ne peut pas exfiltrer de données vers un autre domaine par fetch ou WebSocket. frame-ancestors 'none' et X-Frame-Options: DENY empêchent l'intégration du site dans une iframe (clickjacking).
Attention
script-src autorise 'unsafe-inline'. Ce choix est imposé par les scripts en ligne de Next.js et par themeInitScript, injecté dans frontend-web/src/app/layout.tsx pour appliquer le thème avant le premier rendu. Il affaiblit la protection contre le XSS : un script injecté dans la page serait exécuté. Comme le jeton de session est dans localStorage (voir Sessions), la CSP ne suffit pas à le protéger en cas d'injection. Une CSP par nonce, que Next.js permet via un middleware, retirerait ce besoin.
En développement, aucune CSP n'est envoyée, pour laisser fonctionner le rechargement à chaud.
Autres mesures
| Mesure | Situation |
|---|---|
| Polices | Inter servie par le site lui-même (next/font/local, fichiers de frontend-web/src/app/fonts), autorisée par font-src 'self' data: ; aucune requête vers un service de polices tiers |
| Liens externes | Les liens target="_blank" portent rel="noopener noreferrer" |
Fichier security.txt |
frontend-web/public/.well-known/security.txt, contact securite@etherbay.fr, expiration le 10 novembre 2027 |
| Cookies | Aucun cookie posé par l'application ; préférences et jeton dans localStorage |
| Rendu | React échappe le texte affiché ; le seul dangerouslySetInnerHTML est le script de thème, sans donnée externe |
| Pièces d'identité | La page Identités de l'espace Mairie affiche le recto et le verso en data: URL, autorisées par img-src data: ; la réponse de l'API porte Cache-Control: no-store. Les images restent dans la mémoire de l'onglet tant que la demande est ouverte |
Site de l'Office de Tourisme
Le site est statique (HTML, CSS, modules JavaScript) et servi par nginx non privilégié. La configuration est dans frontend-office-tourisme/docker/nginx.conf et frontend-office-tourisme/docker/security-headers.conf.
En-têtes
| En-tête | Valeur |
|---|---|
Content-Security-Policy |
default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self' |
Strict-Transport-Security |
max-age=63072000; includeSubDomains |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
Referrer-Policy |
strict-origin-when-cross-origin |
Permissions-Policy |
Identique au web |
Cross-Origin-Opener-Policy |
same-origin |
Cross-Origin-Resource-Policy |
same-origin |
La CSP du site est plus stricte que celle du web : aucun script en ligne n'est autorisé (script-src 'self'), et seuls les attributs style en ligne le sont (style-src-attr 'unsafe-inline'), pas les balises <style>. Toutes les ressources (polices Inter et Jost, bibliothèque qrcode-generator, photos) sont servies par le site lui-même.
Le fichier d'en-têtes est inclus au niveau du serveur et dans chaque bloc location qui ajoute ses propres en-têtes. Ce doublement est nécessaire : dans nginx, une directive add_header dans un bloc location annule celles héritées du niveau supérieur.
Durcissement nginx
| Mesure | Directive |
|---|---|
| Version masquée | server_tokens off |
| Méthodes limitées | Tout autre verbe que GET et HEAD reçoit 405 |
| Corps limité | client_max_body_size 16k |
| Fichiers cachés | location ~ /\.(?!well-known) renvoie 404 : .git, .env et assimilés ne sont pas servis |
| Utilisateur | Image nginxinc/nginx-unprivileged, utilisateur 101, port 8080 |
Fichier security.txt |
Servi depuis /.well-known/security.txt |
Serveur de développement
npm run dev sert le site avec serve et frontend-office-tourisme/serve.json. Ce fichier n'applique qu'une partie des en-têtes (CSP, X-Content-Type-Options, Referrer-Policy, X-Frame-Options) aux pages HTML. Les en-têtes complets ne sont garantis que par l'image nginx.
Données dans le navigateur
Le site n'envoie aucune donnée à un serveur. Panier, favoris, commandes de démonstration, inscription à la newsletter et choix du bandeau cookies sont stockés dans le localStorage. La billetterie ne demande aucune coordonnée bancaire. Les modules qui construisent du HTML échappent les valeurs avec escapeHtml de frontend-office-tourisme/assets/js/modules/utils.js. Le bandeau est décrit dans Cookies, mentions et CGU.
Application mobile
Stockage sécurisé
mobile-app/src/services/secureStorage.ts enregistre le jeton (blocklucky_jwt_token) et le profil (blocklucky_user_profile) avec expo-secure-store :
| Plateforme | Support | Option |
|---|---|---|
| iOS | Keychain | WHEN_UNLOCKED_THIS_DEVICE_ONLY : lisible seulement appareil déverrouillé, non migré vers un autre appareil par une sauvegarde |
| Android | Keystore (données chiffrées par une clé du Keystore) | Même option |
| Web (Expo pour le web) | localStorage |
Aucune protection supplémentaire |
Une copie du jeton est aussi gardée dans un cache mémoire du module, vidé à la fermeture de l'application. Les erreurs d'écriture dans le stockage sécurisé sont ignorées silencieusement. L'application ne relit de toute façon pas la session au démarrage : le jeton reste stocké mais l'utilisateur doit se reconnecter. À la déconnexion, le jeton et le profil sont effacés après l'appel à POST /api/auth/logout. Aucun mot de passe n'est stocké sur l'appareil.
Connexion Google
Le mobile obtient le jeton d'identité par expo-auth-session (Google, avec les identifiants EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID, EXPO_PUBLIC_GOOGLE_ANDROID_CLIENT_ID et EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID), puis le transmet au backend, qui le vérifie (SocialAuthButtons.tsx). L'application ne garde pas ce jeton. Les identifiants client sont publics par nature ; ils figurent vides dans mobile-app/.env.example. Sans eux, ou si le backend répond 503, le bouton Google est désactivé.
Photos de la pièce d'identité
VerifyAgeScreen.tsx prend le recto et le verso avec l'appareil photo ou les choisit dans la galerie (expo-image-picker, autorisations demandées au moment de l'usage, textes d'explication déclarés dans app.json). Les images sont lues en base64, sans métadonnées EXIF (exif: false), refusées au-delà de 4 Mo, puis envoyées en HTTPS par POST /api/user/identity et retirées de l'état de l'écran après l'envoi. Le sélecteur dépose aussi une copie de chaque image dans le cache de l'application (asset.uri), que le code ne supprime pas. Une photo prise dans la galerie y reste, puisqu'elle appartient au citoyen.
Configuration
| Mesure | Fichier | Valeur |
|---|---|---|
| Sauvegarde Android | mobile-app/app.json |
android.allowBackup: false : les données de l'application ne partent pas dans la sauvegarde Google |
| Chiffrement déclaré | idem | ios.config.usesNonExemptEncryption: false |
| Autorisations | idem | Appareil photo et photos (plugin expo-image-picker), avec un texte qui explique l'usage pour la pièce d'identité ; lecture des sons par le plugin expo-audio, sans autorisation particulière |
| Schéma d'URL | idem | blocklucky, qui permet à expo-auth-session de revenir dans l'application après l'authentification Google ; aucun lien profond applicatif n'est configuré |
| Secrets embarqués | mobile-app/.env.example |
Uniquement des URL et des identifiants client publics ; toute variable EXPO_PUBLIC_* est lisible dans le binaire |
Ce qui n'est pas en place
| Mesure | Situation |
|---|---|
| Trafic chiffré obligatoire | Les URL d'exemple sont en http://localhost pour le développement. En production, EXPO_PUBLIC_API_URL doit être en HTTPS ; rien dans le code ne refuse une URL HTTP. |
| Épinglage de certificat | Absent |
| Détection de root ou de jailbreak, attestation d'intégrité | Absentes |
| Masquage de l'écran dans le sélecteur d'applications | Absent |
| Garde 401 | Absente : une session révoquée côté serveur n'est pas effacée de l'appareil |
En-tête Idempotency-Key |
Jamais envoyé, y compris pour l'achat d'un ticket à gratter |
| Mise à jour forcée | Le backend expose MOBILE_MIN_VERSION sous le nom minimumMobileVersion dans GET /api/config, mais l'application ne lit pas cette valeur aujourd'hui. Le déploiement est traité dans Builds et publication. |
Ces absences sont acceptables pour un pilote sur réseau de test. Elles redeviennent prioritaires si l'application manipule des fonds réels, la clé de portefeuille restant de toute façon côté serveur.