src/Service/EuroOffice/CallbackHandler.php line 171

Open in your IDE?
  1. <?php
  2. namespace App\Service\EuroOffice;
  3. use App\Entity\Section;
  4. use App\Entity\SectionRevision;
  5. use App\Entity\User;
  6. use App\Message\IndexSectionMessage;
  7. use App\Repository\UserRepository;
  8. use Doctrine\ORM\EntityManagerInterface;
  9. use Firebase\JWT\JWT;
  10. use Firebase\JWT\Key;
  11. use Psr\Log\LoggerInterface;
  12. use Symfony\Component\HttpFoundation\Request;
  13. use Symfony\Component\Messenger\MessageBusInterface;
  14. use Symfony\Contracts\Cache\CacheInterface;
  15. use Symfony\Contracts\Cache\ItemInterface;
  16. use Symfony\Contracts\HttpClient\HttpClientInterface;
  17. /**
  18.  * Gère les callbacks DocumentServer (status 0..7).
  19.  * Doc : https://api.onlyoffice.com/editors/callback
  20.  */
  21. class CallbackHandler
  22. {
  23.     /**
  24.      * Whitelist d'hôtes autorisés pour le téléchargement du DOCX retourné par
  25.      * DocServer dans le payload callback (champ `url`). Sans whitelist, le
  26.      * payload contrôlé par l'attaquant permettait un SSRF intra-Docker
  27.      * (http://qdrant:6333, http://mysql, http://169.254.169.254/...).
  28.      *
  29.      * Construite depuis EURO_OFFICE_PUBLIC_URL + EURO_OFFICE_CALLBACK_HOST
  30.      * (les 2 URLs déjà connues du DocServer pour notre stack).
  31.      *
  32.      * @var string[]
  33.      */
  34.     private readonly array $allowedHosts;
  35.     /**
  36.      * Hostname (sans port) à utiliser quand on télécharge le DOCX depuis le
  37.      * callback. DocServer met l'URL publique browser-side (ex: localhost:8083)
  38.      * dans le payload mais Symfony ne peut pas joindre cet host depuis le
  39.      * réseau Docker → on réécrit vers ce hostname interne.
  40.      */
  41.     private readonly ?string $internalDocserverHost;
  42.     private readonly ?int $internalDocserverPort;
  43.     private readonly ?string $internalDocserverScheme;
  44.     private readonly ?string $publicDocserverHost;
  45.     public function __construct(
  46.         private readonly EntityManagerInterface $em,
  47.         private readonly DocumentStorage $storage,
  48.         private readonly DocxAnalyzer $analyzer,
  49.         private readonly HttpClientInterface $http,
  50.         private readonly UserRepository $users,
  51.         private readonly LoggerInterface $logger,
  52.         private readonly CacheInterface $cache,
  53.         private readonly MessageBusInterface $bus,
  54.         private readonly string $jwtSecret,
  55.         ?string $euroOfficePublicUrl null,
  56.         ?string $docserverInternalUrl null,
  57.         // Permet d'ajouter d'autres hôtes (déploiements multi-DocServer)
  58.         ?string $extraAllowedHostsCsv null,
  59.     ) {
  60.         $hosts = [];
  61.         foreach ([$euroOfficePublicUrl$docserverInternalUrl] as $url) {
  62.             $host $url $this->extractHost($url) : null;
  63.             if ($host$hosts[] = $host;
  64.         }
  65.         foreach (explode(',', (string) ($extraAllowedHostsCsv ?? '')) as $extra) {
  66.             $extra trim($extra);
  67.             if ($extra !== ''$hosts[] = strtolower($extra);
  68.         }
  69.         // Fallback dev : le hostname Docker `documentserver` qu'on retrouve dans
  70.         // EURO_OFFICE_INTERNAL_URL par défaut. On l'ajoute pour ne pas bloquer
  71.         // un setup Docker standard sans configuration explicite.
  72.         $hosts[] = 'documentserver';
  73.         $this->allowedHosts array_values(array_unique($hosts));
  74.         // Pré-parse l'URL interne pour la réécriture (host + port + scheme)
  75.         $internalParts $docserverInternalUrl parse_url($docserverInternalUrl) : null;
  76.         $this->internalDocserverHost $internalParts['host'] ?? 'documentserver';
  77.         $this->internalDocserverPort = isset($internalParts['port']) ? (int) $internalParts['port'] : null;
  78.         $this->internalDocserverScheme $internalParts['scheme'] ?? 'http';
  79.         $publicParts $euroOfficePublicUrl parse_url($euroOfficePublicUrl) : null;
  80.         $this->publicDocserverHost $publicParts['host'] ?? null;
  81.     }
  82.     /** @return array{error:int, message?:string} */
  83.     public function handle(Section $sectionRequest $request): array
  84.     {
  85.         $payload $this->verifyAndDecode($request);
  86.         if ($payload === null) {
  87.             return ['error' => 1'message' => 'invalid jwt'];
  88.         }
  89.         $status = (int) ($payload['status'] ?? 0);
  90.         switch ($status) {
  91.             case 1: return ['error' => 0]; // editing
  92.             case 2:                          // ready to save (last user closed)
  93.             case 6:                          // force save (autosave)
  94.                 $url $payload['url'] ?? null;
  95.                 if (!$url) return ['error' => 1'message' => 'no url'];
  96.                 if (!$this->isAllowedDocserverUrl($url)) {
  97.                     $this->logger->warning('Callback rejeté : URL hors whitelist', [
  98.                         'section' => $section->getUuid(),
  99.                         'url' => $url,
  100.                         'allowedHosts' => $this->allowedHosts,
  101.                     ]);
  102.                     return ['error' => 1'message' => 'url not allowed'];
  103.                 }
  104.                 // **Anti-replay** : un attaquant qui a intercepté un callback
  105.                 // légitime (MITM intra-Docker, leak via logs) pourrait le
  106.                 // rejouer pour écraser le DOCX avec une URL contrôlée. On
  107.                 // calcule un hash unique du payload + URL et on le marque
  108.                 // comme "consommé" en cache pendant 1h. Replay = rejet.
  109.                 if ($this->isReplay($section$url$payload)) {
  110.                     $this->logger->warning('Callback replay détecté → rejet', [
  111.                         'section' => $section->getUuid(),
  112.                         'url' => $url,
  113.                     ]);
  114.                     return ['error' => 1'message' => 'replay detected'];
  115.                 }
  116.                 $this->saveDocument($section$url$payload$status === 2);
  117.                 return ['error' => 0];
  118.             case 3:
  119.             case 7:
  120.                 $this->logger->error('Save error reçu', ['section' => $section->getUuid(), 'payload' => $payload]);
  121.                 return ['error' => 0];
  122.             default: return ['error' => 0];
  123.         }
  124.     }
  125.     /**
  126.      * Vérifie le JWT du callback. En PROD, le secret DOIT être présent et un
  127.      * token DOIT être fourni — pas de fallback "JWT désactivé en dev" qui
  128.      * laissait passer n'importe quel POST anonyme. En dev (APP_ENV=dev) on
  129.      * tolère l'absence de token uniquement si JWT_ENABLED est explicitement
  130.      * désactivé côté DocServer (configuration locale).
  131.      */
  132.     private function verifyAndDecode(Request $request): ?array
  133.     {
  134.         $body json_decode($request->getContent(), true) ?? [];
  135.         if (isset($body['token'])) {
  136.             try {
  137.                 return (array) JWT::decode($body['token'], new Key($this->jwtSecret'HS256'));
  138.             } catch (\Throwable $e) {
  139.                 $this->logger->warning('Callback JWT invalide (body)', ['error' => $e->getMessage()]);
  140.                 return null;
  141.             }
  142.         }
  143.         $h $request->headers->get('Authorization');
  144.         if ($h && str_starts_with($h'Bearer ')) {
  145.             try {
  146.                 $decoded = (array) JWT::decode(substr($h7), new Key($this->jwtSecret'HS256'));
  147.                 return array_merge($body, (array) ($decoded['payload'] ?? []));
  148.             } catch (\Throwable $e) {
  149.                 $this->logger->warning('Callback JWT invalide (header)', ['error' => $e->getMessage()]);
  150.                 return null;
  151.             }
  152.         }
  153.         // Pas de token → refus en prod, tolérance en dev (DocServer JWT_ENABLED=false)
  154.         $appEnv $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? 'prod';
  155.         if ($appEnv === 'prod' || $appEnv === 'staging') {
  156.             $this->logger->warning('Callback sans JWT refusé (env='.$appEnv.')');
  157.             return null;
  158.         }
  159.         return $body;
  160.     }
  161.     private function saveDocument(Section $sectionstring $url, array $payloadbool $finalSave): void
  162.     {
  163.         // L'URL fournie par DocServer est l'URL publique (browser-side), ex:
  164.         // http://localhost:8083/cache/files/... — Symfony, depuis son container,
  165.         // ne peut pas joindre `localhost:8083`. On réécrit vers le hostname
  166.         // Docker interne avant le download.
  167.         $fetchUrl $this->rewriteToInternalUrl($url);
  168.         // verify_peer=true (défaut). En intra-Docker on parle souvent en HTTP donc le drapeau
  169.         // est moot, mais s'il y a TLS on veut une vraie vérif (les attaquants en MITM réseau
  170.         // pouvaient remplacer le contenu téléchargé via cert auto-signé).
  171.         $bytes $this->http->request('GET'$fetchUrl, [
  172.             'timeout' => 60,
  173.             'max_redirects' => 0// pas de suivi de redirect (sinon bypass de la whitelist)
  174.         ])->getContent();
  175.         if ($finalSave && $section->getDocxPath() && $this->storage->exists($section->getDocxPath())) {
  176.             $rev = new SectionRevision();
  177.             $rev->setSection($section);
  178.             $rev->setName('Auto — '.(new \DateTimeImmutable())->format('Y-m-d H:i:s'));
  179.             $rev->setText('');
  180.             if (isset($payload['users'][0])) {
  181.                 $u $this->users->findOneBy(['uuid' => $payload['users'][0]]);
  182.                 if ($u instanceof User$rev->setUser($u);
  183.             }
  184.             $old $this->storage->read($section->getDocxPath());
  185.             $this->storage->writeRevision(sprintf('section-%s-%d.docx'$section->getUuid(), time()), $old);
  186.             $this->em->persist($rev);
  187.         }
  188.         $this->storage->write($section->getDocxPath(), $bytes);
  189.         $analysis $this->analyzer->analyze($bytes);
  190.         $section->setCountWords($analysis['words']);
  191.         $section->setCountCharacters($analysis['characters']);
  192.         $section->setText($analysis['text']);   // garde le texte plein dans Section.text pour ELS
  193.         $section->setLastSavedAt(new \DateTimeImmutable());
  194.         $section->regenerateDocxKey();
  195.         $this->em->flush();
  196.         // Dispatch async re-index RAG. Best-effort : si Redis down, on log
  197.         // mais on ne casse pas le save. Reindex manuel via app:rag:reindex
  198.         // possible plus tard.
  199.         try {
  200.             $this->bus->dispatch(new IndexSectionMessage($section->getId()));
  201.         } catch (\Throwable $e) {
  202.             $this->logger->warning('Section RAG re-index dispatch failed', [
  203.                 'sectionUuid' => $section->getUuid(),
  204.                 'error' => $e->getMessage(),
  205.             ]);
  206.         }
  207.     }
  208.     /**
  209.      * Vérifie que l'URL fournie par le callback :
  210.      *  - utilise un schéma http/https (pas file://, gopher://, etc.)
  211.      *  - pointe sur un host whitelisté
  212.      *  - n'est pas un IP privée non whitelistée (anti-SSRF)
  213.      */
  214.     private function isAllowedDocserverUrl(string $url): bool
  215.     {
  216.         $parts parse_url($url);
  217.         if (!$parts || empty($parts['host']) || empty($parts['scheme'])) return false;
  218.         $scheme strtolower($parts['scheme']);
  219.         if (!in_array($scheme, ['http''https'], true)) return false;
  220.         $host strtolower($parts['host']);
  221.         if (in_array($host$this->allowedHoststrue)) return true;
  222.         // Bloque les hôtes pointant sur des plages privées (sauf si explicitement
  223.         // whitelistés au-dessus). Couvre 127.0.0.0/8, 169.254.0.0/16 (link-local
  224.         // / AWS metadata), 10/8, 172.16/12, 192.168/16.
  225.         $ip filter_var($hostFILTER_VALIDATE_IP) ? $host : @gethostbyname($host);
  226.         if ($ip && filter_var($ipFILTER_VALIDATE_IPFILTER_FLAG_NO_PRIV_RANGE FILTER_FLAG_NO_RES_RANGE) === false) {
  227.             return false;
  228.         }
  229.         return false;
  230.     }
  231.     /**
  232.      * Si l'URL contient l'hostname public du DocServer (browser-side, ex
  233.      * `localhost:8083`), réécrit vers l'hostname Docker interne (ex
  234.      * `documentserver`). Sinon → URL retournée telle-quelle (cas multi-DocServer
  235.      * où le host est déjà interne).
  236.      *
  237.      * Ne touche QUE le host + port. Path/query/fragment préservés (le md5/expires
  238.      * dans la query string sont signés par DocServer et doivent passer intacts).
  239.      */
  240.     private function rewriteToInternalUrl(string $url): string
  241.     {
  242.         $parts parse_url($url);
  243.         if (!$parts || empty($parts['host'])) return $url;
  244.         $currentHost strtolower($parts['host']);
  245.         // Réécrit uniquement si on touche bien le host public (sinon laissé tel
  246.         // quel : multi-DocServer staging/prod).
  247.         if ($this->publicDocserverHost === null || $currentHost !== $this->publicDocserverHost) {
  248.             return $url;
  249.         }
  250.         if ($this->internalDocserverHost === null) return $url;
  251.         $rebuilt = ($this->internalDocserverScheme ?? 'http') . '://' $this->internalDocserverHost;
  252.         if ($this->internalDocserverPort !== null) {
  253.             $rebuilt .= ':' $this->internalDocserverPort;
  254.         }
  255.         $rebuilt .= $parts['path'] ?? '';
  256.         if (!empty($parts['query'])) $rebuilt .= '?' $parts['query'];
  257.         if (!empty($parts['fragment'])) $rebuilt .= '#' $parts['fragment'];
  258.         return $rebuilt;
  259.     }
  260.     private function extractHost(string $url): ?string
  261.     {
  262.         $url trim($url);
  263.         if ($url === '') return null;
  264.         $parts parse_url($url);
  265.         return !empty($parts['host']) ? strtolower($parts['host']) : null;
  266.     }
  267.     /**
  268.      * Détecte un replay sur le callback : on store le hash (section + url +
  269.      * iat du JWT + docx key) en cache 1h. Si la même clé revient → on était
  270.      * déjà passé → replay → reject.
  271.      *
  272.      * Le pattern Symfony Cache `get($key, callable)` retourne la valeur
  273.      * stockée (1er hit invoque la callback, suivants la skip). On stocke un
  274.      * `ts` au 1er passage ; si `now - ts > 2s` au retour, c'est qu'on avait
  275.      * déjà cette clé → replay.
  276.      */
  277.     private function isReplay(Section $sectionstring $url, array $payload): bool
  278.     {
  279.         // Clé unique par couple (section, url, iat). `iat` change à chaque
  280.         // save légitime → 2 saves successifs ne sont jamais marqués comme
  281.         // replay l'un de l'autre.
  282.         $signature hash('sha256'implode('|', [
  283.             $section->getUuid(),
  284.             $url,
  285.             (string) ($payload['iat'] ?? ''),
  286.             (string) ($payload['key'] ?? ''),
  287.         ]));
  288.         $cacheKey 'docserver_callback_replay.'.$signature;
  289.         try {
  290.             $existing $this->cache->get($cacheKey, function (ItemInterface $item) {
  291.                 $item->expiresAfter(3600); // 1h
  292.                 return ['ts' => time()];
  293.             });
  294.             $ts is_array($existing) ? (int) ($existing['ts'] ?? 0) : 0;
  295.             // Marge de 2s : si on revient sur la même clé après >2s, c'est
  296.             // qu'elle a été enregistrée par un appel précédent → replay.
  297.             return $ts && (time() - $ts) > 2;
  298.         } catch (\Throwable $e) {
  299.             // Cache down → fail-open (mieux que bloquer tous les saves)
  300.             $this->logger->warning('Callback replay check : cache error', ['error' => $e->getMessage()]);
  301.             return false;
  302.         }
  303.     }
  304. }