feat(http): answer 429 after repeated failed token checks on /commands
CI / Backend tests (Maven / Java 21) (push) Successful in 1m9s
CI / Backend tests (Maven / Java 21) (push) Successful in 1m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f7939fa6e
commit
5f2dc81107
4 files changed
+123
-8
No files matched your search
@@ -13,6 +13,7 @@ import home.gateway.domain.Setpoint;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
@@ -25,6 +26,7 @@ import static home.gateway.adapters.http.GatewayHttpServer.respond;
|
||||
* {@code POST /commands} applies one setpoint change, {@code GET /commands} lists the setpoints
|
||||
* and pending restores. Only the backend may call it (S6): every request needs the shared token.
|
||||
* HTTP status follows the outcome: 200 applied, 422 rejected, 502 failed on the controller side.
|
||||
* Repeated bad tokens lead to 429 for a while ({@link FailedLoginThrottle}).
|
||||
*/
|
||||
class CommandsEndpoint implements HttpHandler {
|
||||
|
||||
@@ -45,8 +47,9 @@ class CommandsEndpoint implements HttpHandler {
|
||||
private final Commands commands;
|
||||
private final Snapshot snapshot;
|
||||
private final byte[] token;
|
||||
private final FailedLoginThrottle throttle;
|
||||
|
||||
CommandsEndpoint(Commands commands, Snapshot snapshot, String token) {
|
||||
CommandsEndpoint(Commands commands, Snapshot snapshot, String token, Clock clock) {
|
||||
if (token == null || token.length() < MIN_TOKEN_LENGTH) {
|
||||
throw new IllegalArgumentException(
|
||||
"command token must be at least " + MIN_TOKEN_LENGTH + " characters");
|
||||
@@ -54,6 +57,7 @@ class CommandsEndpoint implements HttpHandler {
|
||||
this.commands = commands;
|
||||
this.snapshot = snapshot;
|
||||
this.token = token.getBytes(StandardCharsets.UTF_8);
|
||||
this.throttle = new FailedLoginThrottle(clock);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -62,7 +66,12 @@ class CommandsEndpoint implements HttpHandler {
|
||||
var method = exchange.getRequestMethod();
|
||||
if (!exchange.getRequestURI().getPath().equals("/commands")) {
|
||||
respond(exchange, 404, new GatewayHttpServer.Error("not found"));
|
||||
} else if (throttle.isBlocked()) {
|
||||
exchange.getResponseHeaders().set("Retry-After",
|
||||
String.valueOf(FailedLoginThrottle.COOL_DOWN.toSeconds()));
|
||||
respond(exchange, 429, new GatewayHttpServer.Error("too many failed attempts"));
|
||||
} else if (!authorized(exchange)) {
|
||||
throttle.recordFailure();
|
||||
exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer");
|
||||
respond(exchange, 401, new GatewayHttpServer.Error("unauthorized"));
|
||||
} else if (method.equals("GET")) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package home.gateway.adapters.http;
|
||||
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
|
||||
/**
|
||||
* Slows down guessing of the command token: after {@value #MAX_FAILURES} failures within a
|
||||
* minute, from any source, every request waits out a cool-down before its token is checked.
|
||||
* One shared counter on purpose: the only legitimate caller is the backend, so there is no
|
||||
* good client for a lockout to hurt except an attacker's.
|
||||
*/
|
||||
final class FailedLoginThrottle {
|
||||
|
||||
static final int MAX_FAILURES = 5;
|
||||
static final Duration WINDOW = Duration.ofMinutes(1);
|
||||
static final Duration COOL_DOWN = Duration.ofSeconds(60);
|
||||
|
||||
private final Clock clock;
|
||||
private final Deque<Instant> failures = new ArrayDeque<>();
|
||||
private Instant blockedUntil = Instant.MIN;
|
||||
|
||||
FailedLoginThrottle(Clock clock) {
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
synchronized boolean isBlocked() {
|
||||
return clock.instant().isBefore(blockedUntil);
|
||||
}
|
||||
|
||||
synchronized void recordFailure() {
|
||||
var now = clock.instant();
|
||||
failures.addLast(now);
|
||||
while (failures.peekFirst().isBefore(now.minus(WINDOW))) {
|
||||
failures.removeFirst();
|
||||
}
|
||||
if (failures.size() >= MAX_FAILURES) {
|
||||
blockedUntil = now.plus(COOL_DOWN);
|
||||
failures.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ public class GatewayHttpServer implements AutoCloseable {
|
||||
* read-only server needs no token; call before {@link #start()}.
|
||||
*/
|
||||
public void serveCommands(Commands commands, String token) {
|
||||
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token));
|
||||
server.createContext("/commands", new CommandsEndpoint(commands, snapshot, token, clock));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
|
||||
@@ -25,7 +25,9 @@ import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -42,6 +44,23 @@ class CommandsEndpointTest {
|
||||
private static final Instant T0 = Instant.parse("2026-10-24T10:00:00Z");
|
||||
private static final Path EXAMPLES = Path.of("src/test/resources/commands");
|
||||
|
||||
private final AtomicReference<Instant> now = new AtomicReference<>(T0);
|
||||
private final Clock clock = new Clock() {
|
||||
@Override
|
||||
public ZoneOffset getZone() {
|
||||
return ZoneOffset.UTC;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Clock withZone(ZoneId zone) {
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant instant() {
|
||||
return now.get();
|
||||
}
|
||||
};
|
||||
private final ObjectMapper json = new ObjectMapper();
|
||||
private final HttpClient client = HttpClient.newHttpClient();
|
||||
private final Snapshot snapshot = new Snapshot();
|
||||
@@ -72,7 +91,7 @@ class CommandsEndpointTest {
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws Exception {
|
||||
server = new GatewayHttpServer(snapshot, Clock.fixed(T0, ZoneOffset.UTC), 0, Set.of());
|
||||
server = new GatewayHttpServer(snapshot, clock, 0, Set.of());
|
||||
server.serveCommands(commands, BEARER);
|
||||
server.start();
|
||||
}
|
||||
@@ -245,15 +264,58 @@ class CommandsEndpointTest {
|
||||
@Test
|
||||
void everyMethodNeedsTheToken() throws Exception {
|
||||
for (var method : List.of("GET", "POST", "DELETE")) {
|
||||
assertEquals(401, send(method, null).statusCode());
|
||||
assertEquals(401, send(method, null, "Authorization", "Bearer wrong-token-value-xx").statusCode());
|
||||
assertEquals(401, send(method, null, "Authorization", BEARER).statusCode());
|
||||
assertEquals(401, send(method, null, "Authorization", "Basic " + BEARER).statusCode());
|
||||
assertEquals(401, failedAttempt(method, null));
|
||||
assertEquals(401, failedAttempt(method, null, "Authorization", "Bearer wrong-token-value-xx"));
|
||||
assertEquals(401, failedAttempt(method, null, "Authorization", BEARER));
|
||||
assertEquals(401, failedAttempt(method, null, "Authorization", "Basic " + BEARER));
|
||||
}
|
||||
assertEquals(401, send("POST", example("request-boost.json")).statusCode());
|
||||
assertEquals(401, failedAttempt("POST", example("request-boost.json")));
|
||||
assertTrue(received.isEmpty());
|
||||
}
|
||||
|
||||
/** A failed request, then enough time passes for the throttle to forget it. */
|
||||
private int failedAttempt(String method, String body, String... headers) throws Exception {
|
||||
var status = send(method, body, headers).statusCode();
|
||||
now.updateAndGet(time -> time.plus(Duration.ofMinutes(2)));
|
||||
return status;
|
||||
}
|
||||
|
||||
@Test
|
||||
void fiveFailuresInAMinuteBlockEvenAValidTokenUntilTheCoolDownEnds() throws Exception {
|
||||
nextResult = new CommandResult(Status.APPLIED, "ok", null);
|
||||
for (int i = 0; i < 5; i++) {
|
||||
assertEquals(401, send("GET", null, "Authorization", "Bearer wrong-token-value-xx").statusCode());
|
||||
}
|
||||
|
||||
var blocked = authorized("GET", null);
|
||||
assertEquals(429, blocked.statusCode());
|
||||
assertEquals("60", blocked.headers().firstValue("Retry-After").orElseThrow());
|
||||
assertEquals(429, authorized("POST", "{\"setpoint\":\"cwu_economy\",\"value\":40}").statusCode());
|
||||
assertTrue(received.isEmpty());
|
||||
|
||||
now.updateAndGet(time -> time.plusSeconds(59));
|
||||
assertEquals(429, authorized("GET", null).statusCode());
|
||||
now.updateAndGet(time -> time.plusSeconds(2));
|
||||
assertEquals(200, authorized("GET", null).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failuresSpreadOverMoreThanAMinuteDoNotBlock() throws Exception {
|
||||
for (int i = 0; i < 10; i++) {
|
||||
assertEquals(401, send("GET", null).statusCode());
|
||||
now.updateAndGet(time -> time.plusSeconds(20));
|
||||
}
|
||||
|
||||
assertEquals(200, authorized("GET", null).statusCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void successfulRequestsNeverCountAsFailures() throws Exception {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
assertEquals(200, authorized("GET", null).statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void otherMethodsAre405() throws Exception {
|
||||
var response = authorized("PUT", "{}");
|
||||
|
||||
Reference in new issue
Block a user