feat(devices): X-Just-Provisioned gate so reset devices can't leak prior owner's photos
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Pairs with the firmware change of the same scope. Server-side contract:
- X-Just-Provisioned: 1 + binding older than 5 min → 204 (the device
paints its setup QR) regardless of any approved images on the
seller's account. No image content leaks.
- X-Just-Provisioned: 1 + binding fresher than 5 min → normal
response (200/304/204), with X-Claimed: 1 stamped so the firmware
clears its NVS flag and returns to standard operation.
- No header → no special behavior. Long-stable devices unaffected.
This is what makes the BOOT-5s reset actually safe to use as the
"factory reset" gesture: now it forces a real re-claim cycle, instead
of silently inheriting the prior owner's content because the MAC is
still bound on the server.
3 functional tests: stale-binding 204, fresh-binding 200 + X-Claimed,
and absence-of-header preserves legacy behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -52,6 +52,9 @@ class DeviceImageController extends AbstractController
|
|||||||
return $device->getRotationIntervalMinutes() * 60 * 1000;
|
return $device->getRotationIntervalMinutes() * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recent-claim window — see image() for the full semantics. */
|
||||||
|
private const FRESH_CLAIM_WINDOW_SECONDS = 300;
|
||||||
|
|
||||||
#[Route('/api/device/{mac}/image', name: 'api_device_image', methods: ['GET'])]
|
#[Route('/api/device/{mac}/image', name: 'api_device_image', methods: ['GET'])]
|
||||||
public function image(string $mac, Request $request, EntityManagerInterface $em): Response
|
public function image(string $mac, Request $request, EntityManagerInterface $em): Response
|
||||||
{
|
{
|
||||||
@@ -61,6 +64,27 @@ class DeviceImageController extends AbstractController
|
|||||||
return new Response(null, Response::HTTP_NOT_FOUND);
|
return new Response(null, Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The firmware sends X-Just-Provisioned: 1 on every poll until it
|
||||||
|
// sees X-Claimed: 1 in a response. The flag survives only inside the
|
||||||
|
// device's NVS — it gets set at WiFi-setup completion (after a BOOT-5s
|
||||||
|
// wipe or first-ever boot) and only ever clears once the server has
|
||||||
|
// affirmatively acknowledged the user has linked the device.
|
||||||
|
//
|
||||||
|
// Why we need this: NVS-wiping the device gives the firmware no way
|
||||||
|
// to tell the server "I just got reset." So if a frame is sold and
|
||||||
|
// the seller forgot to "Remove this frame" via the PWA, the buyer's
|
||||||
|
// first poll after WiFi setup would otherwise return 200 with the
|
||||||
|
// seller's image — leaking the seller's photos onto the buyer's
|
||||||
|
// panel until the buyer happens to navigate to /setup/{mac}.
|
||||||
|
//
|
||||||
|
// With this header, the server short-circuits to 204 (which the
|
||||||
|
// firmware paints as the setup QR) when the binding looks stale.
|
||||||
|
// Once the user re-links via /setup/{mac}, linkedAt becomes recent
|
||||||
|
// and we transition out of the gate.
|
||||||
|
$justProvisioned = $request->headers->get('X-Just-Provisioned') === '1';
|
||||||
|
$linkedAtAge = (new \DateTimeImmutable())->getTimestamp() - $device->getLinkedAt()->getTimestamp();
|
||||||
|
$bindingIsFresh = $linkedAtAge <= self::FRESH_CLAIM_WINDOW_SECONDS;
|
||||||
|
|
||||||
$intervalMs = $this->computeIntervalMs($device);
|
$intervalMs = $this->computeIntervalMs($device);
|
||||||
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
|
$currentImageId = (int) $request->headers->get('X-Current-Image-Id', '-1');
|
||||||
$device->markSeen();
|
$device->markSeen();
|
||||||
@@ -83,6 +107,29 @@ class DeviceImageController extends AbstractController
|
|||||||
// the second flush triggers a second publish to keep it accurate.
|
// the second flush triggers a second publish to keep it accurate.
|
||||||
$this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));
|
$this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));
|
||||||
|
|
||||||
|
// The fresh-provisioning gate: refuse to serve images for stale
|
||||||
|
// bindings. Forces the device to display the setup QR (its 204 path)
|
||||||
|
// until the user re-links via /setup/{mac}.
|
||||||
|
if ($justProvisioned && !$bindingIsFresh) {
|
||||||
|
$this->logger->info('device.poll.awaiting_claim', [
|
||||||
|
'device_id' => $device->getId(),
|
||||||
|
'mac' => $mac,
|
||||||
|
'linked_age_sec' => $linkedAtAge,
|
||||||
|
]);
|
||||||
|
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
||||||
|
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||||
|
return $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the gate: any response we build below should tell the device
|
||||||
|
// it's been acknowledged so it can clear its just-provisioned flag.
|
||||||
|
$stampClaimed = static function (Response $r) use ($justProvisioned, $bindingIsFresh): Response {
|
||||||
|
if ($justProvisioned && $bindingIsFresh) {
|
||||||
|
$r->headers->set('X-Claimed', '1');
|
||||||
|
}
|
||||||
|
return $r;
|
||||||
|
};
|
||||||
|
|
||||||
// Locked image bypasses rotation entirely. Otherwise, advance only
|
// Locked image bypasses rotation entirely. Otherwise, advance only
|
||||||
// when the device's configured schedule says it's due — except a
|
// when the device's configured schedule says it's due — except a
|
||||||
// cold-boot poll (X-Boot-Reason: cold) is treated as a deliberate
|
// cold-boot poll (X-Boot-Reason: cold) is treated as a deliberate
|
||||||
@@ -108,7 +155,7 @@ class DeviceImageController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
||||||
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||||
return $r;
|
return $stampClaimed($r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Asset lookup is needed before the 304 check so we can compare its
|
// Asset lookup is needed before the 304 check so we can compare its
|
||||||
@@ -131,7 +178,7 @@ class DeviceImageController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
||||||
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||||
return $r;
|
return $stampClaimed($r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 304: device already has this image — skip the binary transfer and redraw.
|
// 304: device already has this image — skip the binary transfer and redraw.
|
||||||
@@ -160,7 +207,7 @@ class DeviceImageController extends AbstractController
|
|||||||
$r = new Response(null, Response::HTTP_NOT_MODIFIED);
|
$r = new Response(null, Response::HTTP_NOT_MODIFIED);
|
||||||
$r->headers->set('X-Image-Id', (string) $image->getId());
|
$r->headers->set('X-Image-Id', (string) $image->getId());
|
||||||
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||||
return $r;
|
return $stampClaimed($r);
|
||||||
}
|
}
|
||||||
|
|
||||||
$binPath = $this->projectDir . '/' . $asset->getFilePath();
|
$binPath = $this->projectDir . '/' . $asset->getFilePath();
|
||||||
@@ -173,7 +220,7 @@ class DeviceImageController extends AbstractController
|
|||||||
]);
|
]);
|
||||||
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
$r = new Response(null, Response::HTTP_NO_CONTENT);
|
||||||
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
$r->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||||
return $r;
|
return $stampClaimed($r);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record what the device is now showing, plus the orientation and
|
// Record what the device is now showing, plus the orientation and
|
||||||
@@ -215,6 +262,6 @@ class DeviceImageController extends AbstractController
|
|||||||
$response->headers->set('X-Image-Sha256', hash_file('sha256', $binPath));
|
$response->headers->set('X-Image-Sha256', hash_file('sha256', $binPath));
|
||||||
$response->headers->set('X-Interval-Ms', (string) $intervalMs);
|
$response->headers->set('X-Interval-Ms', (string) $intervalMs);
|
||||||
|
|
||||||
return $response;
|
return $stampClaimed($response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -478,6 +478,63 @@ class DeviceImageControllerTest extends AppWebTestCase
|
|||||||
$this->assertResponseStatusCodeSame(304);
|
$this->assertResponseStatusCodeSame(304);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── X-Just-Provisioned gate ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The device sets a "just provisioned" flag in NVS at WiFi-setup time
|
||||||
|
// and sends X-Just-Provisioned: 1 on every poll until it sees X-Claimed
|
||||||
|
// back. The server's contract:
|
||||||
|
//
|
||||||
|
// - Header set + binding is stale → 204 (force the device to its
|
||||||
|
// setup-QR screen instead of leaking the prior owner's photo).
|
||||||
|
// - Header set + binding is fresh → normal response + X-Claimed: 1
|
||||||
|
// so the firmware clears the flag and resumes regular operation.
|
||||||
|
// - Header absent → no special behavior (legacy + steady-state polls).
|
||||||
|
|
||||||
|
public function test_just_provisioned_with_stale_binding_returns_204(): void
|
||||||
|
{
|
||||||
|
$setup = $this->createTestSetup();
|
||||||
|
$device = $setup['device'];
|
||||||
|
|
||||||
|
// Backdate linkedAt to look stale (older than the FRESH_CLAIM window).
|
||||||
|
$ref = new \ReflectionProperty(\App\Entity\Device::class, 'linkedAt');
|
||||||
|
$ref->setAccessible(true);
|
||||||
|
$ref->setValue($device, new \DateTimeImmutable('-1 hour'));
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
||||||
|
'HTTP_X_Just_Provisioned' => '1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(204, 'stale binding + just-provisioned must NOT serve the prior owner\'s image');
|
||||||
|
$this->assertFalse(
|
||||||
|
$this->client->getResponse()->headers->has('X-Claimed'),
|
||||||
|
'X-Claimed must not be sent until the binding is fresh',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_just_provisioned_with_fresh_binding_serves_with_X_Claimed(): void
|
||||||
|
{
|
||||||
|
$setup = $this->createTestSetup();
|
||||||
|
// createTestSetup() doesn't backdate linkedAt — it's now-ish, well
|
||||||
|
// within the FRESH_CLAIM window — so this exercises the post-claim
|
||||||
|
// transition branch.
|
||||||
|
|
||||||
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
||||||
|
'HTTP_X_Just_Provisioned' => '1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertSame('1', $this->client->getResponse()->headers->get('X-Claimed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_no_just_provisioned_header_means_no_X_Claimed_response_header(): void
|
||||||
|
{
|
||||||
|
$this->createTestSetup();
|
||||||
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertFalse($this->client->getResponse()->headers->has('X-Claimed'));
|
||||||
|
}
|
||||||
|
|
||||||
// Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path)
|
// Returns 204 when RenderedAsset has Ready status but filePath is null (device.poll.no_asset path)
|
||||||
public function test_returns_204_when_ready_asset_has_null_file_path(): void
|
public function test_returns_204_when_ready_asset_has_null_file_path(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user