Prechádzať zdrojové kódy

fix: pin RapiDoc CDN load with SRI hash on /api/docs (SEC_REVIEW F58)

`DocsController::viewer` embedded the RapiDoc viewer with a bare
`<script src="https://cdn.jsdelivr.net/.../rapidoc-min.js">` — no
`integrity` attribute, no `crossorigin`. If jsDelivr served
different bytes (compromise, in-flight modification, hostile origin
failover), the browser would happily execute the alternate JS.
RapiDoc has full DOM access on the docs page, including any
"try-it-now" auth token the operator pastes in.

Add SRI: `integrity="sha384-…"` + `crossorigin="anonymous"`. The
hash was computed from the actual upstream bytes via:

  curl -sSL https://cdn.jsdelivr.net/npm/rapidoc@9.3.4/dist/\
       rapidoc-min.js | openssl dgst -sha384 -binary | base64 -w 0

  → MDSxszbIJtK/9YakZ3tvi2bK6LaaHnB8+Hd2/fCfih0tLa+Mqlv6HO0bZdrICjjG

The browser now refuses to execute the script unless the served
bytes hash to that exact value, so a CDN compromise can no longer
inject altered JS into the docs page. The hash is captured as a
class constant `RAPIDOC_INTEGRITY` alongside `RAPIDOC_URL` so a
future version bump is a documented two-line change — the docblock
includes the recipe to reproduce the hash.

Caddyfile CSP is unchanged: `script-src 'self'
https://cdn.jsdelivr.net 'unsafe-inline'` still allows the CDN
*host* (per-host contract); the SRI is the per-*bytes* contract.
Vendoring locally was considered but rejected — the M01 Caddyfile
routes everything through PHP, and reshaping that to serve a
static asset would be a wider change than the F58 ask. CDN + SRI
is the SEC_REVIEW-accepted alternative.

Regression test in
`api/tests/Integration/Public/DocsControllerTest.php`:
  - `testDocsPageEmbedsRapiDocWithSriIntegrity` matches the
    `integrity="sha384-<64 base64 chars>"` shape and asserts
    `crossorigin="anonymous"` is present.
  - `testOpenapiSpecIsServed` is a sanity check that the spec
    endpoint still wires through the edited controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 4 dní pred
rodič
commit
95e206c436

+ 31 - 8
api/src/Application/Public/DocsController.php

@@ -13,18 +13,38 @@ use Psr\Http\Message\ServerRequestInterface;
  *  - `GET /api/v1/openapi.yaml` — the raw spec, `application/yaml`.
  *  - `GET /api/docs`           — single-page HTML loading RapiDoc.
  *
- * RapiDoc is loaded from a CDN-vendored npm static asset (jsDelivr SRI'd
- * URL). It's the smallest viable viewer — ~120 KB minified, no
- * dependencies. We deliberately don't ship a vendored copy in `public/`
- * because the asset path would need to coexist with FrankenPHP's
- * static-file serving rules and the M01 Caddyfile is configured to
- * route everything through PHP. The CDN URL is locked to a specific
- * version, so an outage there is the only failure mode.
+ * RapiDoc is loaded from jsDelivr at a specific version; the
+ * `<script>` tag carries an SRI `integrity="sha384-…"` and
+ * `crossorigin="anonymous"` so the browser refuses to execute the
+ * JS if a CDN compromise (or an inflight content modification)
+ * delivers different bytes (SEC_REVIEW F58). We deliberately don't
+ * ship a vendored copy in `public/` because the asset path would
+ * need to coexist with FrankenPHP's static-file serving rules and
+ * the M01 Caddyfile is configured to route everything through PHP.
+ * The CDN URL is locked to a specific version, so an outage there
+ * is the only failure mode.
+ *
+ * Bumping the RapiDoc version is a two-line change:
+ *   1. Update {@see RAPIDOC_URL} to the new version path.
+ *   2. Update {@see RAPIDOC_INTEGRITY} to:
+ *        sha384- + base64(openssl dgst -sha384 -binary
+ *                          rapidoc-min.js)
+ *      You can compute it locally via:
+ *        curl -sSL <url> | openssl dgst -sha384 -binary
+ *          | base64 -w 0
  */
 final class DocsController
 {
     private const RAPIDOC_URL = 'https://cdn.jsdelivr.net/npm/rapidoc@9.3.4/dist/rapidoc-min.js';
 
+    /**
+     * SEC_REVIEW F58: SRI hash for rapidoc-min.js@9.3.4 served by
+     * jsDelivr. Computed from the actual upstream bytes via
+     * `openssl dgst -sha384 -binary | base64`. The browser refuses
+     * to execute the script if the bytes don't match.
+     */
+    private const RAPIDOC_INTEGRITY = 'sha384-MDSxszbIJtK/9YakZ3tvi2bK6LaaHnB8+Hd2/fCfih0tLa+Mqlv6HO0bZdrICjjG';
+
     public function __construct(private readonly string $openapiPath)
     {
     }
@@ -48,6 +68,7 @@ final class DocsController
     public function viewer(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
     {
         $rapidoc = self::RAPIDOC_URL;
+        $integrity = self::RAPIDOC_INTEGRITY;
         $html = <<<HTML
             <!DOCTYPE html>
             <html lang="en">
@@ -55,7 +76,9 @@ final class DocsController
                 <meta charset="utf-8">
                 <title>IRDB — API Reference</title>
                 <meta name="viewport" content="width=device-width, initial-scale=1">
-                <script type="module" src="{$rapidoc}"></script>
+                <script type="module" src="{$rapidoc}"
+                        integrity="{$integrity}"
+                        crossorigin="anonymous"></script>
                 <style>html, body { margin: 0; padding: 0; height: 100%; }</style>
             </head>
             <body>

+ 51 - 0
api/tests/Integration/Public/DocsControllerTest.php

@@ -0,0 +1,51 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Public;
+
+use App\Tests\Integration\Support\AppTestCase;
+
+/**
+ * SEC_REVIEW F58 — `/api/docs` loads the RapiDoc viewer from jsDelivr.
+ * The `<script>` tag must carry an SRI `integrity` hash and
+ * `crossorigin="anonymous"` so the browser refuses to execute the JS
+ * if a CDN compromise or in-flight content modification serves
+ * different bytes.
+ */
+final class DocsControllerTest extends AppTestCase
+{
+    public function testDocsPageEmbedsRapiDocWithSriIntegrity(): void
+    {
+        $resp = $this->request('GET', '/api/docs');
+        self::assertSame(200, $resp->getStatusCode());
+        self::assertStringContainsString('text/html', $resp->getHeaderLine('Content-Type'));
+
+        $html = (string) $resp->getBody();
+        // Script tag points at the locked RapiDoc version.
+        self::assertStringContainsString(
+            'https://cdn.jsdelivr.net/npm/rapidoc@9.3.4/dist/rapidoc-min.js',
+            $html,
+        );
+        // Integrity hash present and well-formed (sha384- + base64).
+        self::assertMatchesRegularExpression(
+            '/integrity="sha384-[A-Za-z0-9+\/=]{64}"/',
+            $html,
+            'expected sha384 SRI on the rapidoc <script> tag',
+        );
+        // crossorigin=anonymous required alongside SRI for cross-origin scripts.
+        self::assertStringContainsString('crossorigin="anonymous"', $html);
+    }
+
+    public function testOpenapiSpecIsServed(): void
+    {
+        // Sanity check that we didn't break the spec endpoint while
+        // editing the controller.
+        $resp = $this->request('GET', '/api/v1/openapi.yaml');
+        // The spec file isn't shipped in tests; either 200 with content
+        // or 500 with `spec_unavailable`. Both are acceptable here —
+        // we just want to prove the route still wires through the
+        // edited controller.
+        self::assertContains($resp->getStatusCode(), [200, 500]);
+    }
+}