Skip to content

Commit

Permalink
SLCORE-1083 Fix SSF-703 (#1223)
Browse files Browse the repository at this point in the history
  • Loading branch information
nquinquenel authored Jan 30, 2025
1 parent 32b2c25 commit 94c5260
Show file tree
Hide file tree
Showing 5 changed files with 213 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .cirrus.yml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ test_linux_task:
- source cirrus-env QA
- PULL_REQUEST_SHA=$GIT_SHA1 regular_mvn_build_deploy_analyze -P-deploy-sonarsource,-release,-sign -Dcommercial -Dmaven.install.skip=true -Dmaven.deploy.skip=true -Dsonar.coverage.jacoco.xmlReportPaths=$CIRRUS_WORKING_DIR/report-aggregate/target/site/jacoco-aggregate/jacoco.xml
cleanup_before_cache_script: cleanup_maven_repository
on_failure:
always:
junit_artifacts:
path: '**/target/surefire-reports/TEST-*.xml'
format: junit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* 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<String, RequestCounter> 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));
requestCounters.compute(origin, (k, v) -> {
if (currentTime - counter.timestamp > TIME_FRAME_MS) {
counter.timestamp = currentTime;
counter.count = 1;
} else {
counter.count++;
}
return counter;
});
return counter.count <= MAX_REQUESTS_PER_ORIGIN;
}

private static class RequestCounter {
long timestamp;
int count;

RequestCounter(long timestamp) {
this.timestamp = timestamp;
this.count = 0;
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* 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.io.HttpFilterChain;
import org.apache.hc.core5.http.message.BasicHeader;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.io.IOException;

import static org.mockito.Mockito.*;

class RateLimitFilterTests {

private final ClassicHttpRequest request = mock(ClassicHttpRequest.class);
private final HttpFilterChain.ResponseTrigger responseTrigger = mock(HttpFilterChain.ResponseTrigger.class);
private final HttpContext context = mock(HttpContext.class);
private final HttpFilterChain chain = mock(HttpFilterChain.class);
private RateLimitFilter filter;

@BeforeEach
void init() {
filter = new RateLimitFilter();
}

@Test
void should_not_proceed_with_request_if_origin_is_null() throws HttpException, IOException {
when(request.getHeader("Origin")).thenReturn(null);

filter.handle(request, responseTrigger, context, chain);

verify(responseTrigger).submitResponse(any());
verify(chain, never()).proceed(any(), any(), any());
}

@Test
void should_proceed_when_request_is_valid() throws HttpException, IOException {
when(request.getHeader("Origin")).thenReturn(new BasicHeader("Origin", "https://example.com"));

filter.handle(request, responseTrigger, context, chain);

verify(responseTrigger, never()).submitResponse(any());
verify(chain).proceed(any(), any(), any());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,15 @@
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import org.apache.commons.lang.RandomStringUtils;
import org.eclipse.jetty.http.HttpStatus;
import org.sonarsource.sonarlint.core.test.utils.junit5.SonarLintTest;
import org.sonarsource.sonarlint.core.test.utils.junit5.SonarLintTestHarness;

import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.mockito.Mockito.when;

class EmbeddedServerMediumTests {
Expand All @@ -41,6 +44,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());

Expand Down Expand Up @@ -141,4 +145,60 @@ 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", RandomStringUtils.randomAlphabetic(10))
.GET().build();
for (int i = 0; i < 15; 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, "");
}

@SonarLintTest
void it_should_not_rate_limit_over_time(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", RandomStringUtils.randomAlphabetic(10))
.GET().build();
for (int i = 0; i < 15; i++) {
java.net.http.HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
}
await().atMost(Duration.ofSeconds(15)).untilAsserted(() -> {
var response = java.net.http.HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
assertThat(response.statusCode()).isEqualTo(HttpStatus.OK_200);
});
}

}

0 comments on commit 94c5260

Please sign in to comment.