Browse Source
Add SsrfSafeAddressResolverGroup that validates resolved IPs at Netty connection time, eliminating the TOCTOU gap where DNS rebinding domains resolve to safe IPs during validation but to private/metadata IPs at connection time. Disable HTTP redirects in TbHttpClient to prevent redirect-based SSRF bypass. Add allow-list support (SSRF_ALLOWED_HOSTS) to SsrfProtectionValidator so customers with IoT devices on private networks can whitelist specific addresses or CIDR ranges while keeping SSRF protection enabled. Add SSRF validation to MS Teams webhook, custom OAuth2 mapper, and GitHub OAuth2 mapper endpoints. Log a warning when SSRF protection is disabled.pull/15253/head
10 changed files with 470 additions and 3 deletions
@ -0,0 +1,135 @@ |
|||
/** |
|||
* 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.rule.engine.rest; |
|||
|
|||
import io.netty.resolver.AddressResolver; |
|||
import io.netty.resolver.AddressResolverGroup; |
|||
import io.netty.resolver.DefaultAddressResolverGroup; |
|||
import io.netty.util.concurrent.EventExecutor; |
|||
import io.netty.util.concurrent.Future; |
|||
import io.netty.util.concurrent.Promise; |
|||
import org.thingsboard.common.util.SsrfProtectionValidator; |
|||
|
|||
import java.net.InetAddress; |
|||
import java.net.InetSocketAddress; |
|||
import java.net.SocketAddress; |
|||
import java.util.List; |
|||
import java.util.stream.Collectors; |
|||
|
|||
/** |
|||
* Custom Netty {@link AddressResolverGroup} that validates every resolved IP address |
|||
* against the SSRF block-list at connection time. This eliminates the DNS rebinding |
|||
* TOCTOU gap where a hostname resolves to a safe IP during validation but to a |
|||
* private/metadata IP when the actual connection is made. |
|||
*/ |
|||
public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup<InetSocketAddress> { |
|||
|
|||
public static final SsrfSafeAddressResolverGroup INSTANCE = new SsrfSafeAddressResolverGroup(); |
|||
|
|||
private SsrfSafeAddressResolverGroup() { |
|||
} |
|||
|
|||
@Override |
|||
protected AddressResolver<InetSocketAddress> newResolver(EventExecutor executor) throws Exception { |
|||
AddressResolver<InetSocketAddress> delegate = DefaultAddressResolverGroup.INSTANCE.getResolver(executor); |
|||
return new SsrfValidatingResolver(executor, delegate); |
|||
} |
|||
|
|||
private static final class SsrfValidatingResolver implements AddressResolver<InetSocketAddress> { |
|||
|
|||
private final EventExecutor executor; |
|||
private final AddressResolver<InetSocketAddress> delegate; |
|||
|
|||
SsrfValidatingResolver(EventExecutor executor, AddressResolver<InetSocketAddress> delegate) { |
|||
this.executor = executor; |
|||
this.delegate = delegate; |
|||
} |
|||
|
|||
@Override |
|||
public boolean isSupported(SocketAddress address) { |
|||
return delegate.isSupported(address); |
|||
} |
|||
|
|||
@Override |
|||
public boolean isResolved(SocketAddress address) { |
|||
return delegate.isResolved(address); |
|||
} |
|||
|
|||
@Override |
|||
public Future<InetSocketAddress> resolve(SocketAddress address) { |
|||
return resolve(address, executor.newPromise()); |
|||
} |
|||
|
|||
@Override |
|||
public Future<InetSocketAddress> resolve(SocketAddress address, Promise<InetSocketAddress> promise) { |
|||
delegate.resolve(address).addListener((Future<InetSocketAddress> future) -> { |
|||
if (!future.isSuccess()) { |
|||
promise.tryFailure(future.cause()); |
|||
return; |
|||
} |
|||
InetSocketAddress resolved = future.getNow(); |
|||
if (SsrfProtectionValidator.isEnabled() && isBlocked(resolved)) { |
|||
promise.tryFailure(new RuntimeException( |
|||
"SSRF protection: resolved address " + resolved.getAddress().getHostAddress() + " is blocked")); |
|||
} else { |
|||
promise.trySuccess(resolved); |
|||
} |
|||
}); |
|||
return promise; |
|||
} |
|||
|
|||
@Override |
|||
public Future<List<InetSocketAddress>> resolveAll(SocketAddress address) { |
|||
return resolveAll(address, executor.newPromise()); |
|||
} |
|||
|
|||
@Override |
|||
public Future<List<InetSocketAddress>> resolveAll(SocketAddress address, Promise<List<InetSocketAddress>> promise) { |
|||
delegate.resolveAll(address).addListener((Future<List<InetSocketAddress>> future) -> { |
|||
if (!future.isSuccess()) { |
|||
promise.tryFailure(future.cause()); |
|||
return; |
|||
} |
|||
List<InetSocketAddress> resolved = future.getNow(); |
|||
if (!SsrfProtectionValidator.isEnabled()) { |
|||
promise.trySuccess(resolved); |
|||
return; |
|||
} |
|||
List<InetSocketAddress> safe = resolved.stream() |
|||
.filter(addr -> !isBlocked(addr)) |
|||
.collect(Collectors.toList()); |
|||
if (safe.isEmpty()) { |
|||
String host = address instanceof InetSocketAddress isa ? isa.getHostString() : address.toString(); |
|||
promise.tryFailure(new RuntimeException( |
|||
"SSRF protection: all resolved addresses for " + host + " are blocked")); |
|||
} else { |
|||
promise.trySuccess(safe); |
|||
} |
|||
}); |
|||
return promise; |
|||
} |
|||
|
|||
@Override |
|||
public void close() { |
|||
delegate.close(); |
|||
} |
|||
|
|||
private static boolean isBlocked(InetSocketAddress socketAddress) { |
|||
InetAddress addr = socketAddress.getAddress(); |
|||
return addr != null && SsrfProtectionValidator.isBlockedAddress(addr); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,150 @@ |
|||
/** |
|||
* 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.rule.engine.rest; |
|||
|
|||
import io.netty.channel.nio.NioEventLoopGroup; |
|||
import io.netty.resolver.AddressResolver; |
|||
import io.netty.util.concurrent.EventExecutor; |
|||
import io.netty.util.concurrent.Future; |
|||
import io.netty.util.concurrent.Promise; |
|||
import org.junit.jupiter.api.AfterAll; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeAll; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.parallel.ResourceLock; |
|||
import org.thingsboard.common.util.SsrfProtectionValidator; |
|||
|
|||
import java.net.InetAddress; |
|||
import java.net.InetSocketAddress; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
import java.util.concurrent.ExecutionException; |
|||
import java.util.concurrent.TimeUnit; |
|||
|
|||
import static org.assertj.core.api.Assertions.assertThat; |
|||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
|||
|
|||
@ResourceLock("SsrfSafeAddressResolverGroupTest") |
|||
class SsrfSafeAddressResolverGroupTest { |
|||
|
|||
private static NioEventLoopGroup eventLoopGroup; |
|||
|
|||
@BeforeAll |
|||
static void setUp() { |
|||
eventLoopGroup = new NioEventLoopGroup(1); |
|||
} |
|||
|
|||
@AfterAll |
|||
static void tearDown() { |
|||
eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); |
|||
SsrfProtectionValidator.setEnabled(false); |
|||
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); |
|||
} |
|||
|
|||
@BeforeEach |
|||
void enableSsrf() { |
|||
SsrfProtectionValidator.setEnabled(true); |
|||
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); |
|||
} |
|||
|
|||
@AfterEach |
|||
void resetState() { |
|||
SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); |
|||
SsrfProtectionValidator.setEnabled(false); |
|||
} |
|||
|
|||
@Test |
|||
void isBlockedAddressWorksForLoopback() throws Exception { |
|||
assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("127.0.0.1"))).isTrue(); |
|||
assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("192.168.1.1"))).isTrue(); |
|||
assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("8.8.8.8"))).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void resolvePublicIpSucceeds() throws Exception { |
|||
EventExecutor executor = eventLoopGroup.next(); |
|||
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); |
|||
Promise<InetSocketAddress> promise = executor.newPromise(); |
|||
|
|||
executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("example.com", 80), promise)); |
|||
InetSocketAddress result = promise.get(10, TimeUnit.SECONDS); |
|||
|
|||
assertThat(result.getAddress()).isNotNull(); |
|||
assertThat(result.getAddress().isLoopbackAddress()).isFalse(); |
|||
assertThat(result.getAddress().isSiteLocalAddress()).isFalse(); |
|||
} |
|||
|
|||
@Test |
|||
void resolveLoopbackFailsWhenSsrfEnabled() throws Exception { |
|||
assertThat(SsrfProtectionValidator.isEnabled()).isTrue(); |
|||
|
|||
EventExecutor executor = eventLoopGroup.next(); |
|||
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); |
|||
Promise<InetSocketAddress> promise = executor.newPromise(); |
|||
|
|||
executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("127.0.0.1", 80), promise)); |
|||
|
|||
assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS)) |
|||
.isInstanceOf(ExecutionException.class) |
|||
.hasRootCauseInstanceOf(RuntimeException.class) |
|||
.rootCause().hasMessageContaining("SSRF protection"); |
|||
} |
|||
|
|||
@Test |
|||
void resolvePrivateIpFailsWhenSsrfEnabled() throws Exception { |
|||
assertThat(SsrfProtectionValidator.isEnabled()).isTrue(); |
|||
|
|||
EventExecutor executor = eventLoopGroup.next(); |
|||
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); |
|||
Promise<InetSocketAddress> promise = executor.newPromise(); |
|||
|
|||
executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("192.168.1.1", 80), promise)); |
|||
|
|||
assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS)) |
|||
.isInstanceOf(ExecutionException.class) |
|||
.hasRootCauseInstanceOf(RuntimeException.class) |
|||
.rootCause().hasMessageContaining("SSRF protection"); |
|||
} |
|||
|
|||
@Test |
|||
void resolveAllowedPrivateIpSucceeds() throws Exception { |
|||
SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24")); |
|||
|
|||
EventExecutor executor = eventLoopGroup.next(); |
|||
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); |
|||
Promise<InetSocketAddress> promise = executor.newPromise(); |
|||
|
|||
executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("192.168.1.1", 80), promise)); |
|||
InetSocketAddress result = promise.get(10, TimeUnit.SECONDS); |
|||
|
|||
assertThat(result.getAddress().getHostAddress()).isEqualTo("192.168.1.1"); |
|||
} |
|||
|
|||
@Test |
|||
void resolveAllReturnsAllWhenSsrfDisabled() throws Exception { |
|||
SsrfProtectionValidator.setEnabled(false); |
|||
|
|||
EventExecutor executor = eventLoopGroup.next(); |
|||
AddressResolver<InetSocketAddress> resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); |
|||
Promise<List<InetSocketAddress>> promise = executor.newPromise(); |
|||
|
|||
executor.submit(() -> resolver.resolveAll(InetSocketAddress.createUnresolved("127.0.0.1", 80), promise)); |
|||
List<InetSocketAddress> results = promise.get(10, TimeUnit.SECONDS); |
|||
|
|||
assertThat(results).isNotEmpty(); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue