feat(http): answer 429 after repeated failed token checks on /commands
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:
rootandClaude Sonnet 5.5 committed 2026-10-24 05:11:54 +02:00
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", "{}");