Tofu/docs/webdav-nextcloud.md
2026-09-18 01:41:02 +02:00

22 KiB

Accéder au WebDAV d'un Nextcloud depuis une page statique

Tofu est servi en statique et parle au Nextcloud directement depuis le navigateur (app/services/webdav/). Aucun backend, aucun proxy applicatif : c'est une contrainte assumée du projet, pas un oubli. Le seul obstacle réel est le CORS.

Ce document dit ce qui bloque exactement, les trois façons de le débloquer, et laquelle choisir. Les vérifications ont été faites le 2026-09-17 ; les commandes pour les refaire sont au §7.


1. Ce que fait le navigateur, et ce que Nextcloud répond

1.1 Toutes les requêtes de Tofu déclenchent un préflight

Une requête cross-origin échappe au préflight uniquement si elle cumule une méthode sûre (GET, HEAD, POST) et seulement des en-têtes de la liste blanche CORS (Accept, Accept-Language, Content-Language, Content-Type limité à trois types, Range). Tofu ne coche aucune de ces cases :

Appel du client WebDAV Méthode En-têtes non safelistés Préflight
listFolder PROPFIND Authorization, Depth, Content-Type: application/xml oui
readJson GET Authorization oui
writeJson PUT Authorization, Content-Type: application/json oui
ensureFolder MKCOL Authorization oui
remove DELETE Authorization oui

Authorization suffit à lui seul : il n'est pas safelisté. Autrement dit chaque requête de Tofu est précédée d'un OPTIONS vers la même URL, portant Origin, Access-Control-Request-Method et Access-Control-Request-Headers.

1.2 Nextcloud répond 401 à ce préflight, sans aucun en-tête CORS

Mesuré sur deux instances Nextcloud publiques et à jour, indépendantes l'une de l'autre :

curl -sS -D - -o /dev/null -X OPTIONS https://cloud.disroot.org/remote.php/dav/ \
  -H 'Origin: https://example.org' \
  -H 'Access-Control-Request-Method: PROPFIND' \
  -H 'Access-Control-Request-Headers: authorization,depth,content-type'
HTTP/1.1 401 Unauthorized
Content-Type: application/xml; charset=utf-8
WWW-Authenticate: Basic realm="Disroot Cloud", charset="UTF-8"
Content-Security-Policy: default-src 'none';
X-Frame-Options: SAMEORIGIN
(aucun en-tête Access-Control-*)

Résultat identique sur https://framagenda.org. Deux choses clochent, et une seule suffit à tout faire échouer :

  • la réponse ne porte aucun Access-Control-* → le navigateur refuse le préflight ;
  • elle est en 401 → même avec les bons en-têtes, un statut hors 2xx fait échouer le préflight, donc la vraie requête n'est jamais envoyée.

Le 401 vient de Sabre/DAV : le plugin d'authentification tourne sur beforeMethod sans exempter OPTIONS, alors que le préflight part par construction sans credentials. Toute solution doit donc traiter deux problèmes distincts : poser les en-têtes, et répondre au préflight avant l'authentification.

Côté navigateur, l'échec est indiscernable d'une panne réseau : fetch rejette avec un TypeError nu, sans statut. C'est exactement pour cela que describeWebdavFailure renvoie kind: 'cors' et pointe vers ce document plutôt que de parler de « réseau ».

1.3 Pourquoi il n'y a rien par défaut

/remote.php/dav/ n'est pas servi par le framework applicatif de Nextcloud : c'est un serveur Sabre/DAV. Le CORSMiddleware de Nextcloud, celui qui gère l'annotation #[CORS] des contrôleurs OCS, ne le traverse jamais. Il n'existe aucune clé de config.php qui pose des en-têtes CORS sur le DAV.

Le support natif est demandé depuis janvier 2017 (nextcloud/server#3131). La demande est toujours ouverte à ce jour ; elle est assignée et jalonnée « Nextcloud 36 ». Ce n'est donc pas disponible aujourd'hui, et un jalon peut glisser : ne rien construire dessus.


2. Le détail qui change tout : nos requêtes ne sont pas « créditées »

webdavRequest fait credentials: 'omit' et pose l'en-tête Authorization lui-même. Au sens de la spécification Fetch, une requête est créditée quand le navigateur y joint des cookies, un certificat client TLS ou une authentification HTTP — c'est-à-dire quand le mode credentials vaut include. Un Authorization que l'on écrit soi-même est un en-tête de requête ordinaire : il ne crédite pas la requête.

Conséquences, vérifiées :

  1. Access-Control-Allow-Origin: * suffit. La règle « le serveur doit nommer une origine explicite » ne s'applique qu'aux requêtes créditées. Le serveur n'a donc pas besoin de connaître l'URL où Tofu est servi, et rien n'est à reconfigurer si elle change.
  2. Access-Control-Allow-Credentials est inutile — et nuisible. Il ne sert à rien ici, et s'il est posé il interdit le *. Ne pas le poser.
  3. Piège : Access-Control-Allow-Headers: * ne couvre jamais Authorization. MDN, textuellement : « The Authorization header doesn't accept wildcard and always needs to be listed explicitly. » C'est l'erreur la plus fréquente : la configuration a l'air correcte, le préflight passe pour Depth et Content-Type, et échoue sur Authorization.
  4. Sans Access-Control-Max-Age, un préflight n'est valable que 5 secondes, soit un OPTIONS avant presque chaque requête. Poser 7200 : c'est le plafond de Chromium ; Firefox plafonne à 86400 et rabote simplement toute valeur plus haute.
  5. Pour lire un en-tête de réponse depuis JS (ETag, DAV), il faut le déclarer dans Access-Control-Expose-Headers : seuls Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified et Pragma sont visibles par défaut.

3. Option A — l'application Nextcloud WebAppPassword

digital-blueprint/webapppassword, catégorie Sécurité de l'App Store. C'est l'application dédiée exactement à ce cas : permettre à une page web tierce de parler au WebDAV/CalDAV.

Est-elle maintenue ? Oui, franchement oui. Dernière version 26.8.0, publiée il y a moins d'un mois, compatible Nextcloud 25 à 35. Ce n'est pas un projet abandonné.

Ce qu'elle fait réellement (lu dans lib/Connector/Sabre/CorsPlugin.php) : elle enregistre un plugin Sabre sur le serveur DAV via l'événement SabrePluginEvent, et, si l'Origin de la requête figure dans la liste autorisée :

  • pose access-control-allow-origin avec l'origine de la requête (pas *) ;
  • renvoie en écho access-control-allow-methods et access-control-allow-headers depuis les en-têtes access-control-request-* du préflight — donc PROPFIND, MKCOL, Authorization, Depth passent sans rien énumérer ;
  • pose access-control-expose-headers: etag, dav ;
  • pose access-control-allow-credentials: true ;
  • répond 204 à un OPTIONS sans en-tête Authorization, avant l'authentification — c'est ce qui règle le 401 du §1.2. La présence même de ce court-circuit dans le code de l'application confirme le diagnostic.

Activation :

sudo -u www-data php occ app:install webapppassword
sudo -u www-data php occ app:enable  webapppassword

Déclaration de l'origine — soit dans la page de réglages de l'application (section Administration → Sécurité), soit dans config/config.php :

'webapppassword.origins' => ['https://tofu.exemple.fr'],

L'origine inclut le schéma, l'hôte et le port éventuel. Un joker de sous-domaine sur un seul niveau est accepté (https://*.exemple.fr). Deux autres clés existent, webapppassword.files_sharing_origins et webapppassword.preview_origins, pour des API que Tofu n'utilise pas : les laisser vides.

Limites, dites franchement :

  • Il faut déclarer l'origine exacte de Tofu, et la reconfigurer si elle change. En développement local, http://localhost:8000 est une origine de plus à déclarer.
  • L'application pose allow-credentials: true et échoue l'origine : c'est strictement plus que ce dont Tofu a besoin (§2), sans bénéfice pour nous.
  • Elle ne pose pas de Access-Control-Max-Age : on garde un préflight de 5 secondes, donc un OPTIONS avant quasiment chaque requête. Fonctionnellement correct, deux fois plus de requêtes.
  • C'est une application tierce dans le chemin d'authentification. À revérifier à chaque montée de version majeure de Nextcloud.
  • Elle propose aussi un mécanisme de mot de passe d'application temporaire via une fenêtre de connexion. Tofu ne l'utilise pas : il demande un mot de passe d'application classique dans son propre formulaire. Seule la partie CORS nous intéresse.

4. Option B — poser les en-têtes dans le serveur web

À faire si l'on tient la configuration nginx/Apache du Nextcloud et qu'on préfère ne pas installer d'application tierce. Comme nos requêtes ne sont pas créditées (§2), * suffit : le serveur n'a pas besoin de connaître l'URL de Tofu.

4.1 nginx

Dans le bloc http { } (nginx ne sait pas faire un if à deux conditions, on les combine dans des map) :

# Origine autorisée, uniquement sur le chemin DAV. Une valeur vide fait que nginx
# n'ajoute pas l'en-tête du tout : le reste du vhost n'est pas touché.
map $request_uri $tofu_cors_origin {
    default              "";
    "~^/remote\.php/dav" "*";
}

map $tofu_cors_origin $tofu_cors_expose {
    default "";
    "*"     "Content-Length, Content-Type, DAV, ETag, Last-Modified, OC-ETag, OC-FileId";
}

map "$request_method:$request_uri" $tofu_dav_preflight {
    default                      0;
    "~^OPTIONS:/remote\.php/dav" 1;
}

Dans le bloc server { } du Nextcloud :

    # « always » est obligatoire : sans lui, nginx ne pose l'en-tête que sur les
    # réponses 2xx/3xx, et le 401 comme le 207 partent nus.
    add_header Access-Control-Allow-Origin   $tofu_cors_origin always;
    add_header Access-Control-Expose-Headers $tofu_cors_expose always;

Puis, au début du bloc PHP existant (location ~ \.php(?:$|/)), sans rien y changer d'autre :

    location ~ \.php(?:$|/) {

        # Répondre au préflight ici, donc avant PHP, donc avant l'authentification.
        # Sinon Sabre renvoie 401 et le navigateur s'arrête là.
        if ($tofu_dav_preflight) {
            add_header Access-Control-Allow-Origin  "*" always;
            add_header Access-Control-Allow-Methods "GET, HEAD, PUT, DELETE, OPTIONS, PROPFIND, PROPPATCH, MKCOL, MOVE, COPY, LOCK, UNLOCK, REPORT" always;
            add_header Access-Control-Allow-Headers "Authorization, Content-Type, Depth, Destination, Overwrite, If, If-Match, If-None-Match, Lock-Token, Timeout, X-Requested-With, OCS-APIRequest" always;
            add_header Access-Control-Max-Age       "7200" always;
            add_header Content-Length               0;
            return 204;
        }

        # ... bloc fastcgi d'origine, inchangé ...
    }

Trois pièges nginx, dans l'ordre où ils mordent :

  1. always. Sans lui, aucun en-tête sur un 401 ou un 207 (la réponse normale d'un PROPFIND) — et le navigateur bloque la lecture.
  2. add_header n'est pas cumulatif entre niveaux. Dès qu'un niveau imbriqué déclare un add_header, il perd tous ceux du niveau parent. C'est pour cela que Access-Control-Allow-Origin est réécrit dans le bloc if. Corollaire : la réponse de préflight perd les en-têtes de sécurité du vhost — sans conséquence, elle n'a pas de corps, mais autant le savoir avant de s'en étonner.
  3. Ne pas ajouter d'add_header dans le bloc PHP hors du if sans y recopier les en-têtes de sécurité du vhost, pour la même raison.

4.2 Apache

Dans le VirtualHost (pas dans le .htaccess de Nextcloud : les règles du vhost passent avant, et le .htaccess est réécrit aux montées de version).

    <IfModule mod_headers.c>
        <LocationMatch "^/remote\.php/dav">
            Header always set Access-Control-Allow-Origin   "*"
            Header always set Access-Control-Allow-Methods  "GET, HEAD, PUT, DELETE, OPTIONS, PROPFIND, PROPPATCH, MKCOL, MOVE, COPY, LOCK, UNLOCK, REPORT"
            Header always set Access-Control-Allow-Headers  "Authorization, Content-Type, Depth, Destination, Overwrite, If, If-Match, If-None-Match, Lock-Token, Timeout, X-Requested-With, OCS-APIRequest"
            Header always set Access-Control-Expose-Headers "Content-Length, Content-Type, DAV, ETag, Last-Modified, OC-ETag, OC-FileId"
            Header always set Access-Control-Max-Age        "7200"
        </LocationMatch>
    </IfModule>

    # Court-circuiter le préflight avant que PHP (donc Nextcloud, donc Sabre) ne le voie.
    <IfModule mod_rewrite.c>
        RewriteEngine On
        RewriteCond %{REQUEST_METHOD} =OPTIONS
        RewriteCond %{REQUEST_URI}    ^/remote\.php/dav
        RewriteRule ^ - [R=200,L]
    </IfModule>

Header always set (et non Header set) pour la même raison que always sous nginx : sans lui, rien sur les réponses d'erreur. Le [R=200,L] renvoie un 200 au corps vide, ce qui est une réponse de préflight valide ; les en-têtes du LocationMatch s'y appliquent puisque l'URI correspond toujours.

4.3 Ce que cette option coûte

Une configuration écrite à la main, à relire à chaque changement de la configuration Nextcloud recommandée — laquelle est régulièrement republiée et souvent recopiée telle quelle lors d'une montée de version, ce qui efface l'ajout sans prévenir.


5. Option C — supprimer le problème : la même origine

S'il n'y a pas de cross-origin, il n'y a pas de CORS. Pas d'en-tête à poser, pas de préflight, pas d'application tierce, rien à réparer après une montée de version. C'est l'option la plus robuste à long terme, et de loin.

Concrètement : servir les fichiers statiques de Tofu par le même serveur web, sur le même hôte, à un chemin voisin. https://cloud.exemple.fr/tofu/ parle à https://cloud.exemple.fr/remote.php/dav/… : même schéma, même hôte, même port, donc même origine.

Attention, un sous-domaine ne suffit pas. https://tofu.exemple.fr et https://cloud.exemple.fr sont deux origines distinctes et tout le CORS s'applique. Seul un chemin sous le même hôte fonctionne.

nginx, dans le bloc server { } du Nextcloud :

    # « ^~ » arrête l'évaluation des expressions régulières : les .js et .css de Tofu
    # sont servis tels quels et ne repartent pas dans index.php.
    location ^~ /tofu/ {
        alias /var/www/tofu/;
        index index.html;
        try_files $uri $uri/ =404;
    }

Apache, dans le VirtualHost :

    Alias /tofu /var/www/tofu
    <Directory /var/www/tofu>
        Require all granted
        Options -Indexes
        AllowOverride None
        DirectoryIndex index.html
    </Directory>

Le déploiement se réduit alors à copier le dépôt (index.html, app/, libs/, styles/) dans /var/www/tofu. Aucune étape de build, c'est tout l'intérêt.

Deux précautions :

  • Ne pas déposer les fichiers dans le dossier d'installation de Nextcloud. Le vérificateur d'intégrité de Nextcloud signale les fichiers étrangers à sa racine, et une montée de version peut les effacer. D'où l'alias / Alias vers un répertoire situé ailleurs.
  • credentials: 'omit' reste le bon choix même en même origine : le cookie de session Nextcloud n'est pas envoyé, le mot de passe d'application reste le seul justificatif, et on ne touche jamais au CSRF de Nextcloud.

Ce que cette option ne règle pas : le développement local. python3 -m http.server 8000 sert Tofu sur http://localhost:8000, qui est une autre origine que le Nextcloud. Pour développer contre un vrai serveur, il faut soit activer en plus l'option A ou B pour l'origine de développement, soit se contenter de « Continuer sans Nextcloud » (§6, point 4).


6. Recommandation, classée et assumée

  1. Option C — même origine. À retenir dès qu'on a accès à la configuration du serveur web qui sert le Nextcloud. Le CORS n'entre pas en jeu, donc rien ne peut le casser : ni une montée de version, ni une application tierce abandonnée, ni une configuration recopiée. Prix : Tofu devient une partie du déploiement du Nextcloud, et le développement en local demande l'une des deux autres options.

  2. Option A — WebAppPassword. Le bon choix quand on est administrateur Nextcloud mais pas administrateur du serveur web (hébergement mutualisé, Nextcloud géré par un tiers). Deux commandes occ et une ligne de configuration, pour un résultat correct. Prix : une application tierce dans le chemin d'authentification, l'origine exacte à déclarer et à maintenir, et un préflight non mis en cache.

  3. Option B — reverse proxy. Quand on tient la configuration web mais qu'on refuse une application tierce. C'est aussi l'option la plus fine : * sans credentials, cache de préflight à 7200 s, exactement ce dont Tofu a besoin et rien de plus. Prix : une configuration à la main, deux pièges faciles (always, court-circuit du préflight), et un ajout qu'une mise à jour de configuration peut effacer silencieusement.

  4. Aucune des trois — et c'est prévu. Si l'on n'est administrateur de rien, aucune de ces options n'est accessible : il n'y a pas de solution côté client, point. Tofu le gère par conception : l'écran de connexion propose « Continuer sans Nextcloud », le planning tourne alors sur createMemoryPlanningStore() et affiche « Hors ligne — rien n'est enregistré ». La feuille reste saisissable et imprimable. Ce n'est pas un échec silencieux, c'est un mode annoncé.

Le support natif de Nextcloud (issue #3131) n'est pas une option. Toujours ouverte après neuf ans, jalonnée Nextcloud 36. À surveiller, pas à attendre.

Ce qui n'est pas proposé, et ne le sera pas

Un proxy applicatif ou un petit backend maison est exclu du projet. Ce n'est pas une préférence esthétique : ce serait un service de plus à déployer, à maintenir, à mettre à jour et à surveiller, qui verrait passer le mot de passe en clair, et qui annulerait la seule propriété qui fait tenir Tofu dans le temps — un dossier de fichiers que n'importe quel serveur web sait servir. Si aucune des options ci-dessus n'est accessible, la réponse est le mode hors ligne du point 4, pas un backend.


7. Vérifier en trois commandes

Remplacer cloud.exemple.fr, tofu.exemple.fr et UTILISATEUR. Le mot de passe est un mot de passe d'application (Nextcloud → Paramètres personnels → Sécurité → Créer un mot de passe d'application), jamais le mot de passe du compte.

# 1. Le préflight passe-t-il ?
curl -sS -D - -o /dev/null -X OPTIONS https://cloud.exemple.fr/remote.php/dav/ \
  -H 'Origin: https://tofu.exemple.fr' \
  -H 'Access-Control-Request-Method: PROPFIND' \
  -H 'Access-Control-Request-Headers: authorization,depth,content-type'

Attendu : un statut 2xx, un Access-Control-Allow-Origin, un Access-Control-Allow-Methods contenant PROPFIND, et un Access-Control-Allow-Headers contenant authorization en toutes lettres. Un 401 ici, c'est le §1.2 : le préflight n'est pas court-circuité avant l'authentification.

# 2. La vraie requête renvoie-t-elle l'en-tête d'origine ?
curl -sS -D - -o /dev/null -X PROPFIND \
  https://cloud.exemple.fr/remote.php/dav/files/UTILISATEUR/ \
  -H 'Origin: https://tofu.exemple.fr' -H 'Depth: 1' \
  -u 'UTILISATEUR:MOT_DE_PASSE_APPLICATION'

Attendu : 207 Multi-Status et un Access-Control-Allow-Origin. Un 207 sans en-tête, c'est le always de nginx ou le Header always set d'Apache qui manque.

# 3. Depuis le navigateur, en réel
python3 -m http.server 8000   # puis http://localhost:8000

L'écran de connexion de Tofu lance runWebdavCheck : joindre le serveur, créer le dossier, écrire, relire, lister, nettoyer. Chaque étape est reportée avec un marqueur textuel OK / ÉCHEC et son détail. C'est le test qui fait foi — les deux commandes curl ne parlent pas CORS, seul un navigateur applique la règle.


Sources