feat(delivery-audit): PIPA retention + cloud hardening (P3-2, P4-1..4)

- 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Xmn8KZWqiQUguEMRtn6rW
This commit is contained in:
유창욱 2026-07-17 09:28:16 +09:00
parent 9386dfc580
commit 1187df5628
19 changed files with 193 additions and 20 deletions

View file

@ -11,4 +11,6 @@ return [
'timestamp_window' => 300, 'timestamp_window' => 300,
'rate_limit_per_min' => 60, 'rate_limit_per_min' => 60,
'ingest_ip_rate_limit_per_min' => 300, 'ingest_ip_rate_limit_per_min' => 300,
// PIPA retention: worker snapshots are purged after this many days (0 disables).
'snapshot_retention_days' => 7,
]; ];

View file

@ -148,6 +148,8 @@ cp "$CLOUD/schema.sql" "$PRIV/schema.sql"
cp "$CLOUD/deploy/htaccess.www" "$WEB/.htaccess" cp "$CLOUD/deploy/htaccess.www" "$WEB/.htaccess"
cp "$CLOUD/deploy/config.template.php" "$PRIV/config.php" cp "$CLOUD/deploy/config.template.php" "$PRIV/config.php"
cp "$CLOUD/deploy/preflight.php" "$WEB/_preflight.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" 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 # storage/snapshots must exist and be writable. Nothing in src/ ever creates it, and the

View file

@ -27,4 +27,5 @@ return [
'timestamp_window' => 300, // HMAC 허용 시간 오차(초) 'timestamp_window' => 300, // HMAC 허용 시간 오차(초)
'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한 'rate_limit_per_min' => 60, // 장비 1대당 분당 이벤트 상한
'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어) 'ingest_ip_rate_limit_per_min' => 300, // IP 1개당 분당 이벤트 상한 (DoS 방어)
'snapshot_retention_days' => 7, // 작업자 스냅샷 보존일 (PIPA; 지나면 삭제, 0=비활성)
]; ];

5
cloud/deploy/user.ini Normal file
View file

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

View file

@ -47,6 +47,21 @@ use App\Api\{ConfigController, HeartbeatController};
use App\Auth\{AuthController, CsrfController}; use App\Auth\{AuthController, CsrfController};
use App\Dashboard\{EventListController, EventDetailController, SnapshotProxy, ZoneConfigController}; 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 // 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 // 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/. // 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); $audit = new PdoAuditRepository($pdo);
$ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps); $ingest = new IngestController($cfg, $devices, $nonces, $rate, $events, $snaps);
$config = new ConfigController($devices, $zones, $thr, $nonces, $cfg); $config = new ConfigController($devices, $zones, $thr, $nonces, $cfg, $rate);
$heartbeat = new HeartbeatController($cfg, $devices, $nonces); $heartbeat = new HeartbeatController($cfg, $devices, $nonces, $rate);
$auth = new AuthController($users, $audit, $rate); $auth = new AuthController($users, $audit, $rate);
$csrfCtl = new CsrfController(); $csrfCtl = new CsrfController();
$list = new EventListController($events, $sess); $list = new EventListController($events, $sess);
@ -86,8 +101,8 @@ $render = function (string $view): Response {
$r = new Router(); $r = new Router();
$r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now, (string)($_SERVER['REMOTE_ADDR'] ?? ''))); $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('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)); $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('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', '/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('POST', '/logout', fn(Request $q) => $auth->logout($sess));

View file

@ -5,17 +5,23 @@ namespace App\Api;
use App\Config; use App\Config;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Security\Hmac; use App\Security\Hmac;
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository}; use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository, NonceRepository, RateLimiterRepository};
final class ConfigController final class ConfigController
{ {
public function __construct( public function __construct(
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds, private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds,
private NonceRepository $nonces, private Config $cfg, 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'); $deviceId = (string)$req->query('device_id');
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;
$nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? ''; $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? '';

View file

@ -5,14 +5,21 @@ namespace App\Api;
use App\Config; use App\Config;
use App\Http\{Request, Response}; use App\Http\{Request, Response};
use App\Security\Hmac; use App\Security\Hmac;
use App\Repository\{DeviceRepository, NonceRepository}; use App\Repository\{DeviceRepository, NonceRepository, RateLimiterRepository};
final class HeartbeatController 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(); $b = $req->json();
$deviceId = (string)($b['device_id'] ?? ''); $deviceId = (string)($b['device_id'] ?? '');
$secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null;

View file

@ -16,4 +16,5 @@ final class Config
public function timestampWindow(): int { return (int)($this->a['timestamp_window'] ?? 300); } 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 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 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); }
} }

View file

@ -15,7 +15,11 @@ final class Router
foreach ($this->routes as [$method, $regex, $handler]) { foreach ($this->routes as [$method, $regex, $handler]) {
if ($req->method() !== $method) continue; if ($req->method() !== $method) continue;
if (preg_match($regex, $req->path(), $m)) { 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); return $handler($req, $params);
} }
} }

View file

@ -17,7 +17,13 @@ final class IngestController
private RateLimiterRepository $rateLimiter, private RateLimiterRepository $rateLimiter,
private EventRepository $events, private EventRepository $events,
private SnapshotRepository $snapshots, 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 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; if (file_put_contents($path, $img['bytes']) === false) continue;
$this->snapshots->add($eventId, $img['uuid'], $img['hash']); $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); return Response::json(['event_id' => $eventId], 201);
} }
} }

View file

@ -29,5 +29,12 @@ final class PdoNonceRepository implements NonceRepository
{ {
$st = $this->pdo->prepare('INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)'); $st = $this->pdo->prepare('INSERT INTO nonces (nonce, expires_at) VALUES (?, ?)');
$st->execute([$nonce, $expiresAt]); $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()]);
}
} }
} }

View file

@ -27,6 +27,14 @@ final class PdoRateLimiterRepository implements RateLimiterRepository
'SELECT count FROM rate_limits WHERE rate_key = ? AND window_start = ?' 'SELECT count FROM rate_limits WHERE rate_key = ? AND window_start = ?'
); );
$select->execute([$key, $windowStart]); $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;
} }
} }

View file

@ -47,4 +47,33 @@ final class PdoSnapshotRepository implements SnapshotRepository
return rtrim($this->snapshotDir, '/\\') . '/' . $uuid . '.jpg'; 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);
}
} }

View file

@ -7,4 +7,8 @@ interface SnapshotRepository
public function add(int $eventId, string $fileUuid, string $contentHash): void; public function add(int $eventId, string $fileUuid, string $contentHash): void;
public function forEvent(int $eventId): array; public function forEvent(int $eventId): array;
public function pathByUuid(string $uuid): ?string; 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;
} }

View file

@ -5,7 +5,7 @@ use App\Config;
use App\Http\Request; use App\Http\Request;
use App\Security\Hmac; use App\Security\Hmac;
use App\Api\{ConfigController, HeartbeatController}; 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; use PHPUnit\Framework\TestCase;
final class ApiSyncTest extends 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'); $req = $this->signed('POST', '/api/heartbeat', 'sek', [], $body, 'hb-stale', '1000');
$this->assertSame(401, $c->post($req, now: 5000)->status()); // 4000s > 300 default window $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
}
} }

View file

@ -75,6 +75,19 @@ final class BasePathTest extends TestCase
$this->assertSame(['id' => '42'], json_decode($r->dispatch($detail)->body(), true)); $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 public function testFromGlobalsHonoursScriptNameWithoutTrustingClientInput(): void
{ {
$_SERVER['REQUEST_METHOD'] = 'GET'; $_SERVER['REQUEST_METHOD'] = 'GET';

View file

@ -65,6 +65,25 @@ final class IngestControllerTest extends TestCase
[$c] = $this->make([]); // no secrets [$c] = $this->make([]); // no secrets
$this->assertSame(401, $c->handle($this->signedReq('x', $this->payload()), now: 1000, clientIp: '1.2.3.4')->status()); $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 { public function testIpFloodRateLimited(): void {
// Attacker without a valid device secret floods from one IP; the pre-auth // Attacker without a valid device secret floods from one IP; the pre-auth
// IP limiter must trip BEFORE any HMAC/device-secret work, protecting // IP limiter must trip BEFORE any HMAC/device-secret work, protecting

View file

@ -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; } 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 { final class InMemorySnapshotRepository implements SnapshotRepository {
public array $rows = []; public array $paths = []; 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"; } 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 forEvent(int $e): array { return $this->rows[$e] ?? []; }
public function pathByUuid(string $u): ?string { return $this->paths[$u] ?? null; } 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 { final class InMemoryUserRepository implements UserRepository {
public function __construct(public array $users = []) {} public function __construct(public array $users = []) {}

View file

@ -112,31 +112,46 @@ misconfig/degradation fail LOUD.
- [ ] **P3-1 [H]** `edge/app.py:18` — `FaceBlurrer` blurs a fixed ~40×40px patch regardless of - [ ] **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. 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` forever; no retention/purge anywhere. PIPA violation. **Fix:** `snapshot_retention_days`
config + a token-guarded purge route + `SnapshotRepository::purgeOlderThan`. 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 - [ ] **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 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`. 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 - [ ] **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 with no checksum; spec mandates SHA256. **Fix:** pin URL to a commit + sha256+size, mirror
`fetch_hailo_hef.py`. `fetch_hailo_hef.py`.
## Phase 4 — Cloud hardening (shared hosting) ## 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). 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')`; **Fix:** global try/catch → generic 500 + `error_log`; `ini_set('display_errors','0')`;
ship `.user.ini`. 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 limiting (unlike /api/events). **Fix:** per-IP + per-device limiter, fail-open on storage
error. 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 grow without bound; no cron on cafe24. **Fix:** opportunistic in-request reap of expired
rows. 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 `camera_id` with a space/`+`/Korean saves zones but never loads them. **Fix:** decode
captured params. 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 - [ ] **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. 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 - [ ] **P4-6 [M]** `cloud/src/Repository/Pdo/PdoZoneRepository.php:86` — editing a zone with a