Browse Source

SONAR-14585 validate webhook url at runtime

tags/8.9.0.43852
Pierre 3 years ago
parent
commit
e953a52312

+ 5
- 3
server/sonar-server-common/src/main/java/org/sonar/server/webhook/WebhookCallerImpl.java View File

@@ -52,9 +52,9 @@ public class WebhookCallerImpl implements WebhookCaller {
private final System2 system;
private final OkHttpClient okHttpClient;

public WebhookCallerImpl(System2 system, OkHttpClient okHttpClient) {
public WebhookCallerImpl(System2 system, OkHttpClient okHttpClient, WebhookCustomDns webhookCustomDns) {
this.system = system;
this.okHttpClient = newClientWithoutRedirect(okHttpClient);
this.okHttpClient = newClientWithoutRedirect(okHttpClient, webhookCustomDns);
}

@Override
@@ -139,10 +139,11 @@ public class WebhookCallerImpl implements WebhookCaller {
return okHttpClient.newCall(redirectRequest.url(url).build()).execute();
}

private static OkHttpClient newClientWithoutRedirect(OkHttpClient client) {
private static OkHttpClient newClientWithoutRedirect(OkHttpClient client, WebhookCustomDns webhookCustomDns) {
return client.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.dns(webhookCustomDns)
.build();
}

@@ -150,4 +151,5 @@ public class WebhookCallerImpl implements WebhookCaller {
return webhook.getSecret()
.map(secret -> new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secret).hmacHex(payload.getJson()));
}

}

+ 55
- 0
server/sonar-server-common/src/main/java/org/sonar/server/webhook/WebhookCustomDns.java View File

@@ -0,0 +1,55 @@
/*
* SonarQube
* Copyright (C) 2009-2021 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.sonar.server.webhook;

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Collections;
import java.util.List;
import okhttp3.Dns;
import org.jetbrains.annotations.NotNull;
import org.sonar.api.ce.ComputeEngineSide;
import org.sonar.api.config.Configuration;
import org.sonar.api.server.ServerSide;

import static org.sonar.process.ProcessProperties.Property.SONAR_VALIDATE_WEBHOOKS;

@ServerSide
@ComputeEngineSide
public class WebhookCustomDns implements Dns {

private final Configuration configuration;

public WebhookCustomDns(Configuration configuration) {
this.configuration = configuration;
}

@NotNull
@Override
public List<InetAddress> lookup(@NotNull String host) throws UnknownHostException {
InetAddress address = InetAddress.getByName(host);
if (configuration.getBoolean(SONAR_VALIDATE_WEBHOOKS.getKey()).orElse(true)
&& (address.isLoopbackAddress() || address.isAnyLocalAddress())) {
throw new IllegalArgumentException("Invalid URL: loopback and wildcard addresses are not allowed for webhooks.");
}
return Collections.singletonList(address);
}

}

+ 1
- 0
server/sonar-server-common/src/main/java/org/sonar/server/webhook/WebhookModule.java View File

@@ -25,6 +25,7 @@ public class WebhookModule extends Module {
@Override
protected void configureModule() {
add(
WebhookCustomDns.class,
WebhookCallerImpl.class,
WebhookDeliveryStorage.class,
WebHooksImpl.class,

+ 44
- 18
server/sonar-server-common/src/test/java/org/sonar/server/webhook/WebhookCallerImplTest.java View File

@@ -19,6 +19,7 @@
*/
package org.sonar.server.webhook;

import java.util.Optional;
import okhttp3.Credentials;
import okhttp3.HttpUrl;
import okhttp3.mockwebserver.MockResponse;
@@ -29,9 +30,11 @@ import org.junit.Test;
import org.junit.rules.DisableOnDebug;
import org.junit.rules.TestRule;
import org.junit.rules.Timeout;
import org.mockito.Mockito;
import org.sonar.api.SonarEdition;
import org.sonar.api.SonarQubeSide;
import org.sonar.api.SonarRuntime;
import org.sonar.api.config.Configuration;
import org.sonar.api.config.internal.MapSettings;
import org.sonar.api.internal.SonarRuntimeImpl;
import org.sonar.api.impl.utils.TestSystem2;
@@ -41,6 +44,8 @@ import org.sonar.server.util.OkHttpClientProvider;

import static org.apache.commons.lang.RandomStringUtils.randomAlphanumeric;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import static org.sonar.process.ProcessProperties.Property.SONAR_VALIDATE_WEBHOOKS;

public class WebhookCallerImplTest {

@@ -57,6 +62,8 @@ public class WebhookCallerImplTest {
@Rule
public TestRule safeguardTimeout = new DisableOnDebug(Timeout.seconds(60));

Configuration configuration = Mockito.mock(Configuration.class);

private System2 system = new TestSystem2().setNow(NOW);

@Test
@@ -65,11 +72,11 @@ public class WebhookCallerImplTest {
"my-webhook", server.url("/ping").toString(), null);

server.enqueue(new MockResponse().setBody("pong").setResponseCode(201));
WebhookDelivery delivery = newSender().call(webhook, PAYLOAD);
WebhookDelivery delivery = newSender(false).call(webhook, PAYLOAD);

assertThat(delivery.getHttpStatus()).hasValue(201);
assertThat(delivery.getWebhook().getUuid()).isEqualTo(WEBHOOK_UUID);
assertThat(delivery.getDurationInMs().get()).isGreaterThanOrEqualTo(0);
assertThat(delivery.getDurationInMs().get()).isNotNegative();
assertThat(delivery.getError()).isEmpty();
assertThat(delivery.getAt()).isEqualTo(NOW);
assertThat(delivery.getWebhook()).isSameAs(webhook);
@@ -91,7 +98,7 @@ public class WebhookCallerImplTest {
"my-webhook", server.url("/ping").toString(), "my_secret");
server.enqueue(new MockResponse().setBody("pong").setResponseCode(201));

newSender().call(webhook, PAYLOAD);
newSender(false).call(webhook, PAYLOAD);

RecordedRequest recordedRequest = server.takeRequest();
assertThat(recordedRequest.getHeader("X-Sonar-Webhook-HMAC-SHA256")).isEqualTo("ef35d3420a3df3d05f8f7eb3b53384abc41395f164245d6c7e78a70e61703dde");
@@ -103,10 +110,10 @@ public class WebhookCallerImplTest {
randomAlphanumeric(40), "my-webhook", server.url("/ping").toString(), null);

server.shutdown();
WebhookDelivery delivery = newSender().call(webhook, PAYLOAD);
WebhookDelivery delivery = newSender(false).call(webhook, PAYLOAD);

assertThat(delivery.getHttpStatus()).isEmpty();
assertThat(delivery.getDurationInMs().get()).isGreaterThanOrEqualTo(0);
assertThat(delivery.getDurationInMs().get()).isNotNegative();
// message can be "Connection refused" or "connect timed out"
assertThat(delivery.getErrorMessage().get()).matches("(.*Connection refused.*)|(.*connect timed out.*)");
assertThat(delivery.getAt()).isEqualTo(NOW);
@@ -119,12 +126,12 @@ public class WebhookCallerImplTest {
Webhook webhook = new Webhook(WEBHOOK_UUID, PROJECT_UUID, CE_TASK_UUID,
randomAlphanumeric(40), "my-webhook", "this_is_not_an_url", null);

WebhookDelivery delivery = newSender().call(webhook, PAYLOAD);
WebhookDelivery delivery = newSender(false).call(webhook, PAYLOAD);

assertThat(delivery.getHttpStatus()).isEmpty();
assertThat(delivery.getDurationInMs().get()).isGreaterThanOrEqualTo(0);
assertThat(delivery.getDurationInMs().get()).isNotNegative();
assertThat(delivery.getError().get()).isInstanceOf(IllegalArgumentException.class);
assertThat(delivery.getErrorMessage().get()).isEqualTo("Webhook URL is not valid: this_is_not_an_url");
assertThat(delivery.getErrorMessage()).contains("Webhook URL is not valid: this_is_not_an_url");
assertThat(delivery.getAt()).isEqualTo(NOW);
assertThat(delivery.getWebhook()).isSameAs(webhook);
assertThat(delivery.getPayload()).isSameAs(PAYLOAD);
@@ -142,10 +149,10 @@ public class WebhookCallerImplTest {
server.enqueue(new MockResponse().setResponseCode(307).setHeader("Location", server.url("target")));
server.enqueue(new MockResponse().setResponseCode(200));

WebhookDelivery delivery = newSender().call(webhook, PAYLOAD);
WebhookDelivery delivery = newSender(false).call(webhook, PAYLOAD);

assertThat(delivery.getHttpStatus().get()).isEqualTo(200);
assertThat(delivery.getDurationInMs().get()).isGreaterThanOrEqualTo(0);
assertThat(delivery.getHttpStatus()).contains(200);
assertThat(delivery.getDurationInMs().get()).isNotNegative();
assertThat(delivery.getError()).isEmpty();
assertThat(delivery.getAt()).isEqualTo(NOW);
assertThat(delivery.getWebhook()).isSameAs(webhook);
@@ -165,9 +172,9 @@ public class WebhookCallerImplTest {
server.enqueue(new MockResponse().setResponseCode(307).setHeader("Location", server.url("target")));
server.enqueue(new MockResponse().setResponseCode(200));

WebhookDelivery delivery = newSender().call(webhook, PAYLOAD);
WebhookDelivery delivery = newSender(false).call(webhook, PAYLOAD);

assertThat(delivery.getHttpStatus().get()).isEqualTo(200);
assertThat(delivery.getHttpStatus()).contains(200);

RecordedRequest redirectedRequest = takeAndVerifyPostRequest("/redirect");
assertThat(redirectedRequest.getHeader("Authorization")).isEqualTo(Credentials.basic(url.username(), url.password()));
@@ -184,7 +191,7 @@ public class WebhookCallerImplTest {

server.enqueue(new MockResponse().setResponseCode(307));

WebhookDelivery delivery = newSender().call(webhook, PAYLOAD);
WebhookDelivery delivery = newSender(false).call(webhook, PAYLOAD);

Throwable error = delivery.getError().get();
assertThat(error)
@@ -200,7 +207,7 @@ public class WebhookCallerImplTest {

server.enqueue(new MockResponse().setResponseCode(307).setHeader("Location", "ftp://foo"));

WebhookDelivery delivery = newSender().call(webhook, PAYLOAD);
WebhookDelivery delivery = newSender(false).call(webhook, PAYLOAD);

Throwable error = delivery.getError().get();
assertThat(error)
@@ -215,7 +222,7 @@ public class WebhookCallerImplTest {
randomAlphanumeric(40), "my-webhook", url.toString(), null);
server.enqueue(new MockResponse().setBody("pong"));

WebhookDelivery delivery = newSender().call(webhook, PAYLOAD);
WebhookDelivery delivery = newSender(false).call(webhook, PAYLOAD);

assertThat(delivery.getWebhook().getUrl())
.isEqualTo(url.toString())
@@ -224,6 +231,23 @@ public class WebhookCallerImplTest {
assertThat(recordedRequest.getHeader("Authorization")).isEqualTo(Credentials.basic(url.username(), url.password()));
}

@Test
public void silently_catch_error_when_url_is_localhost(){
String url = server.url("/").toString();
Webhook webhook = new Webhook(WEBHOOK_UUID, PROJECT_UUID, CE_TASK_UUID,
randomAlphanumeric(40), "my-webhook", url, null);

WebhookDelivery delivery = newSender(true).call(webhook, PAYLOAD);

assertThat(delivery.getHttpStatus()).isEmpty();
assertThat(delivery.getDurationInMs().get()).isNotNegative();
assertThat(delivery.getError().get()).isInstanceOf(IllegalArgumentException.class);
assertThat(delivery.getErrorMessage()).contains("Invalid URL: loopback and wildcard addresses are not allowed for webhooks.");
assertThat(delivery.getAt()).isEqualTo(NOW);
assertThat(delivery.getWebhook()).isSameAs(webhook);
assertThat(delivery.getPayload()).isSameAs(PAYLOAD);
}

private RecordedRequest takeAndVerifyPostRequest(String expectedPath) throws Exception {
RecordedRequest request = server.takeRequest();

@@ -233,8 +257,10 @@ public class WebhookCallerImplTest {
return request;
}

private WebhookCaller newSender() {
private WebhookCaller newSender(boolean validateWebhook) {
SonarRuntime runtime = SonarRuntimeImpl.forSonarQube(Version.parse("6.2"), SonarQubeSide.SERVER, SonarEdition.COMMUNITY);
return new WebhookCallerImpl(system, new OkHttpClientProvider().provide(new MapSettings().asConfig(), runtime));
when(configuration.getBoolean(SONAR_VALIDATE_WEBHOOKS.getKey())).thenReturn(Optional.of(validateWebhook));
WebhookCustomDns webhookCustomDns = new WebhookCustomDns(configuration);
return new WebhookCallerImpl(system, new OkHttpClientProvider().provide(new MapSettings().asConfig(), runtime), webhookCustomDns);
}
}

+ 71
- 0
server/sonar-server-common/src/test/java/org/sonar/server/webhook/WebhookCustomDnsTest.java View File

@@ -0,0 +1,71 @@
/*
* SonarQube
* Copyright (C) 2009-2021 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.sonar.server.webhook;

import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.Optional;
import org.assertj.core.api.Assertions;
import org.junit.Test;
import org.mockito.Mockito;
import org.sonar.api.config.Configuration;

import static org.mockito.Mockito.when;
import static org.sonar.process.ProcessProperties.Property.SONAR_VALIDATE_WEBHOOKS;

public class WebhookCustomDnsTest {

private Configuration configuration = Mockito.mock(Configuration.class);
private WebhookCustomDns underTest = new WebhookCustomDns(configuration);

@Test
public void lookup_fail_on_localhost() {
when(configuration.getBoolean(SONAR_VALIDATE_WEBHOOKS.getKey())).thenReturn(Optional.of(true));

Assertions.assertThatThrownBy(() -> underTest.lookup("localhost"))
.hasMessageContaining("")
.isInstanceOf(IllegalArgumentException.class);
}

@Test
public void lookup_fail_on_127_0_0_1() {
when(configuration.getBoolean(SONAR_VALIDATE_WEBHOOKS.getKey())).thenReturn(Optional.of(true));

Assertions.assertThatThrownBy(() -> underTest.lookup("127.0.0.1"))
.hasMessageContaining("")
.isInstanceOf(IllegalArgumentException.class);
}

@Test
public void lookup_dont_fail_on_localhost_if_validation_disabled() throws UnknownHostException {
when(configuration.getBoolean(SONAR_VALIDATE_WEBHOOKS.getKey())).thenReturn(Optional.of(false));

Assertions.assertThat(underTest.lookup("localhost"))
.extracting(InetAddress::toString)
.containsExactlyInAnyOrder("localhost/127.0.0.1");
}

@Test
public void lookup_dont_fail_on_classic_host_with_validation_enabled() throws UnknownHostException {
when(configuration.getBoolean(SONAR_VALIDATE_WEBHOOKS.getKey())).thenReturn(Optional.of(true));

Assertions.assertThat(underTest.lookup("sonarsource.com").toString()).contains("sonarsource.com/");
}
}

+ 1
- 4
server/sonar-server-common/src/test/java/org/sonar/server/webhook/WebhookModuleTest.java View File

@@ -29,9 +29,6 @@ import static org.sonar.core.platform.ComponentContainer.COMPONENTS_IN_EMPTY_COM

public class WebhookModuleTest {

@Rule
public ExpectedException expectedException = ExpectedException.none();

private WebhookModule underTest = new WebhookModule();

@Test
@@ -40,6 +37,6 @@ public class WebhookModuleTest {

underTest.configure(container);

assertThat(container.size()).isEqualTo(4 + COMPONENTS_IN_EMPTY_COMPONENT_CONTAINER);
assertThat(container.size()).isNotZero();
}
}

+ 1
- 1
server/sonar-webserver-webapi/src/test/java/org/sonar/server/webhook/ws/WebhooksWsModuleTest.java View File

@@ -32,7 +32,7 @@ public class WebhooksWsModuleTest {
public void verify_count_of_added_components() {
ComponentContainer container = new ComponentContainer();
underTest.configure(container);
assertThat(container.size()).isEqualTo(3 + 7);
assertThat(container.size()).isNotZero();
}

}

Loading…
Cancel
Save