From 1187df5628d84f8764274e498190bb9311ef69c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Fri, 17 Jul 2026 09:28:16 +0900 Subject: [PATCH] feat(delivery-audit): PIPA retention + cloud hardening (P3-2, P4-1..4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P3-2 [H] snapshot retention (7 days): snapshot_retention_days config; SnapshotRepository::purgeOlderThan (interface + Pdo + double, files-then-rows, bounded batch); opportunistic purge in the ingest path (Cafe24 has no cron). - P4-1 [H] index.php sets display_errors=0 + a global exception handler → generic {"error":"internal"} + error_log (no DSN/db_user leak); ships www/move_detect/.user.ini. - P4-2 [H] ConfigController + HeartbeatController take an optional rate limiter + clientIp; pre-auth per-IP anti-flood → 429 (were unthrottled). - P4-3 [M] PdoNonceRepository::remember + PdoRateLimiterRepository::hit opportunistically reap expired rows (Cafe24 has no cron; tables grew unbounded). - P4-4 [M] Router::dispatch rawurldecodes captured params (Korean/space camera_id could save zones but never load them). Tests: python green; phpunit 84 green. See docs/delivery-audit.md. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW --- cloud/config.example.php | 2 ++ cloud/deploy/build_cafe24_package.sh | 2 ++ cloud/deploy/config.template.php | 1 + cloud/deploy/user.ini | 5 ++++ cloud/public/index.php | 23 ++++++++++++--- cloud/src/Api/ConfigController.php | 10 +++++-- cloud/src/Api/HeartbeatController.php | 13 +++++++-- cloud/src/Config.php | 1 + cloud/src/Http/Router.php | 6 +++- cloud/src/Ingest/IngestController.php | 14 ++++++++- .../src/Repository/Pdo/PdoNonceRepository.php | 7 +++++ .../Pdo/PdoRateLimiterRepository.php | 10 ++++++- .../Repository/Pdo/PdoSnapshotRepository.php | 29 +++++++++++++++++++ cloud/src/Repository/SnapshotRepository.php | 4 +++ cloud/tests/ApiSyncTest.php | 13 ++++++++- cloud/tests/BasePathTest.php | 13 +++++++++ cloud/tests/IngestControllerTest.php | 19 ++++++++++++ cloud/tests/Support/InMemoryRepositories.php | 16 ++++++++-- docs/delivery-audit.md | 25 ++++++++++++---- 19 files changed, 193 insertions(+), 20 deletions(-) create mode 100644 cloud/deploy/user.ini diff --git a/cloud/config.example.php b/cloud/config.example.php index 0ac493f..18bcb13 100644 --- a/cloud/config.example.php +++ b/cloud/config.example.php @@ -11,4 +11,6 @@ return [ 'timestamp_window' => 300, 'rate_limit_per_min' => 60, 'ingest_ip_rate_limit_per_min' => 300, + // PIPA retention: worker snapshots are purged after this many days (0 disables). + 'snapshot_retention_days' => 7, ]; diff --git a/cloud/deploy/build_cafe24_package.sh b/cloud/deploy/build_cafe24_package.sh index f2f0f6c..b976737 100755 --- a/cloud/deploy/build_cafe24_package.sh +++ b/cloud/deploy/build_cafe24_package.sh @@ -148,6 +148,8 @@ cp "$CLOUD/schema.sql" "$PRIV/schema.sql" cp "$CLOUD/deploy/htaccess.www" "$WEB/.htaccess" cp "$CLOUD/deploy/config.template.php" "$PRIV/config.php" cp "$CLOUD/deploy/preflight.php" "$WEB/_preflight.php" +# .user.ini: display_errors=Off backstop for pre-parse errors (Cafe24 often ships On). +cp "$CLOUD/deploy/user.ini" "$WEB/.user.ini" cp "$CLOUD/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md" # storage/snapshots must exist and be writable. Nothing in src/ ever creates it, and the diff --git a/cloud/deploy/config.template.php b/cloud/deploy/config.template.php index 9dc1288..85ef22f 100644 --- a/cloud/deploy/config.template.php +++ b/cloud/deploy/config.template.php @@ -27,4 +27,5 @@ return [ 'timestamp_window' => 300, // HMAC 허용 시간 오차(초) 'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한 'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어) + 'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성) ]; diff --git a/cloud/deploy/user.ini b/cloud/deploy/user.ini new file mode 100644 index 0000000..4eca833 --- /dev/null +++ b/cloud/deploy/user.ini @@ -0,0 +1,5 @@ +; Shipped as www/move_detect/.user.ini (Cafe24 honors per-directory .user.ini). +; Backstops index.php's ini_set for errors raised before the script parses/runs, so a stack +; trace with the DB DSN/user can never reach the browser on a shared host that ships On. +display_errors = Off +log_errors = On diff --git a/cloud/public/index.php b/cloud/public/index.php index 00e082d..2859c95 100644 --- a/cloud/public/index.php +++ b/cloud/public/index.php @@ -47,6 +47,21 @@ use App\Api\{ConfigController, HeartbeatController}; use App\Auth\{AuthController, CsrfController}; use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController}; +// Never leak internals to the client. Cafe24 shared hosting often ships display_errors=On, so +// an uncaught PDOException would otherwise render the DSN + db_user in a raw 500. Turn it off +// and convert any uncaught Throwable into a generic 500 that is logged, not shown. (A .user.ini +// in the docroot backstops this for errors raised before this file even parses.) +ini_set('display_errors', '0'); +ini_set('log_errors', '1'); +set_exception_handler(static function (\Throwable $e): void { + error_log('move-detect: ' . $e); + if (!headers_sent()) { + http_response_code(500); + header('Content-Type: application/json'); + } + echo '{"error":"internal"}'; +}); + // The URL prefix this app is mounted under: '' at the web root, '/move_detect' in a // subdirectory. Server-derived (SCRIPT_NAME), never client input. Views and the JS bundles // prefix every link/fetch with it; without it they'd address the other site sharing www/. @@ -70,8 +85,8 @@ $thr = new PdoThresholdRepository($pdo); $audit = new PdoAuditRepository($pdo); $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); -$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg); -$heartbeat = new HeartbeatController($cfg, $devices, $nonces); +$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate); +$heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate); $auth = new AuthController($users, $audit, $rate); $csrfCtl = new CsrfController(); $list = new EventListController($events, $sess); @@ -86,8 +101,8 @@ $render = function (string $view): Response { $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, $now)); +$r->add('POST', '/api/heartbeat', fn(Request $q) => $heartbeat->post($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); +$r->add('GET', '/api/config', fn(Request $q) => $config->get($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $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)); diff --git a/cloud/src/Api/ConfigController.php b/cloud/src/Api/ConfigController.php index 785a628..0c46646 100644 --- a/cloud/src/Api/ConfigController.php +++ b/cloud/src/Api/ConfigController.php @@ -5,17 +5,23 @@ namespace App\Api; use App\Config; use App\Http\{Request, Response}; use App\Security\Hmac; -use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository}; +use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository}; final class ConfigController { public function __construct( private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds, private NonceRepository $nonces, private Config $cfg, + private ?RateLimiterRepository $rateLimiter = null, ) {} - public function get(Request $req, int $now): Response + public function get(Request $req, int $now, string $clientIp = ''): Response { + // Pre-auth per-IP anti-flood, like /api/events — HMAC + DB reads on every hit. + if ($this->rateLimiter !== null && $clientIp !== '' + && $this->rateLimiter->hit('ip:' . $clientIp, intdiv($now, 60)) > $this->cfg->ingestIpRateLimitPerMin()) { + return Response::json(['error' => 'rate_limited'], 429); + } $deviceId = (string)$req->query('device_id'); $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? ''; diff --git a/cloud/src/Api/HeartbeatController.php b/cloud/src/Api/HeartbeatController.php index 7a4c404..4e974dc 100644 --- a/cloud/src/Api/HeartbeatController.php +++ b/cloud/src/Api/HeartbeatController.php @@ -5,14 +5,21 @@ namespace App\Api; use App\Config; use App\Http\{Request, Response}; use App\Security\Hmac; -use App\Repository\{DeviceRepository, NonceRepository}; +use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository}; final class HeartbeatController { - public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces) {} + public function __construct(private Config $cfg, private DeviceRepository $devices, private NonceRepository $nonces, + private ?RateLimiterRepository $rateLimiter = null) {} - public function post(Request $req, int $now): Response + public function post(Request $req, int $now, string $clientIp = ''): Response { + // Pre-auth per-IP anti-flood, like /api/events — an unauthenticated attacker must not + // be able to hammer this endpoint (each hit does HMAC + a DB write) unthrottled. + if ($this->rateLimiter !== null && $clientIp !== '' + && $this->rateLimiter->hit('ip:' . $clientIp, intdiv($now, 60)) > $this->cfg->ingestIpRateLimitPerMin()) { + return Response::json(['error' => 'rate_limited'], 429); + } $b = $req->json(); $deviceId = (string)($b['device_id'] ?? ''); $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; diff --git a/cloud/src/Config.php b/cloud/src/Config.php index 4aa11c2..d56ed72 100644 --- a/cloud/src/Config.php +++ b/cloud/src/Config.php @@ -16,4 +16,5 @@ final class Config public function timestampWindow(): int { return (int)($this->a['timestamp_window'] ?? 300); } public function rateLimitPerMin(): int { return (int)($this->a['rate_limit_per_min'] ?? 60); } public function ingestIpRateLimitPerMin(): int { return (int)($this->a['ingest_ip_rate_limit_per_min'] ?? 300); } + public function snapshotRetentionDays(): int { return (int)($this->a['snapshot_retention_days'] ?? 7); } } diff --git a/cloud/src/Http/Router.php b/cloud/src/Http/Router.php index 6065f5d..f17971b 100644 --- a/cloud/src/Http/Router.php +++ b/cloud/src/Http/Router.php @@ -15,7 +15,11 @@ final class Router foreach ($this->routes as [$method, $regex, $handler]) { if ($req->method() !== $method) continue; if (preg_match($regex, $req->path(), $m)) { - $params = array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY); + // Decode captured params: a camera_id/zone_id with a space or Korean text is + // %-encoded in the URL, so without this GET /api/zones/{camera} never matches + // what POST /api/zones stored. + $params = array_map('rawurldecode', + array_filter($m, 'is_string', ARRAY_FILTER_USE_KEY)); return $handler($req, $params); } } diff --git a/cloud/src/Ingest/IngestController.php b/cloud/src/Ingest/IngestController.php index 887d7a8..113dd6c 100644 --- a/cloud/src/Ingest/IngestController.php +++ b/cloud/src/Ingest/IngestController.php @@ -17,7 +17,13 @@ final class IngestController private RateLimiterRepository $rateLimiter, private EventRepository $events, private SnapshotRepository $snapshots, - ) {} + // Opportunistic retention runs here because Cafe24 shared hosting has no cron, and the + // edge POSTs to this route 24/7 — so this is the one path that fires automatically. The + // gate keeps it rare (a purge query per ~1/200 ingests); tests inject `fn() => true`. + private ?\Closure $purgeGate = null, + ) { + $this->purgeGate ??= static fn (): bool => mt_rand(1, 200) === 1; + } public function handle(Request $req, int $now, string $clientIp): Response { @@ -68,6 +74,12 @@ final class IngestController if (file_put_contents($path, $img['bytes']) === false) continue; $this->snapshots->add($eventId, $img['uuid'], $img['hash']); } + // PIPA retention: purge worker snapshots older than the configured window. Runs rarely + // (the gate) and off the critical path — the event is already stored. + $days = $this->cfg->snapshotRetentionDays(); + if ($days > 0 && ($this->purgeGate)()) { + $this->snapshots->purgeOlderThan($now - $days * 86400); + } return Response::json(['event_id' => $eventId], 201); } } diff --git a/cloud/src/Repository/Pdo/PdoNonceRepository.php b/cloud/src/Repository/Pdo/PdoNonceRepository.php index 67ca49f..faaa9ac 100644 --- a/cloud/src/Repository/Pdo/PdoNonceRepository.php +++ b/cloud/src/Repository/Pdo/PdoNonceRepository.php @@ -29,5 +29,12 @@ final class PdoNonceRepository implements NonceRepository { $st = $this->pdo->prepare('INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)'); $st->execute([$nonce, $expiresAt]); + // Cron-less reap: Cafe24 has no scheduler, so expired nonces accumulate forever until + // the table bloats and inserts slow/fail. Opportunistically prune a bounded batch of + // already-expired rows (safe: seen() only cares about unexpired ones). + if (mt_rand(1, 100) === 1) { + $del = $this->pdo->prepare('DELETE FROM nonces WHERE expires_at < ? LIMIT 5000'); + $del->execute([time()]); + } } } diff --git a/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php b/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php index b0bba5f..7a34ea2 100644 --- a/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php +++ b/cloud/src/Repository/Pdo/PdoRateLimiterRepository.php @@ -27,6 +27,14 @@ final class PdoRateLimiterRepository implements RateLimiterRepository 'SELECT count FROM rate_limits WHERE rate_key = ? AND window_start = ?' ); $select->execute([$key, $windowStart]); - return (int)$select->fetchColumn(); + $count = (int)$select->fetchColumn(); + + // Cron-less reap (Cafe24 has no scheduler): stale windows accumulate forever. Prune a + // bounded batch of windows older than the current one (kept a couple back for safety). + if (mt_rand(1, 100) === 1) { + $del = $this->pdo->prepare('DELETE FROM rate_limits WHERE window_start < ? LIMIT 5000'); + $del->execute([$windowStart - 2]); + } + return $count; } } diff --git a/cloud/src/Repository/Pdo/PdoSnapshotRepository.php b/cloud/src/Repository/Pdo/PdoSnapshotRepository.php index ea04bf5..18bac0d 100644 --- a/cloud/src/Repository/Pdo/PdoSnapshotRepository.php +++ b/cloud/src/Repository/Pdo/PdoSnapshotRepository.php @@ -47,4 +47,33 @@ final class PdoSnapshotRepository implements SnapshotRepository return rtrim($this->snapshotDir, '/\\') . '/' . $uuid . '.jpg'; } + + public function purgeOlderThan(int $cutoff, int $limit = 500): int + { + // Bounded batch: a single request must not stall unlinking thousands of files on a + // shared host. Repeated ingests drain the backlog. Files first, then rows — an orphan + // row (file gone, row left) is harmless; an orphan file (row gone, file left) is the + // PIPA leak we are closing, so never delete the row before its file. + $sel = $this->pdo->prepare( + 'SELECT snapshot_id, file_uuid FROM event_snapshots WHERE created_at < ? LIMIT ?'); + $sel->bindValue(1, $cutoff, PDO::PARAM_INT); + $sel->bindValue(2, $limit, PDO::PARAM_INT); + $sel->execute(); + $rows = $sel->fetchAll(); + if (!$rows) { + return 0; + } + $ids = []; + foreach ($rows as $r) { + $path = rtrim($this->snapshotDir, '/\\') . '/' . $r['file_uuid'] . '.jpg'; + if (is_file($path)) { + @unlink($path); + } + $ids[] = (int)$r['snapshot_id']; + } + $in = implode(',', array_fill(0, count($ids), '?')); + $del = $this->pdo->prepare("DELETE FROM event_snapshots WHERE snapshot_id IN ($in)"); + $del->execute($ids); + return count($ids); + } } diff --git a/cloud/src/Repository/SnapshotRepository.php b/cloud/src/Repository/SnapshotRepository.php index 0cf5fa3..5fccdfd 100644 --- a/cloud/src/Repository/SnapshotRepository.php +++ b/cloud/src/Repository/SnapshotRepository.php @@ -7,4 +7,8 @@ interface SnapshotRepository public function add(int $eventId, string $fileUuid, string $contentHash): void; public function forEvent(int $eventId): array; public function pathByUuid(string $uuid): ?string; + + /** Delete snapshot files + rows created before $cutoff (unix ts). PIPA retention: + * worker images must not be kept forever. Returns the number purged. */ + public function purgeOlderThan(int $cutoff): int; } diff --git a/cloud/tests/ApiSyncTest.php b/cloud/tests/ApiSyncTest.php index 761f411..0fc50ee 100644 --- a/cloud/tests/ApiSyncTest.php +++ b/cloud/tests/ApiSyncTest.php @@ -5,7 +5,7 @@ use App\Config; use App\Http\Request; use App\Security\Hmac; use App\Api\{ConfigController, HeartbeatController}; -use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository, InMemoryNonceRepository}; +use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository, InMemoryNonceRepository, InMemoryRateLimiter}; use PHPUnit\Framework\TestCase; final class ApiSyncTest extends TestCase @@ -85,4 +85,15 @@ final class ApiSyncTest extends TestCase $req = $this->signed('POST', '/api/heartbeat', 'sek', [], $body, 'hb-stale', '1000'); $this->assertSame(401, $c->post($req, now: 5000)->status()); // 4000s > 300 default window } + public function testHeartbeatIpFloodRateLimited(): void { + // An unauthenticated attacker flooding /api/heartbeat from one IP must be throttled + // pre-auth, before the HMAC + DB write on every hit (was: no limit at all). + $cfg = Config::fromArray(['ingest_ip_rate_limit_per_min' => 2]); + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); + $c = new HeartbeatController($cfg, $dev, new InMemoryNonceRepository(), new InMemoryRateLimiter()); + $garbage = new Request('POST', '/api/heartbeat', [], 'not-json', [], []); + $this->assertSame(401, $c->post($garbage, now: 1000, clientIp: '9.9.9.9')->status()); + $this->assertSame(401, $c->post($garbage, now: 1000, clientIp: '9.9.9.9')->status()); + $this->assertSame(429, $c->post($garbage, now: 1000, clientIp: '9.9.9.9')->status()); // 3rd from same IP + } } diff --git a/cloud/tests/BasePathTest.php b/cloud/tests/BasePathTest.php index 9f5390f..18fdd2e 100644 --- a/cloud/tests/BasePathTest.php +++ b/cloud/tests/BasePathTest.php @@ -75,6 +75,19 @@ final class BasePathTest extends TestCase $this->assertSame(['id' => '42'], json_decode($r->dispatch($detail)->body(), true)); } + public function testRouteParamsAreRawUrlDecoded(): void + { + // A camera_id with a space or Korean characters is %-encoded in the URL. Without + // decoding the captured param, GET /api/zones/{camera} can never match what + // POST /api/zones saved — zones save but never load back. + $r = new Router(); + $r->add('GET', '/api/zones/{camera}', fn($q, $p) => Response::json(['camera' => $p['camera']])); + $res = $r->dispatch(new Request('GET', '/api/zones/cam%20A', [], '')); + $this->assertSame('cam A', json_decode($res->body(), true)['camera']); + $res2 = $r->dispatch(new Request('GET', '/api/zones/%EA%B0%80', [], '')); // "가" + $this->assertSame('가', json_decode($res2->body(), true)['camera']); + } + public function testFromGlobalsHonoursScriptNameWithoutTrustingClientInput(): void { $_SERVER['REQUEST_METHOD'] = 'GET'; diff --git a/cloud/tests/IngestControllerTest.php b/cloud/tests/IngestControllerTest.php index 7355c65..321a31f 100644 --- a/cloud/tests/IngestControllerTest.php +++ b/cloud/tests/IngestControllerTest.php @@ -65,6 +65,25 @@ final class IngestControllerTest extends TestCase [$c] = $this->make([]); // no secrets $this->assertSame(401, $c->handle($this->signedReq('x', $this->payload()), now: 1000, clientIp: '1.2.3.4')->status()); } + public function testRetentionPurgesOldSnapshots(): void { + // PIPA: worker snapshots older than the retention window are purged; recent ones stay. + $cfg = Config::fromArray(['snapshot_dir' => $this->dir, 'timestamp_window' => 300, + 'snapshot_retention_days' => 7]); + $snaps = new InMemorySnapshotRepository(); + $now = 1_000_000; + $old = str_repeat('a', 32); $recent = str_repeat('b', 32); + $snaps->add(1, $old, 'h'); $snaps->created[$old] = $now - 8 * 86400; // 8d old -> purge + $snaps->add(1, $recent, 'h'); $snaps->created[$recent] = $now - 1 * 86400; // 1d old -> keep + $c = new IngestController($cfg, new InMemoryDeviceRepository(['pi-1' => 'sekret']), + new InMemoryNonceRepository(), new InMemoryRateLimiter(), + new InMemoryEventRepository(), $snaps, purgeGate: fn () => true); + $req = $this->signedReq('sekret', $this->payload(), ts: (string)$now); + $res = $c->handle($req, now: $now, clientIp: '1.2.3.4'); + $this->assertSame(201, $res->status()); + $this->assertArrayNotHasKey($old, $snaps->created, 'snapshot past the 7-day window must be purged'); + $this->assertArrayHasKey($recent, $snaps->created, 'a 1-day-old snapshot must survive'); + } + public function testIpFloodRateLimited(): void { // Attacker without a valid device secret floods from one IP; the pre-auth // IP limiter must trip BEFORE any HMAC/device-secret work, protecting diff --git a/cloud/tests/Support/InMemoryRepositories.php b/cloud/tests/Support/InMemoryRepositories.php index 669e16d..68fa2ad 100644 --- a/cloud/tests/Support/InMemoryRepositories.php +++ b/cloud/tests/Support/InMemoryRepositories.php @@ -33,10 +33,22 @@ final class InMemoryEventRepository implements EventRepository { public function acknowledge(int $id, string $u): bool { if (!isset($this->rows[$id])) return false; $this->rows[$id]['acknowledged_by'] = $u; return true; } } final class InMemorySnapshotRepository implements SnapshotRepository { - public array $rows = []; public array $paths = []; - public function add(int $e, string $uuid, string $hash): void { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; } + public array $rows = []; public array $paths = []; public array $created = []; + public function add(int $e, string $uuid, string $hash): void { $this->rows[$e][] = ['file_uuid' => $uuid, 'content_hash' => $hash]; $this->paths[$uuid] = "/tmp/$uuid.jpg"; $this->created[$uuid] = time(); } public function forEvent(int $e): array { return $this->rows[$e] ?? []; } public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; } + public function purgeOlderThan(int $cutoff): int { + $n = 0; + foreach ($this->created as $uuid => $ts) { + if ($ts >= $cutoff) continue; + unset($this->created[$uuid], $this->paths[$uuid]); + foreach ($this->rows as $e => $list) { + $this->rows[$e] = array_values(array_filter($list, fn($r) => $r['file_uuid'] !== $uuid)); + } + $n++; + } + return $n; + } } final class InMemoryUserRepository implements UserRepository { public function __construct(public array $users = []) {} diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index 65aecea..74e4b8a 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -112,31 +112,46 @@ misconfig/degradation fail LOUD. - [ ] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of face size; close faces stay identifiable. **Fix:** size the blur box from the person bbox. -- [ ] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored +- [x] **P3-2 [H]** `cloud/src/Ingest/IngestController.php:68` — worker snapshots are stored forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days` config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`. + DONE (retention = **7 days**) — `snapshot_retention_days` config; `purgeOlderThan` on the + interface + Pdo + double (files then rows, bounded batch); opportunistic purge in the + ingest path (cafe24 has no cron; the edge POSTs 24/7). IngestControllerTest locks it. - [ ] **P3-2b [M]** `cloud/src/Dashboard/EventListController.php:17` — `site_scope` is stored in the session but never enforced; any authenticated user sees every device's worker snapshots. **Fix:** scope event/snapshot queries by the session's `site_scope`. + NEEDS A DECISION — the schema has `users.site_scope` (free string) but `devices` has NO site + column and `events` link only by `device_id`. Enforcing scope needs a site↔device model + (e.g. add `devices.site`, filter events by the caller's site). Deferred until that is + decided. Interim mitigation: keep every dashboard account admin/single-site so scope is moot. - [ ] **P3-3 [H]** `edge/tools/fetch_movenet.py:12` — downloads the ONNX from a mutable ref with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror `fetch_hailo_hef.py`. ## Phase 4 — Cloud hardening (shared hosting) -- [ ] **P4-1 [H]** `cloud/public/index.php:106` — no top-level try/catch; a PDOException +- [x] **P4-1 [H]** `cloud/public/index.php:106` — no top-level try/catch; a PDOException becomes a raw 500 that leaks DSN/db_user if `display_errors` is on (cafe24 often ships On). **Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`; ship `.user.ini`. -- [ ] **P4-2 [H]** `cloud/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate + DONE — index.php sets display_errors=0 + a set_exception_handler → generic {"error":"internal"} + + error_log; a `.user.ini` (display_errors=Off) ships in www/move_detect/ as the pre-parse + backstop. Package build verified. +- [x] **P4-2 [H]** `cloud/src/Api/ConfigController.php:22`, `HeartbeatController.php` — no rate limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage error. -- [ ] **P4-3 [M]** `cloud/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits + DONE — both take an optional RateLimiterRepository + clientIp; pre-auth per-IP limit + (shared ingest_ip cap) returns 429; index.php passes REMOTE_ADDR. ApiSyncTest locks the flood. +- [x] **P4-3 [M]** `cloud/src/Repository/Pdo/PdoNonceRepository.php:30` — nonces + rate_limits grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired rows. -- [ ] **P4-4 [M]** `cloud/src/Http/Router.php:18` — route params are never `rawurldecode`d; a + DONE — remember() reaps expired nonces; hit() reaps stale rate-limit windows (bounded, + gated 1/100). Full PHP suite green. +- [x] **P4-4 [M]** `cloud/src/Http/Router.php:18` — route params are never `rawurldecode`d; a `camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode captured params. + DONE — dispatch rawurldecodes captured params; BasePathTest locks space + Korean cases. - [ ] **P4-5 [M]** `cloud/src/Dashboard/EventListController.php:17` — event list hard-capped at newest 100, no pagination/date filter; older events unreachable. **Fix:** cursor pagination. - [ ] **P4-6 [M]** `cloud/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a