fix(cloud): move ingest per-device rate limit post-auth, add pre-auth IP limit (H1)
An unauthenticated attacker could guess a real device_id and 429-lock that camera by hammering /api/events before HMAC verification ran, dropping its accident events. Add a cheap pre-auth per-IP limiter (Config::ingestIpRateLimitPerMin, default 300/min) that fires before any device-secret lookup, and move the existing per-device limiter to run only after HMAC + timestamp + nonce checks succeed, so only authenticated requests consume a device's own quota.
This commit is contained in:
parent
c1fb22ee16
commit
7828f9c419
4 changed files with 39 additions and 9 deletions
|
|
@ -44,7 +44,7 @@ $render = function (string $view): Response {
|
||||||
};
|
};
|
||||||
|
|
||||||
$r = new Router();
|
$r = new Router();
|
||||||
$r->add('POST', '/api/events', fn(Request $q) => $ingest->handle($q, $now));
|
$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));
|
||||||
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q));
|
$r->add('GET', '/api/config', fn(Request $q) => $config->get($q));
|
||||||
$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now));
|
$r->add('POST', '/login', fn(Request $q) => $auth->login($q, $sess, $now));
|
||||||
|
|
|
||||||
|
|
@ -15,4 +15,5 @@ final class Config
|
||||||
public function nonceTtl(): int { return (int)($this->a['nonce_ttl'] ?? 86400); }
|
public function nonceTtl(): int { return (int)($this->a['nonce_ttl'] ?? 86400); }
|
||||||
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); }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,16 @@ final class IngestController
|
||||||
private SnapshotRepository $snapshots,
|
private SnapshotRepository $snapshots,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function handle(Request $req, int $now): Response
|
public function handle(Request $req, int $now, string $clientIp): Response
|
||||||
{
|
{
|
||||||
$payload = $req->json();
|
$payload = $req->json();
|
||||||
$deviceId = (string)($payload['device_id'] ?? '');
|
$deviceId = (string)($payload['device_id'] ?? '');
|
||||||
$window = intdiv($now, 60);
|
$window = intdiv($now, 60);
|
||||||
if ($deviceId !== '' && $this->rateLimiter->hit($deviceId, $window) > $this->cfg->rateLimitPerMin()) {
|
|
||||||
|
// Pre-auth IP limit (cheap, anti-flood): guards the endpoint before any
|
||||||
|
// HMAC/device-secret work, so an unauthenticated attacker can't burn
|
||||||
|
// a real device's own quota (see per-device limit below, moved post-auth).
|
||||||
|
if ($clientIp !== '' && $this->rateLimiter->hit('ip:' . $clientIp, $window) > $this->cfg->ingestIpRateLimitPerMin()) {
|
||||||
return Response::json(['error' => 'rate_limited'], 429);
|
return Response::json(['error' => 'rate_limited'], 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +50,13 @@ final class IngestController
|
||||||
}
|
}
|
||||||
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
|
$this->nonces->remember($nonce, $now + $this->cfg->nonceTtl());
|
||||||
|
|
||||||
|
// Post-auth per-device limit: only authenticated (HMAC-verified) requests
|
||||||
|
// count toward a device's quota, so an attacker guessing device_id can no
|
||||||
|
// longer 429-lock a real camera.
|
||||||
|
if ($this->rateLimiter->hit($deviceId, $window) > $this->cfg->rateLimitPerMin()) {
|
||||||
|
return Response::json(['error' => 'rate_limited'], 429);
|
||||||
|
}
|
||||||
|
|
||||||
$v = EventValidator::validate($payload);
|
$v = EventValidator::validate($payload);
|
||||||
if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400);
|
if (!$v['ok']) return Response::json(['error' => 'invalid', 'details' => $v['errors']], 400);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ final class IngestControllerTest extends TestCase
|
||||||
|
|
||||||
public function testAcceptsValidSignedEvent(): void {
|
public function testAcceptsValidSignedEvent(): void {
|
||||||
[$c, $events, $snaps] = $this->make();
|
[$c, $events, $snaps] = $this->make();
|
||||||
$res = $c->handle($this->signedReq('sekret', $this->payload()), now: 1000);
|
$res = $c->handle($this->signedReq('sekret', $this->payload()), now: 1000, clientIp: '1.2.3.4');
|
||||||
$this->assertSame(201, $res->status());
|
$this->assertSame(201, $res->status());
|
||||||
$this->assertCount(1, $events->rows);
|
$this->assertCount(1, $events->rows);
|
||||||
$id = json_decode($res->body(), true)['event_id'];
|
$id = json_decode($res->body(), true)['event_id'];
|
||||||
|
|
@ -48,21 +48,39 @@ final class IngestControllerTest extends TestCase
|
||||||
public function testRejectsBadSignature(): void {
|
public function testRejectsBadSignature(): void {
|
||||||
[$c] = $this->make();
|
[$c] = $this->make();
|
||||||
$req = $this->signedReq('WRONG', $this->payload());
|
$req = $this->signedReq('WRONG', $this->payload());
|
||||||
$this->assertSame(401, $c->handle($req, now: 1000)->status());
|
$this->assertSame(401, $c->handle($req, now: 1000, clientIp: '1.2.3.4')->status());
|
||||||
}
|
}
|
||||||
public function testRejectsReplayedNonce(): void {
|
public function testRejectsReplayedNonce(): void {
|
||||||
[$c] = $this->make();
|
[$c] = $this->make();
|
||||||
$req = $this->signedReq('sekret', $this->payload());
|
$req = $this->signedReq('sekret', $this->payload());
|
||||||
$this->assertSame(201, $c->handle($req, now: 1000)->status());
|
$this->assertSame(201, $c->handle($req, now: 1000, clientIp: '1.2.3.4')->status());
|
||||||
$this->assertSame(401, $c->handle($req, now: 1000)->status()); // same nonce again
|
$this->assertSame(401, $c->handle($req, now: 1000, clientIp: '1.2.3.4')->status()); // same nonce again
|
||||||
}
|
}
|
||||||
public function testRejectsStaleTimestamp(): void {
|
public function testRejectsStaleTimestamp(): void {
|
||||||
[$c] = $this->make();
|
[$c] = $this->make();
|
||||||
$req = $this->signedReq('sekret', $this->payload(), ts: '1000');
|
$req = $this->signedReq('sekret', $this->payload(), ts: '1000');
|
||||||
$this->assertSame(401, $c->handle($req, now: 5000)->status()); // 4000s > 300 window
|
$this->assertSame(401, $c->handle($req, now: 5000, clientIp: '1.2.3.4')->status()); // 4000s > 300 window
|
||||||
}
|
}
|
||||||
public function testRejectsUnknownDevice(): void {
|
public function testRejectsUnknownDevice(): void {
|
||||||
[$c] = $this->make([]); // no secrets
|
[$c] = $this->make([]); // no secrets
|
||||||
$this->assertSame(401, $c->handle($this->signedReq('x', $this->payload()), now: 1000)->status());
|
$this->assertSame(401, $c->handle($this->signedReq('x', $this->payload()), now: 1000, clientIp: '1.2.3.4')->status());
|
||||||
|
}
|
||||||
|
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
|
||||||
|
// real devices from having their own per-device quota consumed.
|
||||||
|
$cfg = Config::fromArray(['snapshot_dir' => $this->dir, 'timestamp_window' => 300, 'ingest_ip_rate_limit_per_min' => 2]);
|
||||||
|
$dev = new InMemoryDeviceRepository(['pi-1' => 'sekret']);
|
||||||
|
$nonce = new InMemoryNonceRepository();
|
||||||
|
$rl = new InMemoryRateLimiter();
|
||||||
|
$events = new InMemoryEventRepository();
|
||||||
|
$snaps = new InMemorySnapshotRepository();
|
||||||
|
$c = new IngestController($cfg, $dev, $nonce, $rl, $events, $snaps);
|
||||||
|
|
||||||
|
$garbage = new Request('POST', '/api/events', [], 'not-json', [], []);
|
||||||
|
$this->assertSame(401, $c->handle($garbage, now: 1000, clientIp: '9.9.9.9')->status());
|
||||||
|
$this->assertSame(401, $c->handle($garbage, now: 1000, clientIp: '9.9.9.9')->status());
|
||||||
|
$res = $c->handle($garbage, now: 1000, clientIp: '9.9.9.9');
|
||||||
|
$this->assertSame(429, $res->status()); // 3rd request from same IP: rate limited pre-auth
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue