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

435 lines
22 KiB
Markdown

# 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 :
```sh
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 :**
```sh
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` :
```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`) :
```nginx
# 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 :
```nginx
# « 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 :
```nginx
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).
```apache
<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 :
```nginx
# « ^~ » 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` :
```apache
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.
```sh
# 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.
```sh
# 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.
```sh
# 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
- [MDN — Cross-Origin Resource Sharing (CORS)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS) — méthodes et en-têtes safelistés, définition d'une requête créditée, `*` interdit aux requêtes créditées.
- [MDN — `Access-Control-Allow-Headers`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Headers) — « The `Authorization` header doesn't accept wildcard and always needs to be listed explicitly. »
- [MDN — `Access-Control-Max-Age`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Max-Age) — défaut 5 s, plafond 7200 s (Chromium ≥ 76), 86400 s (Firefox).
- [nextcloud/server#3131 — Support for cross-domain WebDAV access (CORS)](https://github.com/nextcloud/server/issues/3131) — ouverte depuis 2017, jalon Nextcloud 36.
- [nextcloud/server#19719 — CORS Problem CalDav](https://github.com/nextcloud/server/issues/19719) — symptôme identique sur `/remote.php/dav/`.
- [digital-blueprint/webapppassword](https://github.com/digital-blueprint/webapppassword) — dépôt, clés `webapppassword.*`, jokers de sous-domaine.
- [`lib/Connector/Sabre/CorsPlugin.php`](https://github.com/digital-blueprint/webapppassword/blob/main/lib/Connector/Sabre/CorsPlugin.php) — en-têtes réellement posés et court-circuit du préflight en 204 avant authentification.
- [WebAppPassword sur l'App Store Nextcloud](https://apps.nextcloud.com/apps/webapppassword) — version 26.8.0, Nextcloud 25 à 35.
- [Nextcloud — Basic File & Folder Operations (WebDAV)](https://docs.nextcloud.com/server/stable/developer_manual/client_apis/WebDAV/basic.html) — `/remote.php/dav/files/<user>/`, authentification Basic.
- Mesures directes du 2026-09-17 sur `cloud.disroot.org` et `framagenda.org` (§1.2).