diff --git a/cloud/deploy/build_cafe24_package.sh b/cloud/deploy/build_cafe24_package.sh index b976737..04b3adc 100755 --- a/cloud/deploy/build_cafe24_package.sh +++ b/cloud/deploy/build_cafe24_package.sh @@ -152,6 +152,13 @@ cp "$CLOUD/deploy/preflight.php" "$WEB/_preflight.php" cp "$CLOUD/deploy/user.ini" "$WEB/.user.ini" cp "$CLOUD/deploy/README-UPLOAD.md" "$STAGE/README-UPLOAD.md" +# `cp -r` above copies whatever is in cloud/src|public — defensively strip dev/tooling caches +# so a stray one (e.g. a tool that ran in the tree) can never ship inside the web-served +# package. Legit dotfiles (.htaccess/.user.ini/.gitkeep) are named explicitly, not globbed. +find "$WEB" "$PRIV" -depth \( -name '.ruff_cache' -o -name '.impeccable' -o -name '__pycache__' \ + -o -name '.pytest_cache' -o -name '.mypy_cache' -o -name '.DS_Store' \ + -o -name '.phpunit.result.cache' -o -name 'Thumbs.db' \) -exec rm -rf {} + 2>/dev/null || true + # storage/snapshots must exist and be writable. Nothing in src/ ever creates it, and the # failure is silent rather than loud: IngestController does # `if (file_put_contents(...) === false) continue;`, so a missing dir means the snapshot is diff --git a/cloud/src/Auth/AuthController.php b/cloud/src/Auth/AuthController.php index 9382884..7d464b9 100644 --- a/cloud/src/Auth/AuthController.php +++ b/cloud/src/Auth/AuthController.php @@ -67,8 +67,11 @@ final class AuthController public function logout(SessionStore $sess): Response { - $sess->clear(); + // Rotate the id FIRST (invalidates the old cookie) while the session is still active, + // then wipe. The old order called session_regenerate_id() AFTER session_destroy(), + // which warns on an inactive session and leaves the old cookie unreplaced. $sess->regenerate(); + $sess->clear(); return Response::json(['ok' => true], 200); } } diff --git a/cloud/src/Http/Request.php b/cloud/src/Http/Request.php index 64312eb..2ef87b6 100644 --- a/cloud/src/Http/Request.php +++ b/cloud/src/Http/Request.php @@ -42,6 +42,9 @@ final class Request */ public static function pathFor(string $requestUri, string $base): string { + // Collapse leading slashes FIRST: parse_url('//api/events') reads 'api' as a + // scheme-relative host and returns '/events', silently dropping the first segment. + $requestUri = '/' . ltrim($requestUri, '/'); $path = parse_url($requestUri, PHP_URL_PATH) ?: '/'; if ($base !== '' && ($path === $base || str_starts_with($path, $base . '/'))) { // Match $base exactly or as a whole segment. A bare str_starts_with($path, $base) diff --git a/cloud/src/Ingest/EventValidator.php b/cloud/src/Ingest/EventValidator.php index 6267f65..1dde7b6 100644 --- a/cloud/src/Ingest/EventValidator.php +++ b/cloud/src/Ingest/EventValidator.php @@ -32,6 +32,13 @@ final class EventValidator if (isset($p['zone_id']) && !is_string($p['zone_id'])) $errors[] = 'bad:zone_id'; if (isset($p['duration_s']) && !is_numeric($p['duration_s'])) $errors[] = 'bad:duration_s'; + // Length caps matching the DB columns (device_id/camera_id VARCHAR(64), zone_id + // CHAR(32)). Without these an overlong id is a MySQL 'Data too long' 500 that the edge + // re-queues and retries forever; a 400 lets it drop the poison event instead. + foreach (['device_id' => 64, 'camera_id' => 64, 'zone_id' => 32] as $k => $max) { + if (isset($p[$k]) && is_string($p[$k]) && mb_strlen($p[$k]) > $max) $errors[] = "too_long:$k"; + } + if ($errors) return ['ok' => false, 'errors' => $errors, 'clean' => []]; $clean = [ diff --git a/cloud/tests/BasePathTest.php b/cloud/tests/BasePathTest.php index 18fdd2e..f51a576 100644 --- a/cloud/tests/BasePathTest.php +++ b/cloud/tests/BasePathTest.php @@ -75,6 +75,15 @@ final class BasePathTest extends TestCase $this->assertSame(['id' => '42'], json_decode($r->dispatch($detail)->body(), true)); } + public function testDoubleSlashUriIsNotMisrouted(): void + { + // A URI like '//api/events' makes parse_url treat 'api' as a host (scheme-relative), + // returning path '/events' — so '//api/events' would silently route to '/events'. + // Leading slashes must collapse to a single one. + $this->assertSame('/api/events', Request::pathFor('//api/events', '')); + $this->assertSame('/api/events', Request::pathFor('///api/events?x=1', '')); + } + public function testRouteParamsAreRawUrlDecoded(): void { // A camera_id with a space or Korean characters is %-encoded in the URL. Without diff --git a/cloud/tests/EventValidatorTest.php b/cloud/tests/EventValidatorTest.php index d1612d3..7f58677 100644 --- a/cloud/tests/EventValidatorTest.php +++ b/cloud/tests/EventValidatorTest.php @@ -15,6 +15,19 @@ final class EventValidatorTest extends TestCase $this->assertTrue($r['ok'], implode(',', $r['errors'])); $this->assertSame('fall', $r['clean']['event_type']); } + public function testRejectsOverlongIds(): void { + // An overlong id must be a 400 (invalid), not a MySQL 'Data too long' 500 that the edge + // then retries forever. Columns: device_id/camera_id VARCHAR(64), zone_id CHAR(32). + $long = $this->valid(); $long['device_id'] = str_repeat('x', 65); + $this->assertFalse(EventValidator::validate($long)['ok']); + $cam = $this->valid(); $cam['camera_id'] = str_repeat('y', 65); + $this->assertFalse(EventValidator::validate($cam)['ok']); + $zone = $this->valid(); $zone['zone_id'] = str_repeat('z', 33); + $this->assertFalse(EventValidator::validate($zone)['ok']); + // a normal-length id still passes + $ok = $this->valid(); $ok['device_id'] = str_repeat('x', 64); + $this->assertTrue(EventValidator::validate($ok)['ok']); + } public function testRejectsUnknownEventType(): void { $p = $this->valid(); $p['event_type'] = 'explode'; $this->assertFalse(EventValidator::validate($p)['ok']); diff --git a/docs/delivery-audit.md b/docs/delivery-audit.md index 1c3fe3e..50f4823 100644 --- a/docs/delivery-audit.md +++ b/docs/delivery-audit.md @@ -169,16 +169,19 @@ misconfig/degradation fail LOUD. order write→insert, log failures, reconcile orphans. - [ ] **P4-8 [L]** `cloud/src/Auth/AuthController.php:31` — login form emits a CSRF token that `login` never checks. **Fix:** verify it (or drop it). -- [ ] **P4-9 [L]** `cloud/src/Auth/AuthController.php:71` — logout calls `regenerate()` after +- [x] **P4-9 [L]** `cloud/src/Auth/AuthController.php:71` — logout calls `regenerate()` after `session_destroy()` → E_WARNING, old cookie not replaced. **Fix:** reorder. -- [ ] **P4-10 [L]** `cloud/src/Ingest/EventValidator.php:26` — no string length caps → an + DONE — logout() rotates the id then wipes (was regenerate-after-destroy). +- [x] **P4-10 [L]** `cloud/src/Ingest/EventValidator.php:26` — no string length caps → an overlong id becomes a MySQL 'Data too long' 500 retry loop instead of a 400. **Fix:** cap lengths in the validator. -- [ ] **P4-11 [L]** `cloud/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/` + DONE — EventValidator caps device_id/camera_id (64) + zone_id (32); EventValidatorTest. +- [x] **P4-11 [L]** `cloud/src/Http/Request.php:45` — `pathFor` routes any `//...` URI to `/` (parse_url scheme-relative). **Fix:** collapse/normalize. ## Phase 5 — Install / bundle / supply-chain + DONE — pathFor collapses leading slashes before parse_url; BasePathTest locks it. - [x] **P5-1 [B]** `deploy/bundle/install_offline.sh:79` — the OFFLINE installer has no backend-usability gate (setup_online's [6/8] does): it installs `POSE_BACKEND=movenet` verbatim even when the bundle carries no working backend. **Fix:** mirror the [6/8] probe. @@ -197,7 +200,7 @@ misconfig/degradation fail LOUD. `/dev/media*` and `/dev/dma_heap`; picamera2/libcamera on Pi 5 needs them, so the CSI camera is denied by the unit's own policy. **Fix:** add the device classes; verify on hardware. DONE — added `char-media` + `/dev/dma_heap/`. OFF-HW: confirm on the Pi with the CSI camera. -- [ ] **P5-6 [L]** `cloud/deploy/build_cafe24_package.sh` — dev cache dirs +- [x] **P5-6 [L]** `cloud/deploy/build_cafe24_package.sh` — dev cache dirs (`.impeccable/`, `.ruff_cache/`) can ship inside the web-served package. **Fix:** exclude. --- @@ -212,3 +215,5 @@ misconfig/degradation fail LOUD. tuning — needs on-device validation), Phase 3 (privacy — P3-2 retention needs a policy decision: how many days), Phase 4 (cloud hardening, 11 items — mostly clear), Phase 5 (P5-2..6). Nothing committed yet. + + DONE — build prunes .ruff_cache/.impeccable/__pycache__/etc from the staged package. \ No newline at end of file