|
|
@@ -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>
|