diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4336a58 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,65 @@ +# Mises à jour de dépendances proposées chaque semaine. Les versions mineures et correctives sont +# regroupées en une PR par écosystème et par dossier ; une version majeure arrive seule, pour être lue. +version: 2 +updates: + - package-ecosystem: npm + directory: /backend + schedule: + interval: weekly + groups: + backend-minor-patch: + patterns: ["*"] + update-types: [minor, patch] + + - package-ecosystem: npm + directory: /frontend + schedule: + interval: weekly + groups: + frontend-minor-patch: + patterns: ["*"] + update-types: [minor, patch] + + - package-ecosystem: npm + directory: /landing + schedule: + interval: weekly + groups: + landing-minor-patch: + patterns: ["*"] + update-types: [minor, patch] + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions-minor-patch: + patterns: ["*"] + update-types: [minor, patch] + + # Dockerfile.web (racine) et backend/Dockerfile + - package-ecosystem: docker + directories: + - / + - /backend + schedule: + interval: weekly + groups: + docker-minor-patch: + patterns: ["*"] + update-types: [minor, patch] + + # Images de docker-compose.yml + - package-ecosystem: docker-compose + directory: / + schedule: + interval: weekly + groups: + compose-minor-patch: + patterns: ["*"] + update-types: [minor, patch] + ignore: + # Changer de version majeure de PostgreSQL demande un pg_dump / restauration du volume db-data + - dependency-name: postgres + update-types: [version-update:semver-major] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bd7a61..43a1fd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,11 +25,11 @@ jobs: run: working-directory: frontend steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: persist-credentials: false - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 24 cache: npm @@ -38,6 +38,10 @@ jobs: - name: Dépendances run: npm ci --no-audit --no-fund + # Bloque seulement sur une faille critique d'une dépendance de production + - name: Audit des dépendances de production + run: npm audit --omit=dev --audit-level=critical + - name: Lint run: npm run lint @@ -55,11 +59,11 @@ jobs: run: working-directory: landing steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: persist-credentials: false - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 24 cache: npm @@ -68,10 +72,14 @@ jobs: - name: Dépendances run: npm ci --no-audit --no-fund + # Bloque seulement sur une faille critique d'une dépendance de production + - name: Audit des dépendances de production + run: npm audit --omit=dev --audit-level=critical + - name: Tests run: npm test - # Le build lit les polices dans ../frontend/src/assets, présentes dans le dépôt. + # Le build ne lit que landing/ : aucune police n'est prise dans ../frontend. - name: Build run: npm run build @@ -106,11 +114,11 @@ jobs: CHECKPOINT_DISABLE: '1' steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: persist-credentials: false - - uses: actions/setup-node@v5 + - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0 with: node-version: 24 cache: npm @@ -119,6 +127,10 @@ jobs: - name: Dépendances run: npm ci --no-audit --no-fund + # Bloque seulement sur une faille critique d'une dépendance de production + - name: Audit des dépendances de production + run: npm audit --omit=dev --audit-level=critical + - name: Client Prisma run: npm run prisma:generate @@ -139,7 +151,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 30 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 with: persist-credentials: false diff --git a/.github/workflows/deploy-hostinger.yml b/.github/workflows/deploy-hostinger.yml index bdc3847..ed7bcc2 100644 --- a/.github/workflows/deploy-hostinger.yml +++ b/.github/workflows/deploy-hostinger.yml @@ -48,20 +48,30 @@ jobs: steps: - name: Start SSH agent - uses: webfactory/ssh-agent@v0.9.0 + uses: webfactory/ssh-agent@dc588b651fe13675774614f8e6a936a468676387 # v0.9.0 with: ssh-private-key: ${{ secrets.HOSTINGER_SSH_PRIVATE_KEY }} - - name: Add Hostinger host key (best-effort) + # Clé d'hôte épinglée par le secret SSH_KNOWN_HOSTS (sortie de ssh-keyscan relue à la main, voir + # deploy/HOSTINGER_GITHUB_SETUP.md). Sans ce secret, repli sur ssh-keyscan au moment du run, qui + # fait confiance à la clé présentée ce jour-là : un avertissement le signale dans le run. + - name: Hostinger host key env: HOSTINGER_HOST: ${{ secrets.HOSTINGER_HOST }} HOSTINGER_PORT: ${{ secrets.HOSTINGER_PORT }} + SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }} run: | set -euo pipefail PORT="${HOSTINGER_PORT:-22}" mkdir -p ~/.ssh - # keyscan can fail if the host firewall blocks GitHub runners; make it non-fatal - ssh-keyscan -T 5 -p "$PORT" -H "$HOSTINGER_HOST" >> ~/.ssh/known_hosts || true + chmod 700 ~/.ssh + if [ -n "$SSH_KNOWN_HOSTS" ]; then + printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts + else + echo "::warning::Secret SSH_KNOWN_HOSTS absent : clé d'hôte récupérée par ssh-keyscan, sans vérification. Ajoutez le secret." + ssh-keyscan -T 5 -p "$PORT" -H "$HOSTINGER_HOST" >> ~/.ssh/known_hosts || true + fi + chmod 600 ~/.ssh/known_hosts - name: Deploy on Hostinger env: @@ -74,7 +84,7 @@ jobs: PORT="${HOSTINGER_PORT:-22}" # backend/.env vit sur le serveur, jamais dans GitHub (dépôt public) - ssh -p "$PORT" -o ServerAliveInterval=30 "$HOSTINGER_USER@$HOSTINGER_HOST" "FORCE_FULL=$FORCE_FULL bash -s" <<'EOF' + ssh -p "$PORT" -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 "$HOSTINGER_USER@$HOSTINGER_HOST" "FORCE_FULL=$FORCE_FULL bash -s" <<'EOF' set -euo pipefail cd /var/www/parenthese # Sous le même verrou que deploy.sh : récupération du code, puis déploiement diff --git a/.github/workflows/fresh-install.yml b/.github/workflows/fresh-install.yml index 7893d2d..d5e77b8 100644 --- a/.github/workflows/fresh-install.yml +++ b/.github/workflows/fresh-install.yml @@ -58,7 +58,7 @@ jobs: - name: Upload screenshots if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: fresh-install-shots path: parenthese/shots diff --git a/.gitignore b/.gitignore index 48eae82..e1865a4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,8 @@ backend/storage/media/ # Clés SSH / certificats *.pem id_* + +# Outils et notes de travail locaux (méthode BMAD, mémoire d'agent) +_bmad/ +_bmad-output/ +memory/ diff --git a/CONTRIBUTING.fr.md b/CONTRIBUTING.fr.md index fccfc6e..1d22b2c 100644 --- a/CONTRIBUTING.fr.md +++ b/CONTRIBUTING.fr.md @@ -35,6 +35,13 @@ Si vous touchez à Docker ou à nginx, lancez `docker compose up -d --build` pui - Pas de nouvelle dépendance lourde (framework, bibliothèque d'interface, service externe) sans en avoir discuté dans une issue. - La promesse du README tient : pas de publicité, et aucune mesure d'audience activée par défaut sur une instance auto-hébergée. -## Licence +## Licence des contributions -Parenthèse est publié sous [PolyForm Noncommercial 1.0.0](LICENSE). En proposant une contribution, vous acceptez qu'elle soit publiée sous cette même licence. +Parenthèse est publié sous [PolyForm Noncommercial 1.0.0](LICENSE) et le reste. + +En ouvrant une pull request, vous + +- certifiez avoir écrit la contribution ou avoir le droit de la proposer, dans l'esprit du [Developer Certificate of Origin](https://developercertificate.org/). Signez vos commits avec `git commit -s`, qui ajoute une ligne `Signed-off-by` ; +- accordez au mainteneur, Ilies Allali, une licence perpétuelle, irrévocable, mondiale et gratuite pour utiliser, modifier et proposer votre contribution sous d'autres licences, y compris commerciales. + +Si vous ne pouvez pas l'accepter, dites-le dans la pull request avant sa fusion. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f5d3b2..178d919 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,13 @@ If you touch Docker or nginx, run `docker compose up -d --build` then `bash tool - No new heavy dependency (framework, UI library, external service) without discussing it in an issue. - The README promise holds: no ads, and no analytics turned on by default on a self-hosted instance. -## License +## License of contributions -Parenthèse is published under [PolyForm Noncommercial 1.0.0](LICENSE). By submitting a contribution, you agree that it is published under the same license. +Parenthèse is published under [PolyForm Noncommercial 1.0.0](LICENSE), and stays under it. + +By opening a pull request, you: + +- certify that you wrote the contribution or otherwise have the right to submit it, in the spirit of the [Developer Certificate of Origin](https://developercertificate.org/). Sign your commits with `git commit -s`, which adds a `Signed-off-by:` line; +- grant the maintainer, Ilies Allali, a perpetual, irrevocable, worldwide, royalty-free license to use, modify and relicense your contribution under any terms, including commercial ones. + +If you can't agree to this, say so in the pull request before it is merged. diff --git a/Dockerfile.web b/Dockerfile.web index 8b1aa98..64c0dde 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -17,7 +17,7 @@ ENV VITE_API_BASE_URL="" RUN npm run build -FROM nginx:1.27-alpine +FROM nginx:1.30-alpine COPY deploy/nginx.docker.conf /etc/nginx/conf.d/default.conf COPY --from=build /app/dist /usr/share/nginx/html diff --git a/SECURITY.fr.md b/SECURITY.fr.md index a84559e..574f895 100644 --- a/SECURITY.fr.md +++ b/SECURITY.fr.md @@ -6,10 +6,7 @@ Ne décrivez jamais une faille dans une issue, une discussion ou une pull request : tout y est public. -Deux canaux privés : - -- l'onglet **Security** de ce dépôt GitHub, bouton « Report a vulnerability » ; -- un email à pro.allali.ilies@gmail.com. +Passez de préférence par le signalement privé de GitHub, dans l'onglet **Security** de ce dépôt, bouton « Report a vulnerability ». Si vous ne pouvez pas l'utiliser, écrivez à pro.allali.ilies@gmail.com. Indiquez si possible ce qui est touché (adresse, route de l'API, fichier), les étapes pour reproduire et l'impact que vous constatez. Un rapport court et précis suffit. diff --git a/SECURITY.md b/SECURITY.md index f0194a2..827ccc8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,10 +6,7 @@ Never describe a vulnerability in an issue, a discussion or a pull request: everything there is public. -Two private channels: - -- the **Security** tab of this GitHub repository, "Report a vulnerability" button; -- an email to pro.allali.ilies@gmail.com. +Report it preferably through GitHub's private vulnerability reporting: the **Security** tab of this repository, "Report a vulnerability" button. If you can't use it, send an email to pro.allali.ilies@gmail.com. If you can, say what is affected (address, API route, file), the steps to reproduce and the impact you observe. A short, precise report is enough. English or French, as you prefer. diff --git a/deploy/HOSTINGER_GITHUB_SETUP.md b/deploy/HOSTINGER_GITHUB_SETUP.md index 9b28b2f..3b30d50 100644 --- a/deploy/HOSTINGER_GITHUB_SETUP.md +++ b/deploy/HOSTINGER_GITHUB_SETUP.md @@ -12,6 +12,7 @@ Chaque push sur `main` lance d'abord la CI (`.github/workflows/ci.yml`). Si elle - `HOSTINGER_PORT` : port SSH (facultatif, 22 par défaut) - `HOSTINGER_USER` : utilisateur SSH - `HOSTINGER_SSH_PRIVATE_KEY` : clé privée dont la clé publique est dans `authorized_keys` sur le serveur +- `SSH_KNOWN_HOSTS` : clé d'hôte du serveur, épinglée. La produire depuis une machine de confiance avec `ssh-keyscan -p PORT HÔTE`, comparer l'empreinte (`ssh-keygen -lf`) à celle affichée sur le serveur par `ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub`, puis coller les lignes telles quelles. Sans ce secret, le workflow reprend la clé présentée au moment du run et affiche un avertissement. ## Ce qui vit sur le serveur, hors du dépôt diff --git a/deploy/backup.sh b/deploy/backup.sh index 94e2cc8..2685f85 100644 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -5,7 +5,15 @@ # La base est sauvegardée à chaque passage. L'archive des médias n'est refaite que si les médias ont # changé (sinon une archive identique par jour), et seules les # BACKUP_MEDIA_KEEP dernières archives sont gardées. +# +# Envoi hors du serveur (facultatif, BACKUP_S3_BUCKET) : si BACKUP_AGE_RECIPIENT est renseigné (une ou +# plusieurs clés publiques séparées par des espaces, « age1... » ou « ssh-ed25519 AAAA... » sans commentaire), +# le dump et l'archive sont chiffrés avec age avant l'envoi et seules les copies chiffrées (.age) partent. +# Les fichiers gardés sur le serveur restent en clair, lisibles par root seulement. +# Restauration d'une copie chiffrée : age -d -i cle-privee.txt fichier.dump.age > fichier.dump set -euo pipefail +# Dumps, archives et empreintes lisibles par le seul propriétaire +umask 077 REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" BACKEND_ENV_FILE="$REPO_DIR/backend/.env" @@ -115,9 +123,43 @@ if [ -n "${BACKUP_S3_BUCKET:-}" ]; then S3_ARGS+=(--region "$BACKUP_S3_REGION") fi - aws s3 cp "$DB_BACKUP_FILE" "$S3_BASE_URI/db/$(basename "$DB_BACKUP_FILE")" "${S3_ARGS[@]}" + # Copies à envoyer : chiffrées si une clé age est configurée, sinon les fichiers tels quels (comportement historique) + UPLOAD_DB_FILE="$DB_BACKUP_FILE" + UPLOAD_MEDIA_FILE="$MEDIA_BACKUP_FILE" + if [ -n "${BACKUP_AGE_RECIPIENT:-}" ]; then + if ! command -v age >/dev/null 2>&1; then + echo "ERROR: BACKUP_AGE_RECIPIENT is set but age is not installed (apt install age)." + exit 1 + fi + AGE_ARGS=() + read -r -a AGE_KEYS <<< "$BACKUP_AGE_RECIPIENT" + # Une clé ssh s'écrit en deux mots (« ssh-ed25519 AAAA... ») : on recolle type et clé + i=0 + while [ "$i" -lt "${#AGE_KEYS[@]}" ]; do + key="${AGE_KEYS[$i]}" + if [[ "$key" == ssh-* ]]; then + i=$((i + 1)) + key="$key ${AGE_KEYS[$i]:-}" + fi + AGE_ARGS+=(-r "$key") + i=$((i + 1)) + done + ENCRYPT_DIR="$(mktemp -d "$BACKUP_ROOT/.offsite-XXXXXX")" + trap 'rm -rf "$ENCRYPT_DIR"' EXIT + UPLOAD_DB_FILE="$ENCRYPT_DIR/$(basename "$DB_BACKUP_FILE").age" + age "${AGE_ARGS[@]}" -o "$UPLOAD_DB_FILE" "$DB_BACKUP_FILE" + if [ "$MEDIA_IS_NEW" = "1" ]; then + UPLOAD_MEDIA_FILE="$ENCRYPT_DIR/$(basename "$MEDIA_BACKUP_FILE").age" + age "${AGE_ARGS[@]}" -o "$UPLOAD_MEDIA_FILE" "$MEDIA_BACKUP_FILE" + fi + echo "Copies chiffrées avec age avant envoi." + else + echo "WARNING: BACKUP_AGE_RECIPIENT absent, les sauvegardes partent en clair." + fi + + aws s3 cp "$UPLOAD_DB_FILE" "$S3_BASE_URI/db/$(basename "$UPLOAD_DB_FILE")" "${S3_ARGS[@]}" if [ "$MEDIA_IS_NEW" = "1" ]; then - aws s3 cp "$MEDIA_BACKUP_FILE" "$S3_BASE_URI/media/$(basename "$MEDIA_BACKUP_FILE")" "${S3_ARGS[@]}" + aws s3 cp "$UPLOAD_MEDIA_FILE" "$S3_BASE_URI/media/$(basename "$UPLOAD_MEDIA_FILE")" "${S3_ARGS[@]}" fi aws s3 cp "$CHECKSUM_FILE" "$S3_BASE_URI/meta/$(basename "$CHECKSUM_FILE")" "${S3_ARGS[@]}" else diff --git a/deploy/deploy.sh b/deploy/deploy.sh index eee5977..88cea52 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -92,6 +92,43 @@ fix_perms() { find "$REPO_DIR/$1" -type f -exec chmod 644 {} + } +# Sauvegarde de la base avant migration, à côté de celles de deploy/backup.sh ($BACKUP_ROOT/pre-migration). +# « prisma migrate status » sort en 0 quand la base est à jour : pas de dump. Tout autre code (migration en +# attente, historique divergent, base injoignable) déclenche le dump, et un dump raté arrête le déploiement. +# Les PREMIGRATION_KEEP derniers dumps sont gardés (10 par défaut). +predeploy_dump() { + if (cd "$REPO_DIR/backend" && npx --no-install prisma migrate status >/dev/null 2>&1); then + log "Aucune migration en attente, pas de sauvegarde avant migration" + return 0 + fi + log "Migrations en attente : sauvegarde de la base avant de les appliquer" + ( + set -euo pipefail + umask 077 + # Sous-shell : DATABASE_URL et les réglages de sauvegarde ne fuient pas dans les builds qui suivent + set -a + # shellcheck disable=SC1090,SC1091 + . "$REPO_DIR/backend/.env" + if [ -f "${BACKUP_ENV_FILE:-/etc/genealogy/backup.env}" ]; then + # shellcheck disable=SC1090 + . "${BACKUP_ENV_FILE:-/etc/genealogy/backup.env}" + fi + set +a + command -v pg_dump >/dev/null 2>&1 || { echo "ERROR: pg_dump absent, déploiement arrêté avant migration"; exit 1; } + [ -n "${DATABASE_URL:-}" ] || { echo "ERROR: DATABASE_URL absent de backend/.env"; exit 1; } + dir="${BACKUP_ROOT:-/var/backups/genealogy}/pre-migration" + mkdir -p "$dir" + file="$dir/genealogy-premigration-$(date -u +%Y%m%dT%H%M%SZ)-${NEW_SHA:0:12}.dump" + if ! pg_dump "$DATABASE_URL" --format=custom --file "$file"; then + rm -f "$file" + echo "ERROR: pg_dump a échoué, déploiement arrêté avant migration" + exit 1 + fi + echo "[deploy] Base sauvegardée : $file" + ls -1t "$dir"/genealogy-premigration-*.dump 2>/dev/null | tail -n +"$((${PREMIGRATION_KEEP:-10} + 1))" | xargs -r rm -f + ) +} + # 4. Backend BACKEND_CHANGED=0 if changed backend ecosystem.config.cjs; then @@ -101,6 +138,7 @@ if changed backend ecosystem.config.cjs; then || { echo "ERROR: dépendances backend manquantes après installation"; exit 1; } log "Build backend" (cd "$REPO_DIR/backend" && NODE_ENV='' npm run prisma:generate && NODE_ENV='' npm run build) + predeploy_dump log "Migrations" (cd "$REPO_DIR/backend" && npm run prisma:deploy) else @@ -117,7 +155,8 @@ else log "Frontend inchangé" fi -# 6. Landing (elle utilise les polices du frontend) +# 6. Landing (autonome, plus aucune police lue dans le frontend ; frontend/src/assets reste dans le +# déclencheur par prudence, sans effet tant que rien n'y change) if changed landing frontend/src/assets; then install_deps landing log "Build landing" diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 0af8c3b..b9880e1 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -1,3 +1,42 @@ +# ─── En-têtes de sécurité ───────────────────────────────────────────────────── +# +# nginx n'hérite des add_header du niveau server que dans les location qui n'en déclarent aucun. +# Toute location qui pose son propre add_header (Cache-Control par exemple) répète donc la liste +# complète. Les politiques longues sont rangées dans des variables (map) pour ne pas les recopier. +# +# CSP de l'application (app.parenthese.io), sources autorisées : +# script-src, connect-src https://*.posthog.com, analytics (VITE_POSTHOG_HOST = us.i.posthog.com, +# extensions chargées depuis us-assets.i.posthog.com ; forme recommandée par PostHog) +# connect-src 'self' appels /api, /auth, /trees +# style-src 'unsafe-inline' styles posés en ligne par React et par posthog-js +# img-src data: blob: aperçu d'une photo avant envoi (URL.createObjectURL), images en data: +# font-src 'self' data: polices servies par le site (Vite peut en inliner une petite en data:) +# media-src blob: lecture locale d'un fichier avant envoi +# worker-src blob: compression des enregistrements posthog-js +# frame-src 'self' pour les PDF servis par /api, YouTube sans cookie, carte OpenStreetMap +# frame-ancestors la landing parenthese.io intègre l'app en iframe +map $host $parenthese_app_csp { + default "default-src 'self'; script-src 'self' https://*.posthog.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://*.posthog.com; media-src 'self' blob:; worker-src 'self' blob:; frame-src 'self' https://www.youtube-nocookie.com https://www.openstreetmap.org; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self' https://parenthese.io https://www.parenthese.io"; +} + +# CSP de la landing (parenthese.io), sources autorisées : +# script-src 'sha256-...' script en ligne du (animation du logo, dans index.html et en/index.html). +# S'il change, recalculer l'empreinte : base64(sha256(contenu exact entre +# )), sinon l'animation ne se joue plus (sans autre effet). +# posthog comme pour l'app +# style-src 'unsafe-inline' attributs style du rendu serveur React +# frame-src démonstration de l'app en iframe +# frame-ancestors 'none' équivalent de X-Frame-Options DENY +map $host $parenthese_landing_csp { + default "default-src 'self'; script-src 'self' 'sha256-Av5a1/yoW/tQ2ZQWqH5FnaL896Bubcs9ivdFvsKZBrU=' https://*.posthog.com; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://*.posthog.com; worker-src 'self' blob:; frame-src https://app.parenthese.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"; +} + +# Réponses de l'API relayées (JSON, médias des familles) : intégrables seulement par l'app (PDF en iframe). +map $host $parenthese_api_csp { + default "frame-ancestors 'self' https://parenthese.io https://www.parenthese.io"; +} + + # ─── app.parenthese.io ──────────────────────────────────────────────────────── server { @@ -7,18 +46,22 @@ server { } server { + # « listen ... http2 » gardé : la directive « http2 on; » demande nginx 1.25.1 ou plus, et la version + # installée sur le VPS n'est pas écrite dans le dépôt (Ubuntu 24.04 livre la 1.24). À passer en + # « listen 443 ssl; » + « http2 on; » une fois « nginx -v » vérifié. listen 443 ssl http2; server_name app.parenthese.io; ssl_certificate /etc/letsencrypt/live/app.parenthese.io/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/app.parenthese.io/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; - # Security headers - # frame-ancestors allows the landing page (parenthese.io) to embed the app in an iframe - add_header Content-Security-Policy "frame-ancestors 'self' https://parenthese.io https://www.parenthese.io" always; + # Hérités par les location sans add_header, c'est-à-dire les routes relayées vers l'API + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy $parenthese_api_csp always; add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; gzip on; gzip_vary on; @@ -70,20 +113,21 @@ server { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, no-store, must-revalidate" always; add_header Pragma "no-cache" always; - add_header Content-Security-Policy "frame-ancestors 'self' https://parenthese.io https://www.parenthese.io" always; - # Keep embedding control in CSP; override any inherited SAMEORIGIN frame header. - add_header X-Frame-Options "ALLOWALL" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy $parenthese_app_csp always; add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 30d; add_header Cache-Control "public, immutable"; - add_header Content-Security-Policy "frame-ancestors 'self' https://parenthese.io https://www.parenthese.io" always; - add_header X-Frame-Options "ALLOWALL" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy $parenthese_app_csp always; add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; } } @@ -101,6 +145,8 @@ server { server_name www.parenthese.io; ssl_certificate /etc/letsencrypt/live/parenthese.io/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/parenthese.io/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; return 301 https://parenthese.io$request_uri; } @@ -110,10 +156,13 @@ server { ssl_certificate /etc/letsencrypt/live/parenthese.io/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/parenthese.io/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy $parenthese_landing_csp always; add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; add_header X-Frame-Options "DENY" always; gzip on; @@ -129,7 +178,11 @@ server { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, no-store, must-revalidate" always; add_header Pragma "no-cache" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy $parenthese_landing_csp always; add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; add_header X-Frame-Options "DENY" always; } @@ -137,5 +190,11 @@ server { location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 30d; add_header Cache-Control "public, immutable"; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy $parenthese_landing_csp always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; + add_header X-Frame-Options "DENY" always; } } diff --git a/deploy/nginx.docker.conf b/deploy/nginx.docker.conf index ae6eea2..1d42a65 100644 --- a/deploy/nginx.docker.conf +++ b/deploy/nginx.docker.conf @@ -1,6 +1,16 @@ # nginx du service `web` (docker compose). Dérivé de deploy/nginx.conf (prod app.parenthese.io) : # même origine, /api/* réécrit en /* vers l'API, /auth, /trees et /health relayés tels quels, -# fallback SPA. Pas de TLS ici : placez un reverse proxy HTTPS devant si vous exposez sur Internet. +# fallback SPA. Pas de TLS ici : placez un reverse proxy HTTPS devant si vous exposez sur Internet +# (c'est lui qui posera Strict-Transport-Security). +# +# nginx n'hérite des add_header du niveau server que dans les location qui n'en déclarent aucun : +# les location qui posent leur propre add_header répètent la liste complète. +# +# CSP de l'application : même politique que la production (deploy/nginx.conf, sources expliquées là-bas), +# sans PostHog (l'image ne reçoit pas de clé d'analytics) et sans intégration par un autre site. +map $host $parenthese_app_csp { + default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; media-src 'self' blob:; worker-src 'self' blob:; frame-src 'self' https://www.youtube-nocookie.com https://www.openstreetmap.org; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"; +} server { listen 80; @@ -14,9 +24,12 @@ server { gzip_min_length 1024; gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml; + # Hérités par les location sans add_header, c'est-à-dire les routes relayées vers l'API + add_header Content-Security-Policy "frame-ancestors 'self'" always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; location /api/ { rewrite ^/api(/.*)$ $1 break; @@ -60,15 +73,20 @@ server { try_files $uri $uri/ /index.html; add_header Cache-Control "no-cache, no-store, must-revalidate" always; add_header Pragma "no-cache" always; + add_header Content-Security-Policy $parenthese_app_csp always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 30d; add_header Cache-Control "public, immutable"; + add_header Content-Security-Policy $parenthese_app_csp always; add_header X-Content-Type-Options "nosniff" always; add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; } } diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index 2ef9433..7345673 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -10,6 +10,8 @@ module.exports = { env_production: { NODE_ENV: 'production', PORT: 4000, + // nginx relaie vers 127.0.0.1:4000 (deploy/nginx.conf) : l'API n'écoute pas sur les interfaces publiques + HOST: '127.0.0.1', }, max_memory_restart: '512M', log_date_format: 'YYYY-MM-DD HH:mm:ss Z',