Prechádzať zdrojové kódy

fix: require confirm:"SEED" on /maintenance/seed-demo (SEC_REVIEW F15)

`MaintenanceController::seedDemo` accepted a bare POST and inserted
~3,000 synthetic reports plus reporters/consumers/IPs/manual-blocks
into whatever database it was pointed at. The asymmetry with `purge`
(which gates on `confirm: "PURGE"`) meant any actor with admin role
or a compromised admin/service token could load demo data into a
production DB with one curl. The 409 "already_seeded" check is keyed
only on a literal demo-named reporter, so a partial purge between
calls re-armed the bomb — also a cheap repeated-write DoS vector.

The seed handler now validates `confirm: "SEED"` before any other
work, returning 400 `validation_failed` otherwise. The check runs
before the 409 shortcut and before `beginTransaction`, so rejected
calls never touch `reporters`/`reports`/`api_tokens`.

UI BFF wired end-to-end: `AdminClient::seedDemo` now sends the
literal in the JSON body; `SettingsController::seedDemo` requires
the user to type `SEED` in the form and otherwise surfaces a flash
error (mirroring `purge` exactly); the seed-demo modal in
`pages/settings/index.twig` now asks the admin to type `SEED` to
enable the submit button — same UX as the purge modal.

OpenAPI (`api/public/openapi.yaml` and `api/openapi.php`) documents
the new request body and 400 response. Regression test
`testSeedDemoRequiresLiteralConfirmString` covers both no-body and
wrong-literal POSTs returning 400 with no `reporters`/`reports`
landed; existing happy-path / RBAC tests updated to send the new
body.

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

+ 3 - 1
api/openapi.php

@@ -575,11 +575,13 @@ $paths = [
         'post' => [
             'tags' => ['Admin'],
             'summary' => 'Load demo dataset (Admin)',
-            'description' => "Populates reporters, consumers, IPs, reports, manual blocks, allowlist, and synthetic GeoIP for demos and screenshots. Triggers a full score recompute on completion. Idempotent: returns 409 if demo data is already present.",
+            'description' => "Populates reporters, consumers, IPs, reports, manual blocks, allowlist, and synthetic GeoIP for demos and screenshots. Triggers a full score recompute on completion. Idempotent: returns 409 if demo data is already present.\n\nRequires `confirm: \"SEED\"` in the body — any other value returns 400.",
             'security' => [['BearerAuth' => []]],
             'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
+            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'required' => ['confirm'], 'properties' => ['confirm' => ['type' => 'string', 'enum' => ['SEED']]]]]]],
             'responses' => [
                 '200' => ['description' => 'Demo data inserted', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['status' => ['type' => 'string', 'example' => 'seeded'], 'summary' => ['type' => 'object', 'additionalProperties' => ['type' => 'integer']], 'recompute' => ['$ref' => '#/components/schemas/JobOutcome']]]]]],
+                '400' => ['description' => 'Missing or wrong `confirm` value'],
                 '409' => ['description' => 'Demo data already present'],
                 '412' => ['description' => 'No categories configured'],
             ],

+ 19 - 1
api/public/openapi.yaml

@@ -1359,11 +1359,27 @@ paths:
       tags:
         - Admin
       summary: Load demo dataset (Admin)
-      description: 'Populates reporters, consumers, IPs, reports, manual blocks, allowlist, and synthetic GeoIP for demos and screenshots. Triggers a full score recompute on completion. Idempotent: returns 409 if demo data is already present.'
+      description: |
+        Populates reporters, consumers, IPs, reports, manual blocks, allowlist, and synthetic GeoIP for demos and screenshots. Triggers a full score recompute on completion. Idempotent: returns 409 if demo data is already present.
+
+        Requires `confirm: "SEED"` in the body — any other value returns 400.
       security:
         - BearerAuth: []
       parameters:
         - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              type: object
+              required:
+                - confirm
+              properties:
+                confirm:
+                  type: string
+                  enum:
+                    - SEED
       responses:
         '200':
           description: Demo data inserted
@@ -1381,6 +1397,8 @@ paths:
                       type: integer
                   recompute:
                     '$ref': '#/components/schemas/JobOutcome'
+        '400':
+          description: Missing or wrong `confirm` value
         '409':
           description: Demo data already present
         '412':

+ 12 - 1
api/src/Application/Admin/MaintenanceController.php

@@ -280,10 +280,21 @@ final class MaintenanceController
 
     /**
      * Populate the database with a demo dataset. Idempotent: returns 409 if
-     * the marker reporter ("demo-web-edge") already exists.
+     * the marker reporter ("demo-web-edge") already exists. Body must
+     * include `confirm: "SEED"` — symmetric with `purge` so a single drive-by
+     * POST (or a compromised admin token) can't dump thousands of synthetic
+     * rows into a production database. (SEC_REVIEW F15.)
      */
     public function seedDemo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
     {
+        $body = self::jsonBody($request);
+        $confirm = isset($body['confirm']) && is_string($body['confirm']) ? $body['confirm'] : '';
+        if ($confirm !== 'SEED') {
+            return self::validationFailed($response, [
+                'confirm' => 'must be the literal string "SEED" to authorize the demo seed',
+            ]);
+        }
+
         if ($this->reporters->findByName(self::DEMO_REPORTERS[0]['name']) !== null) {
             return self::json($response, 409, [
                 'error' => 'already_seeded',

+ 39 - 3
api/tests/Integration/Admin/MaintenanceControllerTest.php

@@ -95,7 +95,8 @@ final class MaintenanceControllerTest extends AppTestCase
         $first = $this->request(
             'POST',
             '/api/v1/admin/maintenance/seed-demo',
-            ['Authorization' => 'Bearer ' . $token],
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            '{"confirm": "SEED"}',
         );
         self::assertSame(200, $first->getStatusCode());
         $body = $this->decode($first);
@@ -112,7 +113,8 @@ final class MaintenanceControllerTest extends AppTestCase
         $second = $this->request(
             'POST',
             '/api/v1/admin/maintenance/seed-demo',
-            ['Authorization' => 'Bearer ' . $token],
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            '{"confirm": "SEED"}',
         );
         self::assertSame(409, $second->getStatusCode());
         self::assertSame('already_seeded', $this->decode($second)['error']);
@@ -124,8 +126,42 @@ final class MaintenanceControllerTest extends AppTestCase
         $resp = $this->request(
             'POST',
             '/api/v1/admin/maintenance/seed-demo',
-            ['Authorization' => 'Bearer ' . $token],
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            '{"confirm": "SEED"}',
         );
         self::assertSame(403, $resp->getStatusCode());
     }
+
+    /**
+     * SEC_REVIEW F15: a single drive-by POST with no body must not load the
+     * demo dataset. The API requires `confirm: "SEED"` symmetric with
+     * `purge`'s `"PURGE"` gate.
+     */
+    public function testSeedDemoRequiresLiteralConfirmString(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, Role::Admin);
+
+        // No body at all.
+        $bare = $this->request(
+            'POST',
+            '/api/v1/admin/maintenance/seed-demo',
+            ['Authorization' => 'Bearer ' . $token],
+        );
+        self::assertSame(400, $bare->getStatusCode());
+        self::assertSame('validation_failed', $this->decode($bare)['error']);
+
+        // Wrong literal.
+        $wrong = $this->request(
+            'POST',
+            '/api/v1/admin/maintenance/seed-demo',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            '{"confirm": "yes"}',
+        );
+        self::assertSame(400, $wrong->getStatusCode());
+        self::assertSame('validation_failed', $this->decode($wrong)['error']);
+
+        // Nothing landed.
+        self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reporters'));
+        self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM reports'));
+    }
 }

+ 17 - 9
ui/resources/views/pages/settings/index.twig

@@ -157,7 +157,7 @@
 
         <div class="mt-4 grid gap-4 md:grid-cols-2">
             <div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-900 dark:bg-emerald-950/40"
-                 x-data="{ open: false, submitting: false }">
+                 x-data="{ open: false, confirm: '', submitting: false }">
                 <h3 class="text-sm font-semibold text-emerald-800 dark:text-emerald-200">Load demo data</h3>
                 <p class="mt-1 text-xs text-emerald-900/80 dark:text-emerald-200/80">
                     Inserts demo reporters, consumers, IPs, reports, manual blocks, allowlist entries, and synthetic GeoIP — then triggers a full score recompute. Returns "already seeded" if demo data is present.
@@ -176,16 +176,24 @@
                         <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
                             This will add sample reporters, consumers, IPs, and reports to the database, then run a full recompute. Existing real data is left untouched.
                         </p>
-                        <form method="post" action="/app/settings/maintenance/seed-demo" class="mt-4 flex justify-end gap-2"
+                        <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
+                            Type <code class="rounded bg-slate-100 px-1 py-0.5 font-mono text-xs text-emerald-700 dark:bg-slate-800 dark:text-emerald-300">SEED</code> to confirm:
+                        </p>
+                        <form method="post" action="/app/settings/maintenance/seed-demo" class="mt-3"
                               x-on:submit="submitting = true">
                             <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
-                            <button type="button" x-on:click="open = false"
-                                    class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
-                            <button type="submit" x-bind:disabled="submitting"
-                                    class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50">
-                                <span x-show="!submitting">Load demo data</span>
-                                <span x-show="submitting" x-cloak>Loading…</span>
-                            </button>
+                            <input type="text" name="confirm" autocomplete="off" x-model="confirm"
+                                   class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-sm dark:border-slate-700 dark:bg-slate-950"
+                                   placeholder="SEED">
+                            <div class="mt-4 flex justify-end gap-2">
+                                <button type="button" x-on:click="open = false; confirm = ''"
+                                        class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-50 dark:border-slate-700 dark:hover:bg-slate-800">Cancel</button>
+                                <button type="submit" x-bind:disabled="confirm !== 'SEED' || submitting"
+                                        class="rounded-md bg-emerald-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-40">
+                                    <span x-show="!submitting">Load demo data</span>
+                                    <span x-show="submitting" x-cloak>Loading…</span>
+                                </button>
+                            </div>
                         </form>
                     </div>
                 </div>

+ 4 - 2
ui/src/ApiClient/AdminClient.php

@@ -492,7 +492,9 @@ final class AdminClient
     }
 
     /**
-     * Load the demo dataset. Returns 409 if data is already seeded.
+     * Load the demo dataset. The api requires `confirm: "SEED"` in the body —
+     * symmetric with `purgeData`'s `"PURGE"` gate. Returns 409 if data is
+     * already seeded.
      *
      * @return array<string, mixed>
      */
@@ -501,7 +503,7 @@ final class AdminClient
         return $this->api->request(
             'POST',
             '/api/v1/admin/maintenance/seed-demo',
-            [],
+            ['json' => ['confirm' => 'SEED']],
             $actingUserId,
         );
     }

+ 13 - 0
ui/src/Controllers/SettingsController.php

@@ -206,6 +206,11 @@ final class SettingsController
     /**
      * Load the demo dataset. Idempotent on the api side — repeats return
      * 409 which we surface as an info-level flash, not an error.
+     *
+     * The api requires the literal `SEED` string (SEC_REVIEW F15); we
+     * additionally require the user to have typed it in the form, both to
+     * avoid drive-by clicks and to keep curl-style misuse loud — same
+     * pattern as `purge` above.
      */
     public function seedDemo(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
     {
@@ -220,6 +225,14 @@ final class SettingsController
             return $response->withStatus(303)->withHeader('Location', '/no-access');
         }
 
+        $body = $this->formBody($request);
+        $confirm = isset($body['confirm']) && is_string($body['confirm']) ? trim($body['confirm']) : '';
+        if ($confirm !== 'SEED') {
+            $this->sessions()->flash('error', 'Type SEED exactly to confirm the demo seed.');
+
+            return $response->withStatus(303)->withHeader('Location', '/app/settings');
+        }
+
         try {
             $result = $this->admin->seedDemo($user->userId);
             $summary = is_array($result['summary'] ?? null) ? $result['summary'] : [];