fix(cloud): logout order, url normalization, length caps, package hygiene (P4-9/10/11, P5-6)

- P4-9 [L] logout() rotates the session id before wiping (was regenerate-after-destroy → warning).
- P4-10 [L] EventValidator caps device_id/camera_id (64) + zone_id (32) → 400 not a MySQL
  'Data too long' 500 the edge retries forever.
- P4-11 [L] Request::pathFor collapses leading slashes so '//api/events' isn't misrouted to '/events'.
- P5-6 [L] cafe24 build prunes dev caches (.ruff_cache/.impeccable/__pycache__/…) from the package.

python + phpunit (86) green.

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:48:35 +09:00
parent 36b1f94571
commit c66b034921
7 changed files with 52 additions and 5 deletions

View file

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

View file

@ -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);
}
}

View file

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

View file

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

View file

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

View file

@ -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']);

View file

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