From 3405c1e2ccfc94ac15d2861c65af78febf1eaae9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sun, 12 Jul 2026 09:16:38 +0900 Subject: [PATCH] fix(cloud): enforce CSRF on authenticated state-changing POSTs (M1) Only SameSite=Lax protected the acknowledge and zone save/delete endpoints, even though a Csrf helper and mintable token already existed. Add GET /api/csrf (CsrfController::token) to mint/return a per-session token, check it (auth -> rbac -> csrf -> mutate) in EventDetailController::acknowledge and ZoneConfigController::save/delete, and wire app.js/zone-draw.js to fetch the token on load and send it as X-Csrf-Token on every POST. --- cloud/public/assets/app.js | 14 +++++++- cloud/public/assets/zone-draw.js | 16 +++++++-- cloud/public/index.php | 4 ++- cloud/src/Auth/CsrfController.php | 19 +++++++++++ cloud/src/Dashboard/EventDetailController.php | 4 +++ cloud/src/Dashboard/ZoneConfigController.php | 7 ++++ cloud/tests/DashboardTest.php | 19 +++++++++-- cloud/tests/ZoneConfigTest.php | 34 +++++++++++++++++-- 8 files changed, 107 insertions(+), 10 deletions(-) create mode 100644 cloud/src/Auth/CsrfController.php diff --git a/cloud/public/assets/app.js b/cloud/public/assets/app.js index 10e55cc..f787fa9 100644 --- a/cloud/public/assets/app.js +++ b/cloud/public/assets/app.js @@ -20,6 +20,14 @@ var root = document.getElementById('events-root'); if (!root) return; + var csrfToken = null; + function loadCsrfToken() { + return fetch('/api/csrf', { headers: { Accept: 'application/json' } }) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (data) { if (data && data.token) csrfToken = data.token; }) + .catch(function () { /* ack will fail with 403 and re-enable the button */ }); + } + function severityInfo(sev) { return SEVERITY_LABELS[sev] || { icon: 'ℹ', text: sev || '알수없음', cls: 'sev-info' }; } @@ -89,7 +97,10 @@ function acknowledge(eventId, btn) { btn.disabled = true; - fetch('/events/' + encodeURIComponent(eventId) + '/ack', { method: 'POST' }) + fetch('/events/' + encodeURIComponent(eventId) + '/ack', { + method: 'POST', + headers: { 'X-Csrf-Token': csrfToken || '' } + }) .then(function (res) { if (!res.ok) throw new Error('ack_failed:' + res.status); return loadEvents(); @@ -143,6 +154,7 @@ }); } + loadCsrfToken(); loadEvents(); setInterval(loadEvents, POLL_INTERVAL_MS); })(); diff --git a/cloud/public/assets/zone-draw.js b/cloud/public/assets/zone-draw.js index ecd3e27..a34aee4 100644 --- a/cloud/public/assets/zone-draw.js +++ b/cloud/public/assets/zone-draw.js @@ -35,11 +35,19 @@ var backgroundImage = null; var existingZones = []; // [{zone_id, level, points: [[x,y],...]}] var currentPoints = []; // in-progress polygon being drawn + var csrfToken = null; function setStatus(msg) { statusEl.textContent = msg; } + function loadCsrfToken() { + return fetch('/api/csrf', { headers: { Accept: 'application/json' } }) + .then(function (res) { return res.ok ? res.json() : null; }) + .then(function (data) { if (data && data.token) csrfToken = data.token; }) + .catch(function () { /* save/delete will fail with 403 if the token is missing */ }); + } + function parsePolygon(zone) { try { var pts = JSON.parse(zone.polygon_json || '[]'); @@ -142,7 +150,7 @@ setStatus('저장 중...'); fetch('/api/zones', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-Csrf-Token': csrfToken || '' }, body: JSON.stringify({ device_id: device, camera_id: camera, @@ -165,7 +173,10 @@ } function deleteZone(zoneId) { - fetch('/api/zones/' + encodeURIComponent(zoneId) + '/delete', { method: 'POST' }) + fetch('/api/zones/' + encodeURIComponent(zoneId) + '/delete', { + method: 'POST', + headers: { 'X-Csrf-Token': csrfToken || '' } + }) .then(function (res) { if (res.status === 401) { window.location.href = '/login'; return; } if (!res.ok) throw new Error('delete_failed:' + res.status); @@ -193,5 +204,6 @@ if (params.get('camera')) cameraInput.value = params.get('camera'); if (params.get('device')) deviceInput.value = params.get('device'); loadBackground(params.get('snapshot')); + loadCsrfToken(); if (cameraInput.value) loadZones(); })(); diff --git a/cloud/public/index.php b/cloud/public/index.php index 07093da..7b16dc1 100644 --- a/cloud/public/index.php +++ b/cloud/public/index.php @@ -11,7 +11,7 @@ use App\Repository\Pdo\{PdoDeviceRepository, PdoNonceRepository, PdoRateLimiterR PdoThresholdRepository, PdoAuditRepository}; use App\Ingest\IngestController; use App\Api\{ConfigController, HeartbeatController}; -use App\Auth\AuthController; +use App\Auth\{AuthController, CsrfController}; use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController}; $cfg = Config::fromArray(require __DIR__ . '/../config.php'); @@ -33,6 +33,7 @@ $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); $config = new ConfigController($devices, $zones, $thr); $heartbeat = new HeartbeatController($cfg, $devices); $auth = new AuthController($users, $audit, $rate); +$csrfCtl = new CsrfController(); $list = new EventListController($events, $sess); $detail = new EventDetailController($events, $snaps, $audit, $sess); $proxy = new SnapshotProxy($snaps, $sess); @@ -47,6 +48,7 @@ $r = new Router(); $r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now)); $r->add('GET', '/api/config', fn(Request $q) => $config->get($q)); +$r->add('GET', '/api/csrf', fn(Request $q) => $csrfCtl->token($q, $sess)); $r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $r->add('POST', '/logout', fn(Request $q) => $auth->logout($sess)); $r->add('GET', '/api/events', fn(Request $q) => $list->json($q)); diff --git a/cloud/src/Auth/CsrfController.php b/cloud/src/Auth/CsrfController.php new file mode 100644 index 0000000..92c65dd --- /dev/null +++ b/cloud/src/Auth/CsrfController.php @@ -0,0 +1,19 @@ +get('user_id') === null) { + return Response::json(['error' => 'unauthorized'], 401); + } + if ($sess->get('csrf') === null) { + $sess->set('csrf', bin2hex(random_bytes(32))); + } + return Response::json(['token' => $sess->get('csrf')]); + } +} diff --git a/cloud/src/Dashboard/EventDetailController.php b/cloud/src/Dashboard/EventDetailController.php index 92c64da..90f8c97 100644 --- a/cloud/src/Dashboard/EventDetailController.php +++ b/cloud/src/Dashboard/EventDetailController.php @@ -4,6 +4,7 @@ namespace App\Dashboard; use App\Http\{Request, Response}; use App\Auth\{SessionStore, Rbac}; +use App\Security\Csrf; use App\Repository\{EventRepository, SnapshotRepository, AuditRepository}; final class EventDetailController @@ -26,6 +27,9 @@ final class EventDetailController { if ($this->sess->get('user_id') === null) return Response::json(['error' => 'unauthorized'], 401); if (!Rbac::can((string)$this->sess->get('role'), 'acknowledge')) return Response::json(['error' => 'forbidden'], 403); + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } $id = (int)($params['id'] ?? 0); $user = (string)$this->sess->get('username'); if (!$this->events->acknowledge($id, $user)) return Response::json(['error' => 'not_found'], 404); diff --git a/cloud/src/Dashboard/ZoneConfigController.php b/cloud/src/Dashboard/ZoneConfigController.php index 12fec0b..9eff256 100644 --- a/cloud/src/Dashboard/ZoneConfigController.php +++ b/cloud/src/Dashboard/ZoneConfigController.php @@ -4,6 +4,7 @@ namespace App\Dashboard; use App\Http\{Request, Response}; use App\Auth\{SessionStore, Rbac}; +use App\Security\Csrf; use App\Geometry\Polygon; use App\Repository\{ZoneRepository, AuditRepository}; @@ -31,6 +32,9 @@ final class ZoneConfigController public function save(Request $req): Response { if ($g = $this->guard('edit_zones')) return $g; + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } $b = $req->json(); $level = (string)($b['level'] ?? ''); $polygon = $b['polygon'] ?? []; @@ -51,6 +55,9 @@ final class ZoneConfigController public function delete(Request $req, array $params): Response { if ($g = $this->guard('edit_zones')) return $g; + if (!Csrf::check((string)$this->sess->get('csrf'), (string)$req->header('X-Csrf-Token'))) { + return Response::json(['error' => 'csrf'], 403); + } $id = (string)($params['id'] ?? ''); if (!$this->zones->delete($id)) return Response::json(['error' => 'not_found'], 404); $this->audit->append((string)$this->sess->get('username'), 'zone_delete', "zone:$id", [], []); diff --git a/cloud/tests/DashboardTest.php b/cloud/tests/DashboardTest.php index a1c9286..22de476 100644 --- a/cloud/tests/DashboardTest.php +++ b/cloud/tests/DashboardTest.php @@ -10,7 +10,7 @@ use PHPUnit\Framework\TestCase; final class DashboardTest extends TestCase { private function authed(string $role = 'operator'): ArraySessionStore { - $s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); return $s; + $s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s; } public function testListRequiresAuth(): void { $repo = new InMemoryEventRepository(); @@ -28,8 +28,8 @@ final class DashboardTest extends TestCase public function testAcknowledgeRequiresPermissionAndSetsUser(): void { $repo = new InMemoryEventRepository(); $id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']); - $req = new Request('POST', "/events/$id/ack", [], '', [], []); - // viewer denied + $req = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'tok'], '', [], []); + // viewer denied (RBAC check fires before CSRF) $cv = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('viewer')); $this->assertSame(403, $cv->acknowledge($req, ['id' => (string)$id])->status()); // operator allowed @@ -37,6 +37,19 @@ final class DashboardTest extends TestCase $this->assertSame(200, $co->acknowledge($req, ['id' => (string)$id])->status()); $this->assertSame('u', $repo->find($id)['acknowledged_by']); } + public function testAcknowledgeRejectsMissingOrWrongCsrfToken(): void { + $repo = new InMemoryEventRepository(); + $id = $repo->insert(['severity' => 'critical', 'event_type' => 'no_motion']); + $c = new EventDetailController($repo, new InMemorySnapshotRepository(), new InMemoryAuditRepository(), $this->authed('operator')); + // missing header + $missing = new Request('POST', "/events/$id/ack", [], '', [], []); + $this->assertSame(403, $c->acknowledge($missing, ['id' => (string)$id])->status()); + // wrong token + $wrong = new Request('POST', "/events/$id/ack", ['X-Csrf-Token' => 'nope'], '', [], []); + $this->assertSame(403, $c->acknowledge($wrong, ['id' => (string)$id])->status()); + // acknowledge did not proceed + $this->assertNull($repo->find($id)['acknowledged_by']); + } public function testSnapshotProxyAuthAnd404(): void { $snaps = new InMemorySnapshotRepository(); $proxy = new SnapshotProxy($snaps, new ArraySessionStore()); // unauth diff --git a/cloud/tests/ZoneConfigTest.php b/cloud/tests/ZoneConfigTest.php index 4f07144..0141dfa 100644 --- a/cloud/tests/ZoneConfigTest.php +++ b/cloud/tests/ZoneConfigTest.php @@ -11,15 +11,15 @@ use PHPUnit\Framework\TestCase; final class ZoneConfigTest extends TestCase { private function authed(string $role = 'operator'): ArraySessionStore { - $s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); return $s; + $s = new ArraySessionStore(); $s->set('user_id', 1); $s->set('username', 'u'); $s->set('role', $role); $s->set('csrf', 'tok'); return $s; } public function testPolygonValidation(): void { $this->assertTrue(Polygon::validate([[0,0],[10,0],[10,10]])); $this->assertFalse(Polygon::validate([[0,0],[10,0]])); // <3 $this->assertFalse(Polygon::validate([[0,0],[10,0],['x',10]])); // non-numeric } - private function saveReq(array $body): Request { - return new Request('POST', '/api/zones', [], json_encode($body), [], []); + private function saveReq(array $body, array $headers = ['X-Csrf-Token' => 'tok']): Request { + return new Request('POST', '/api/zones', $headers, json_encode($body), [], []); } public function testSaveRequiresPermission(): void { $c = new ZoneConfigController(new InMemoryZoneRepository(), new InMemoryAuditRepository(), $this->authed('viewer')); @@ -38,4 +38,32 @@ final class ZoneConfigTest extends TestCase $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'BOOM', 'polygon' => [[0,0],[9,0],[9,9]]]))->status()); $this->assertSame(400, $c->save($this->saveReq(['camera_id' => 'cam-1', 'level' => 'warning', 'polygon' => [[0,0]]]))->status()); } + public function testSaveRejectsMissingOrWrongCsrf(): void { + $repo = new InMemoryZoneRepository(); + $c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator')); + $body = ['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon' => [[0,0],[9,0],[9,9]]]; + // missing header + $this->assertSame(403, $c->save($this->saveReq($body, []))->status()); + // wrong token + $this->assertSame(403, $c->save($this->saveReq($body, ['X-Csrf-Token' => 'nope']))->status()); + // save did not proceed + $this->assertCount(0, $repo->forCamera('cam-1')); + } + public function testDeleteRejectsMissingOrWrongCsrf(): void { + $repo = new InMemoryZoneRepository(); + $repo->save(['zone_id' => 'z1', 'device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']); + $c = new ZoneConfigController($repo, new InMemoryAuditRepository(), $this->authed('operator')); + // missing header + $missing = new Request('POST', '/api/zones/z1/delete', [], '', [], []); + $this->assertSame(403, $c->delete($missing, ['id' => 'z1'])->status()); + // wrong token + $wrong = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'nope'], '', [], []); + $this->assertSame(403, $c->delete($wrong, ['id' => 'z1'])->status()); + // delete did not proceed + $this->assertCount(1, $repo->forCamera('cam-1')); + // valid token proceeds + $ok = new Request('POST', '/api/zones/z1/delete', ['X-Csrf-Token' => 'tok'], '', [], []); + $this->assertSame(200, $c->delete($ok, ['id' => 'z1'])->status()); + $this->assertCount(0, $repo->forCamera('cam-1')); + } }