diff --git a/backend/core/src/main/java/org/sonarsource/sonarlint/core/embedded/server/EmbeddedServer.java b/backend/core/src/main/java/org/sonarsource/sonarlint/core/embedded/server/EmbeddedServer.java index fec3668699..77e29575c3 100644 --- a/backend/core/src/main/java/org/sonarsource/sonarlint/core/embedded/server/EmbeddedServer.java +++ b/backend/core/src/main/java/org/sonarsource/sonarlint/core/embedded/server/EmbeddedServer.java @@ -83,7 +83,8 @@ public void start() { .setConnectionReuseStrategy(new DontKeepAliveReuseStrategy()) .setListenerPort(triedPort) .setSocketConfig(socketConfig) - .addFilterFirst("CORS", new CorsFilter()) + .addFilterFirst("RateLimiter", new RateLimitFilter()) + .addFilterAfter("RateLimiter", "CORS", new CorsFilter()) .register("/sonarlint/api/status", statusRequestHandler) .register("/sonarlint/api/token", generatedUserTokenHandler) .register("/sonarlint/api/hotspots/show", showHotspotRequestHandler) diff --git a/backend/core/src/main/java/org/sonarsource/sonarlint/core/embedded/server/RateLimitFilter.java b/backend/core/src/main/java/org/sonarsource/sonarlint/core/embedded/server/RateLimitFilter.java new file mode 100644 index 0000000000..808e31a251 --- /dev/null +++ b/backend/core/src/main/java/org/sonarsource/sonarlint/core/embedded/server/RateLimitFilter.java @@ -0,0 +1,85 @@ +/* + * SonarLint Core - Implementation + * Copyright (C) 2016-2025 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +package org.sonarsource.sonarlint.core.embedded.server; + +import org.apache.hc.core5.http.ClassicHttpRequest; +import org.apache.hc.core5.http.HttpException; +import org.apache.hc.core5.http.HttpStatus; +import org.apache.hc.core5.http.io.HttpFilterChain; +import org.apache.hc.core5.http.io.HttpFilterHandler; +import org.apache.hc.core5.http.message.BasicClassicHttpResponse; +import org.apache.hc.core5.http.protocol.HttpContext; + +import java.io.IOException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +public class RateLimitFilter implements HttpFilterHandler { + + private static final int MAX_REQUESTS_PER_ORIGIN = 10; + private static final long TIME_FRAME_MS = TimeUnit.SECONDS.toMillis(10); + private final ConcurrentHashMap requestCounters = new ConcurrentHashMap<>(); + + @Override + public void handle(ClassicHttpRequest request, HttpFilterChain.ResponseTrigger responseTrigger, HttpContext context, HttpFilterChain chain) + throws HttpException, IOException { + var originHeader = request.getHeader("Origin"); + var origin = originHeader != null ? originHeader.getValue() : null; + if (origin == null) { + var response = new BasicClassicHttpResponse(HttpStatus.SC_BAD_REQUEST); + responseTrigger.submitResponse(response); + } else { + if (!isRequestAllowed(origin)) { + var response = new BasicClassicHttpResponse(HttpStatus.SC_TOO_MANY_REQUESTS); + responseTrigger.submitResponse(response); + } else { + chain.proceed(request, responseTrigger, context); + } + } + } + + private boolean isRequestAllowed(String origin) { + long currentTime = System.currentTimeMillis(); + var counter = requestCounters.computeIfAbsent(origin, k -> new RequestCounter(currentTime)); + synchronized (counter) { + if (currentTime - counter.timestamp > TIME_FRAME_MS) { + counter.timestamp = currentTime; + counter.count = 1; + return true; + } else if (counter.count < MAX_REQUESTS_PER_ORIGIN) { + counter.count++; + return true; + } else { + return false; + } + } + } + + private static class RequestCounter { + long timestamp; + int count; + + RequestCounter(long timestamp) { + this.timestamp = timestamp; + this.count = 1; + } + } + +} diff --git a/medium-tests/src/test/java/mediumtest/EmbeddedServerMediumTests.java b/medium-tests/src/test/java/mediumtest/EmbeddedServerMediumTests.java index 23626db1d9..20c7bd746e 100644 --- a/medium-tests/src/test/java/mediumtest/EmbeddedServerMediumTests.java +++ b/medium-tests/src/test/java/mediumtest/EmbeddedServerMediumTests.java @@ -41,6 +41,7 @@ void it_should_return_the_ide_name_and_empty_description_if_the_origin_is_not_tr var embeddedServerPort = backend.getEmbeddedServerPort(); var request = HttpRequest.newBuilder() .uri(URI.create("http://localhost:" + embeddedServerPort + "/sonarlint/api/status")) + .header("Origin", "https://untrusted") .GET().build(); var response = java.net.http.HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); @@ -141,4 +142,41 @@ void it_should_receive_bad_request_response_if_not_right_method(SonarLintTestHar assertThat(responseToken.headers().map()).doesNotContainKey("Content-Security-Policy-Report-Only"); assertThat(responseStatus.headers().map()).doesNotContainKey("Content-Security-Policy-Report-Only"); } + + @SonarLintTest + void it_should_rate_limit_origin_if_too_many_requests(SonarLintTestHarness harness) throws IOException, InterruptedException { + var fakeClient = harness.newFakeClient().build(); + var backend = harness.newBackend().withEmbeddedServer().withClientName("ClientName").start(fakeClient); + + var embeddedServerPort = backend.getEmbeddedServerPort(); + var request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + embeddedServerPort + "/sonarlint/api/status")) + .header("Origin", "https://sonar") + .GET().build(); + for (int i = 0; i < 10; i++) { + java.net.http.HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + } + var response = java.net.http.HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response) + .extracting(HttpResponse::statusCode, HttpResponse::body) + .containsExactly(HttpStatus.TOO_MANY_REQUESTS_429, ""); + } + + @SonarLintTest + void it_should_not_allow_request_if_origin_is_missing(SonarLintTestHarness harness) throws IOException, InterruptedException { + var fakeClient = harness.newFakeClient().build(); + var backend = harness.newBackend().withEmbeddedServer().withClientName("ClientName").start(fakeClient); + + var embeddedServerPort = backend.getEmbeddedServerPort(); + var request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + embeddedServerPort + "/sonarlint/api/status")) + .GET().build(); + var response = java.net.http.HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + + assertThat(response) + .extracting(HttpResponse::statusCode, HttpResponse::body) + .containsExactly(HttpStatus.BAD_REQUEST_400, ""); + } + }