Browse Source
# Conflicts: # application/pom.xml # common/actor/pom.xml # common/cache/pom.xml # common/cluster-api/pom.xml # common/coap-server/pom.xml # common/dao-api/pom.xml # common/data/pom.xml # common/discovery-api/pom.xml # common/edge-api/pom.xml # common/edqs/pom.xml # common/message/pom.xml # common/pom.xml # common/proto/pom.xml # common/queue/pom.xml # common/script/pom.xml # common/script/remote-js-client/pom.xml # common/script/script-api/pom.xml # common/stats/pom.xml # common/transport/coap/pom.xml # common/transport/http/pom.xml # common/transport/lwm2m/pom.xml # common/transport/mqtt/pom.xml # common/transport/pom.xml # common/transport/snmp/pom.xml # common/transport/transport-api/pom.xml # common/util/pom.xml # common/version-control/pom.xml # dao/pom.xml # edqs/pom.xml # monitoring/pom.xml # msa/black-box-tests/pom.xml # msa/edqs/pom.xml # msa/js-executor/pom.xml # msa/monitoring/pom.xml # msa/pom.xml # msa/tb-node/pom.xml # msa/tb/pom.xml # msa/transport/coap/pom.xml # msa/transport/http/pom.xml # msa/transport/lwm2m/pom.xml # msa/transport/mqtt/pom.xml # msa/transport/pom.xml # msa/transport/snmp/pom.xml # msa/vc-executor-docker/pom.xml # msa/vc-executor/pom.xml # msa/web-ui/pom.xml # netty-mqtt/pom.xml # pom.xml # rest-client/pom.xml # rule-engine/pom.xml # rule-engine/rule-engine-api/pom.xml # rule-engine/rule-engine-components/pom.xml # tools/pom.xml # transport/coap/pom.xml # transport/http/pom.xml # transport/lwm2m/pom.xml # transport/mqtt/pom.xml # transport/pom.xml # transport/snmp/pom.xml # ui-ngx/pom.xmlpull/15304/head
1269 changed files with 25248 additions and 14178 deletions
@ -0,0 +1,79 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.edge; |
|||
|
|||
import com.fasterxml.jackson.databind.node.ObjectNode; |
|||
import com.google.protobuf.AbstractMessage; |
|||
import org.junit.Assert; |
|||
import org.junit.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.thingsboard.common.util.JacksonUtil; |
|||
import org.thingsboard.server.common.data.AdminSettings; |
|||
import org.thingsboard.server.dao.service.DaoSqlTest; |
|||
import org.thingsboard.server.dao.settings.AdminSettingsService; |
|||
import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; |
|||
|
|||
@DaoSqlTest |
|||
public class AdminSettingsEdgeTest extends AbstractEdgeTest { |
|||
|
|||
@Autowired |
|||
private AdminSettingsService adminSettingsService; |
|||
|
|||
@Test |
|||
public void testAdminSettings() throws Exception { |
|||
loginSysAdmin(); |
|||
|
|||
// save
|
|||
AdminSettings adminSettings = new AdminSettings(); |
|||
adminSettings.setKey("edgeTest"); |
|||
ObjectNode jsonValue = JacksonUtil.newObjectNode(); |
|||
jsonValue.put("key1", "value1"); |
|||
adminSettings.setJsonValue(jsonValue); |
|||
|
|||
edgeImitator.expectMessageAmount(1); |
|||
AdminSettings savedAdminSettings = doPost("/api/admin/settings", adminSettings, AdminSettings.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
AbstractMessage latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof AdminSettingsUpdateMsg); |
|||
AdminSettingsUpdateMsg adminSettingsUpdateMsg = (AdminSettingsUpdateMsg) latestMessage; |
|||
AdminSettings adminSettingsMsg = JacksonUtil.fromString(adminSettingsUpdateMsg.getEntity(), AdminSettings.class, true); |
|||
Assert.assertNotNull(adminSettingsMsg); |
|||
Assert.assertEquals("edgeTest", adminSettingsMsg.getKey()); |
|||
Assert.assertEquals("value1", adminSettingsMsg.getJsonValue().get("key1").asText()); |
|||
|
|||
// update
|
|||
ObjectNode updatedJsonValue = (ObjectNode) savedAdminSettings.getJsonValue(); |
|||
updatedJsonValue.put("key2", "value2"); |
|||
savedAdminSettings.setJsonValue(updatedJsonValue); |
|||
|
|||
edgeImitator.expectMessageAmount(1); |
|||
doPost("/api/admin/settings", savedAdminSettings, AdminSettings.class); |
|||
Assert.assertTrue(edgeImitator.waitForMessages()); |
|||
|
|||
latestMessage = edgeImitator.getLatestMessage(); |
|||
Assert.assertTrue(latestMessage instanceof AdminSettingsUpdateMsg); |
|||
adminSettingsUpdateMsg = (AdminSettingsUpdateMsg) latestMessage; |
|||
adminSettingsMsg = JacksonUtil.fromString(adminSettingsUpdateMsg.getEntity(), AdminSettings.class, true); |
|||
Assert.assertNotNull(adminSettingsMsg); |
|||
Assert.assertEquals("edgeTest", adminSettingsMsg.getKey()); |
|||
Assert.assertEquals("value1", adminSettingsMsg.getJsonValue().get("key1").asText()); |
|||
Assert.assertEquals("value2", adminSettingsMsg.getJsonValue().get("key2").asText()); |
|||
|
|||
adminSettingsService.deleteAdminSettingsByTenantIdAndKey(savedAdminSettings.getTenantId(), "edgeTest"); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.dao.nosql; |
|||
|
|||
import lombok.Getter; |
|||
|
|||
@Getter |
|||
public class ResultSetSizeLimitExceededException extends IllegalArgumentException { |
|||
|
|||
private final long limitBytes; |
|||
private final long actualBytes; |
|||
|
|||
public ResultSetSizeLimitExceededException(long limitBytes, long actualBytes) { |
|||
super("Result set size exceeds the maximum allowed limit. Please narrow your query"); |
|||
this.limitBytes = limitBytes; |
|||
this.actualBytes = actualBytes; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,140 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.dao.nosql; |
|||
|
|||
import com.datastax.oss.driver.api.core.cql.AsyncResultSet; |
|||
import com.datastax.oss.driver.api.core.cql.ColumnDefinitions; |
|||
import com.datastax.oss.driver.api.core.cql.ExecutionInfo; |
|||
import com.datastax.oss.driver.api.core.cql.Row; |
|||
import com.datastax.oss.driver.api.core.cql.Statement; |
|||
import com.google.common.util.concurrent.ListenableFuture; |
|||
import com.google.common.util.concurrent.MoreExecutors; |
|||
import com.google.common.util.concurrent.SettableFuture; |
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import java.nio.ByteBuffer; |
|||
import java.util.List; |
|||
import java.util.concurrent.ExecutionException; |
|||
import java.util.function.Function; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
import static org.mockito.Mockito.doReturn; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
class TbResultSetTest { |
|||
|
|||
@Test |
|||
void allRows_withinLimit_returnsAllRows() throws Exception { |
|||
Row row = mock(Row.class); |
|||
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 1000); |
|||
Statement<?> statement = mock(Statement.class); |
|||
|
|||
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000); |
|||
|
|||
List<Row> result = future.get(); |
|||
assertThat(result).hasSize(1); |
|||
assertThat(result.get(0)).isSameAs(row); |
|||
} |
|||
|
|||
@Test |
|||
void allRows_exceedsLimitOnFirstPage_failsWithException() { |
|||
Row row = mock(Row.class); |
|||
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 6000); |
|||
Statement<?> statement = mock(Statement.class); |
|||
|
|||
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000); |
|||
|
|||
assertThatThrownBy(future::get) |
|||
.isInstanceOf(ExecutionException.class) |
|||
.hasCauseInstanceOf(ResultSetSizeLimitExceededException.class); |
|||
} |
|||
|
|||
@Test |
|||
void allRows_exceedsLimitOnSecondPage_failsAfterSecondPage() { |
|||
Row row1 = mock(Row.class); |
|||
Row row2 = mock(Row.class); |
|||
Statement<?> statement = mock(Statement.class); |
|||
doReturn(statement).when(statement).setPagingState((ByteBuffer) null); |
|||
|
|||
AsyncResultSet page2 = createMockResultSet(List.of(row2), false, 3000); |
|||
TbResultSet tbResultSetPage2 = new TbResultSet(statement, page2, s -> null); |
|||
SettableFuture<TbResultSet> page2Future = SettableFuture.create(); |
|||
page2Future.set(tbResultSetPage2); |
|||
TbResultSetFuture tbPage2Future = new TbResultSetFuture(page2Future); |
|||
|
|||
ExecutionInfo page1ExecInfo = mock(ExecutionInfo.class); |
|||
when(page1ExecInfo.getResponseSizeInBytes()).thenReturn(3000); |
|||
when(page1ExecInfo.getPagingState()).thenReturn(null); |
|||
|
|||
AsyncResultSet page1 = createMockResultSet(List.of(row1), true, 3000); |
|||
when(page1.getExecutionInfo()).thenReturn(page1ExecInfo); |
|||
|
|||
Function<Statement, TbResultSetFuture> executeAsync = s -> tbPage2Future; |
|||
TbResultSet tbResultSet = new TbResultSet(statement, page1, executeAsync); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000); |
|||
|
|||
assertThatThrownBy(future::get) |
|||
.isInstanceOf(ExecutionException.class) |
|||
.hasCauseInstanceOf(ResultSetSizeLimitExceededException.class); |
|||
} |
|||
|
|||
@Test |
|||
void allRows_unlimitedWithZero_returnsAllRowsRegardlessOfSize() throws Exception { |
|||
Row row = mock(Row.class); |
|||
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999); |
|||
Statement<?> statement = mock(Statement.class); |
|||
|
|||
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 0); |
|||
|
|||
List<Row> result = future.get(); |
|||
assertThat(result).hasSize(1); |
|||
} |
|||
|
|||
@Test |
|||
void allRows_noLimitOverload_returnsAllRows() throws Exception { |
|||
Row row = mock(Row.class); |
|||
AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999); |
|||
Statement<?> statement = mock(Statement.class); |
|||
|
|||
TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); |
|||
ListenableFuture<List<Row>> future = tbResultSet.allRows(MoreExecutors.directExecutor()); |
|||
|
|||
List<Row> result = future.get(); |
|||
assertThat(result).hasSize(1); |
|||
} |
|||
|
|||
private AsyncResultSet createMockResultSet(List<Row> rows, boolean hasMorePages, int responseSizeInBytes) { |
|||
AsyncResultSet resultSet = mock(AsyncResultSet.class); |
|||
ExecutionInfo executionInfo = mock(ExecutionInfo.class); |
|||
ColumnDefinitions columnDefs = mock(ColumnDefinitions.class); |
|||
|
|||
when(executionInfo.getResponseSizeInBytes()).thenReturn(responseSizeInBytes); |
|||
when(executionInfo.getPagingState()).thenReturn(null); |
|||
when(resultSet.getExecutionInfo()).thenReturn(executionInfo); |
|||
when(resultSet.getColumnDefinitions()).thenReturn(columnDefs); |
|||
when(resultSet.currentPage()).thenReturn(rows); |
|||
when(resultSet.hasMorePages()).thenReturn(hasMorePages); |
|||
when(resultSet.remaining()).thenReturn(rows.size()); |
|||
|
|||
return resultSet; |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,89 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.server.common.msg; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.thingsboard.server.common.data.id.RuleChainId; |
|||
import org.thingsboard.server.common.data.id.RuleNodeId; |
|||
|
|||
import java.util.UUID; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
|
|||
class TbMsgProcessingCtxTest { |
|||
|
|||
private final RuleChainId RULE_CHAIN_ID = new RuleChainId(UUID.fromString("b87c4123-f9f2-41a6-9a09-e3a5b6580b11")); |
|||
private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("1ca5e2ef-1309-41d9-bafa-709e9df0e2a6")); |
|||
|
|||
@Test |
|||
void givenEmptyStack_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithDifferentEntry_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID())); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithMatchingEntry_whenIsAlreadyInStack_thenReturnTrue() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(RULE_CHAIN_ID, RULE_NODE_ID); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithMatchingEntryAmongOthers_whenIsAlreadyInStack_thenReturnTrue() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID())); |
|||
ctx.push(RULE_CHAIN_ID, RULE_NODE_ID); |
|||
ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID())); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isTrue(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithSameChainButDifferentNode_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(RULE_CHAIN_ID, new RuleNodeId(UUID.randomUUID())); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithSameNodeButDifferentChain_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(new RuleChainId(UUID.randomUUID()), RULE_NODE_ID); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void givenStackWithEntryThenPopped_whenIsAlreadyInStack_thenReturnFalse() { |
|||
TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); |
|||
ctx.push(RULE_CHAIN_ID, RULE_NODE_ID); |
|||
ctx.pop(); |
|||
|
|||
assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,247 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.common.util; |
|||
|
|||
import lombok.AccessLevel; |
|||
import lombok.NoArgsConstructor; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
|
|||
import java.net.InetAddress; |
|||
import java.net.URI; |
|||
import java.net.UnknownHostException; |
|||
import java.util.ArrayList; |
|||
import java.util.Collections; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
|
|||
@Slf4j |
|||
@NoArgsConstructor(access = AccessLevel.PRIVATE) |
|||
public class SsrfProtectionValidator { |
|||
|
|||
private static volatile boolean enabled; |
|||
private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https"); |
|||
private static final Set<String> BLOCKED_HOSTNAMES = Set.of("localhost"); |
|||
private static final Set<String> BLOCKED_HOSTNAME_SUFFIXES = Set.of(".internal", ".local"); |
|||
|
|||
private static volatile AdditionalBlockedHosts additionalBlocked = AdditionalBlockedHosts.EMPTY; |
|||
|
|||
// Well-known cloud metadata endpoints not covered by the JDK checks (isLoopback, isSiteLocal, isLinkLocal)
|
|||
private static final List<CidrRange> CLOUD_METADATA_RANGES = List.of( |
|||
CidrRange.of("100.64.0.0", 10), // Carrier-Grade NAT (RFC 6598); Alibaba Cloud and Tencent Cloud metadata
|
|||
CidrRange.of("192.0.0.0", 24), // IANA reserved (RFC 6890); Oracle Cloud alternate metadata endpoint
|
|||
CidrRange.of("168.63.129.16", 32) // Azure WireServer
|
|||
); |
|||
|
|||
public static void validateUri(URI uri) { |
|||
validateUri(uri, enabled); |
|||
} |
|||
|
|||
static void validateUri(URI uri, boolean ssrfProtectionEnabled) { |
|||
if (!ssrfProtectionEnabled) { |
|||
return; |
|||
} |
|||
|
|||
String scheme = uri.getScheme(); |
|||
if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase())) { |
|||
throw new RuntimeException("URI is invalid: only HTTP and HTTPS schemes are allowed, got: " + scheme); |
|||
} |
|||
|
|||
String host = uri.getHost(); |
|||
if (host == null || host.isEmpty()) { |
|||
throw new RuntimeException("URI is invalid: hostname is missing"); |
|||
} |
|||
|
|||
String hostLower = host.toLowerCase(); |
|||
if (BLOCKED_HOSTNAMES.contains(hostLower) || additionalBlocked.hostnames.contains(hostLower)) { |
|||
throwBlockedHost(host); |
|||
} |
|||
for (String suffix : BLOCKED_HOSTNAME_SUFFIXES) { |
|||
if (hostLower.endsWith(suffix)) { |
|||
throwBlockedHost(host); |
|||
} |
|||
} |
|||
// Block IPv6 loopback literal in URL (e.g. http://[::1]/)
|
|||
if ("[::1]".equals(host) || "::1".equals(host)) { |
|||
throwBlockedHost(host); |
|||
} |
|||
|
|||
validateResolvedAddresses(host); |
|||
} |
|||
|
|||
private static void validateResolvedAddresses(String host) { |
|||
InetAddress[] addresses; |
|||
try { |
|||
addresses = InetAddress.getAllByName(host); |
|||
} catch (UnknownHostException e) { |
|||
throw new RuntimeException("URI is invalid: unable to resolve hostname '" + host + "'", e); |
|||
} |
|||
|
|||
for (InetAddress address : addresses) { |
|||
if (isBlockedAddress(address)) { |
|||
log.debug("Blocked request to host '{}' resolved to '{}'", host, address.getHostAddress()); |
|||
throwBlockedHost(host); |
|||
} |
|||
} |
|||
} |
|||
|
|||
private static boolean isBlockedAddress(InetAddress address) { |
|||
// Covers 127.0.0.0/8 and ::1
|
|||
if (address.isLoopbackAddress()) { |
|||
return true; |
|||
} |
|||
// Covers 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
|
|||
if (address.isSiteLocalAddress()) { |
|||
return true; |
|||
} |
|||
// Covers 169.254.0.0/16 and fe80::/10
|
|||
if (address.isLinkLocalAddress()) { |
|||
return true; |
|||
} |
|||
// Covers 0.0.0.0
|
|||
if (address.isAnyLocalAddress()) { |
|||
return true; |
|||
} |
|||
// Additional check for IPv6 unique local addresses (fc00::/7)
|
|||
byte[] addr = address.getAddress(); |
|||
if (addr.length == 16) { |
|||
int firstByte = addr[0] & 0xFF; |
|||
// fc00::/7 means first 7 bits are 1111110, so first byte is 0xFC or 0xFD
|
|||
if (firstByte == 0xFC || firstByte == 0xFD) { |
|||
return true; |
|||
} |
|||
} |
|||
for (CidrRange cidr : CLOUD_METADATA_RANGES) { |
|||
if (cidr.contains(address)) { |
|||
return true; |
|||
} |
|||
} |
|||
// Check additional configured CIDR ranges
|
|||
for (CidrRange cidr : additionalBlocked.cidrRanges) { |
|||
if (cidr.contains(address)) { |
|||
return true; |
|||
} |
|||
} |
|||
return false; |
|||
} |
|||
|
|||
private static void throwBlockedHost(String host) { |
|||
throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed"); |
|||
} |
|||
|
|||
public static void setEnabled(boolean enabled) { |
|||
SsrfProtectionValidator.enabled = enabled; |
|||
} |
|||
|
|||
public static void setAdditionalBlockedHosts(List<String> entries) { |
|||
if (entries == null || entries.isEmpty()) { |
|||
additionalBlocked = AdditionalBlockedHosts.EMPTY; |
|||
return; |
|||
} |
|||
List<CidrRange> cidrRanges = new ArrayList<>(); |
|||
Set<String> hostnames = new HashSet<>(); |
|||
for (String entry : entries) { |
|||
String trimmed = entry.trim(); |
|||
if (trimmed.isEmpty()) { |
|||
continue; |
|||
} |
|||
if (trimmed.contains("/") || isIpLiteral(trimmed)) { |
|||
try { |
|||
cidrRanges.add(CidrRange.parse(trimmed)); |
|||
} catch (Exception e) { |
|||
log.warn("Failed to parse CIDR/IP entry '{}': {}", trimmed, e.getMessage()); |
|||
} |
|||
} else { |
|||
hostnames.add(trimmed.toLowerCase()); |
|||
} |
|||
} |
|||
additionalBlocked = new AdditionalBlockedHosts( |
|||
Collections.unmodifiableList(cidrRanges), |
|||
Collections.unmodifiableSet(hostnames)); |
|||
log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", cidrRanges.size(), hostnames.size()); |
|||
} |
|||
|
|||
private static boolean isIpLiteral(String entry) { |
|||
// IPv4 starts with a digit, IPv6 contains ':'
|
|||
return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":")); |
|||
} |
|||
|
|||
record AdditionalBlockedHosts(List<CidrRange> cidrRanges, Set<String> hostnames) { |
|||
static final AdditionalBlockedHosts EMPTY = new AdditionalBlockedHosts(Collections.emptyList(), Collections.emptySet()); |
|||
} |
|||
|
|||
record CidrRange(byte[] network, int prefixLength) { |
|||
|
|||
static CidrRange of(String ip, int prefixLength) { |
|||
try { |
|||
byte[] addr = InetAddress.getByName(ip).getAddress(); |
|||
if (prefixLength < 0 || prefixLength > addr.length * 8) { |
|||
throw new IllegalArgumentException("Invalid prefix length: " + prefixLength + " for " + ip); |
|||
} |
|||
return new CidrRange(addr, prefixLength); |
|||
} catch (UnknownHostException e) { |
|||
throw new IllegalArgumentException("Invalid IP: " + ip, e); |
|||
} |
|||
} |
|||
|
|||
static CidrRange parse(String entry) throws UnknownHostException { |
|||
int slashIndex = entry.indexOf('/'); |
|||
if (slashIndex >= 0) { |
|||
String ip = entry.substring(0, slashIndex); |
|||
int prefix = Integer.parseInt(entry.substring(slashIndex + 1)); |
|||
byte[] addr = InetAddress.getByName(ip).getAddress(); |
|||
if (prefix < 0 || prefix > addr.length * 8) { |
|||
throw new IllegalArgumentException("Invalid prefix length: " + prefix + " for " + entry); |
|||
} |
|||
return new CidrRange(addr, prefix); |
|||
} else { |
|||
byte[] addr = InetAddress.getByName(entry).getAddress(); |
|||
return new CidrRange(addr, addr.length * 8); |
|||
} |
|||
} |
|||
|
|||
boolean contains(InetAddress address) { |
|||
byte[] addr = address.getAddress(); |
|||
if (addr.length != network.length) { |
|||
return false; |
|||
} |
|||
int fullBytes = prefixLength / 8; |
|||
int remainingBits = prefixLength % 8; |
|||
for (int i = 0; i < fullBytes; i++) { |
|||
if (addr[i] != network[i]) { |
|||
return false; |
|||
} |
|||
} |
|||
if (remainingBits > 0 && fullBytes < addr.length) { |
|||
int mask = 0xFF << (8 - remainingBits); |
|||
if ((addr[fullBytes] & mask) != (network[fullBytes] & mask)) { |
|||
return false; |
|||
} |
|||
} |
|||
return true; |
|||
} |
|||
|
|||
@Override |
|||
public String toString() { |
|||
try { |
|||
return InetAddress.getByAddress(network).getHostAddress() + "/" + prefixLength; |
|||
} catch (UnknownHostException e) { |
|||
return "invalid/" + prefixLength; |
|||
} |
|||
} |
|||
} |
|||
|
|||
} |
|||
@ -0,0 +1,338 @@ |
|||
/** |
|||
* Copyright © 2016-2026 The Thingsboard Authors |
|||
* |
|||
* Licensed under the Apache License, Version 2.0 (the "License"); |
|||
* you may not use this file except in compliance with the License. |
|||
* You may obtain a copy of the License at |
|||
* |
|||
* http://www.apache.org/licenses/LICENSE-2.0
|
|||
* |
|||
* Unless required by applicable law or agreed to in writing, software |
|||
* distributed under the License is distributed on an "AS IS" BASIS, |
|||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|||
* See the License for the specific language governing permissions and |
|||
* limitations under the License. |
|||
*/ |
|||
package org.thingsboard.common.util; |
|||
|
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.parallel.ResourceLock; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.ValueSource; |
|||
|
|||
import java.net.URI; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThatNoException; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
@ResourceLock("SsrfProtectionValidatorTest") // some tests mutate static additional-blocked-hosts
|
|||
public class SsrfProtectionValidatorTest { |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://example.com", |
|||
"https://example.com:8443/path", |
|||
"https://8.8.8.8/dns-query" |
|||
}) |
|||
void testAllowedUrls(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://127.0.0.1", |
|||
"http://127.0.0.1:8080/path", |
|||
"http://127.1.2.3" |
|||
}) |
|||
void testBlockedLoopbackIpv4(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedLocalhost() { |
|||
URI uri = URI.create("http://localhost/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedIpv6Loopback() { |
|||
URI uri = URI.create("http://[::1]/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://169.254.169.254/latest/meta-data/", |
|||
"http://169.254.169.254/latest/meta-data/iam/security-credentials/", |
|||
"http://169.254.1.1" |
|||
}) |
|||
void testBlockedLinkLocalImds(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://10.0.0.1", |
|||
"http://10.255.255.255", |
|||
"http://172.16.0.1", |
|||
"http://172.31.255.255", |
|||
"http://192.168.1.1", |
|||
"http://192.168.0.100:8080/api" |
|||
}) |
|||
void testBlockedPrivateRfc1918(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
// 100.64.0.0/10 — Carrier-Grade NAT (RFC 6598): Alibaba Cloud metadata (100.100.100.200), Tencent Cloud (100.88.222.5)
|
|||
"http://100.100.100.200", |
|||
"http://100.88.222.5", |
|||
"http://100.64.0.1", |
|||
"http://100.127.255.255", |
|||
// 192.0.0.0/24 — IANA reserved (RFC 6890): Oracle Cloud alternate metadata (192.0.0.192)
|
|||
"http://192.0.0.192", |
|||
"http://192.0.0.1", |
|||
// 168.63.129.16 — Azure WireServer
|
|||
"http://168.63.129.16" |
|||
}) |
|||
void testBlockedCloudMetadataEndpoints(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
// Just outside 100.64.0.0/10
|
|||
"http://100.128.0.1", |
|||
// Just outside 192.0.0.0/24
|
|||
"http://192.0.1.1", |
|||
// Adjacent to Azure WireServer
|
|||
"http://168.63.129.17" |
|||
}) |
|||
void testAllowedNearCloudMetadataBoundaries(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"file:///etc/passwd", |
|||
"ftp://internal.host/file" |
|||
}) |
|||
void testBlockedSchemes(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("only HTTP and HTTPS schemes are allowed"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedZeroAddress() { |
|||
URI uri = URI.create("http://0.0.0.0"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = { |
|||
"http://server.internal", |
|||
"http://app.local" |
|||
}) |
|||
void testBlockedHostnameSuffixes(String url) { |
|||
URI uri = URI.create(url); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedNullScheme() { |
|||
URI uri = URI.create("//example.com/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("only HTTP and HTTPS schemes are allowed"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedEmptyHost() { |
|||
URI uri = URI.create("http:///path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("hostname is missing"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedUnresolvableHostname() { |
|||
URI uri = URI.create("http://host.invalid.tld.that.does.not.exist/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("unable to resolve hostname"); |
|||
} |
|||
|
|||
@Test |
|||
void testBlockedLocalhostCaseInsensitive() { |
|||
URI uri = URI.create("http://LOCALHOST/path"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} |
|||
|
|||
@Test |
|||
void testDisabledAllowsPrivateAddresses() { |
|||
URI uri = URI.create("http://127.0.0.1"); |
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, false)); |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedSingleIp() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("8.8.8.8")); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8/dns-query"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Adjacent IP is not blocked
|
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.9"), true)); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedCidrSlash10() { |
|||
try { |
|||
// Use 44.0.0.0/10 (not blocked by default) to verify CIDR /10 matching
|
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("44.0.0.0/10")); |
|||
// Inside the range
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.0.1.1"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Last address in the range
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.63.255.255"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Outside the range
|
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.64.0.1"), true)); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedCidrSlash24() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("198.51.100.0/24")); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.0"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.255"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Outside the range
|
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.101.0"), true)); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedHostnameViaValidateUri() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("evil.corp")); |
|||
URI uri = URI.create("http://evil.corp/api"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedHostnameCaseInsensitive() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("My-Service.Corp")); |
|||
URI uri = URI.create("http://my-service.corp/api"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testSetAdditionalBlockedHostsEmptyAndNull() { |
|||
// Should not throw
|
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(null); |
|||
} |
|||
|
|||
@Test |
|||
void testCidrRangeInvalidPrefixLength() { |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/999")) |
|||
.isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessageContaining("Invalid prefix length"); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/-1")) |
|||
.isInstanceOf(IllegalArgumentException.class) |
|||
.hasMessageContaining("Invalid prefix length"); |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedCidrViaValidateUri() { |
|||
// 203.0.113.0/24 (TEST-NET-3) is not blocked by default
|
|||
URI uri = URI.create("http://203.0.113.1"); |
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)); |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24")); |
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void testAdditionalBlockedMixedConfig() { |
|||
try { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24", "evil.corp", "8.8.8.8")); |
|||
// CIDR range
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://203.0.113.50"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Hostname
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://evil.corp/api"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Single IP
|
|||
assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8"), true)) |
|||
.isInstanceOf(RuntimeException.class) |
|||
.hasMessageContaining("URI is invalid"); |
|||
// Not in any additional block list
|
|||
assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://1.1.1.1"), true)); |
|||
} finally { |
|||
SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); |
|||
} |
|||
} |
|||
|
|||
} |
|||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue