3

Often I work on Spring Boot Java applications (built with Gradle or Maven) that make network calls to external services (e.g. to external REST-like APIs). When running automated (JUnit) tests, I do not want these calls to happen, even by accident. Calling a real endpoint (even a "dev" environment one designed for testing) could affect external state, cause needless server workload, or unintentionally reveal information to the endpoint. It might also cause the test to inadvertently depend on that external resource being up, leading to build failures if the resource ever goes down while building the project, or when building the project offline.

What should typically happen with these calls is that they should either be mocked out to do nothing or should point to a service on the same machine (e.g. a WireMock endpoint on localhost spun up by the test, or a local Docker container spun up by the test using Testcontainers).

What I tend to in these situations is create a test-specific bean definition profile that overrides all HTTP endpoints with something obviously bogus that is guaranteed not to route somewhere, e.g. http://example.invalid/bookSearch. That way, if a developer misses mocking out or changing the endpoint in a test, it won't call a real one and trigger real side effects.

However, this approach is potentially error prone, and oversights in the implementation or with future changes could cause network calls to still be made. Examples include:

  1. A new endpoint could be added by a developer without remembering to or knowing that they needed to override the test route
  2. An endpoint could be missed being overridden in the first place
  3. A third party library could be making a request that the developer didn't know about or wasn't accounted for

This is mostly a concern with Spring integration tests that spin up some or all of the environment (e.g. using @SpringBootTest or @ExtendWith(SpringExtension.class)). However, this could conceivably apply to a unit test if a component used something as a non-required constructor argument that connected to the network, or if a developer passed in a real network-connecting component where they should have used a mock or something connecting to localhost.

It occurs to me that there might be a more bullet-proof solution to this scenario. For instance, perhaps there is a way to tell the JVM or its underlying HTTP/FTP/etc. libraries to block all external network traffic.

Is there a way to prevent nonlocal network access in Java Spring Boot JUnit tests, or otherwise provide a guarantee that external network endpoints will not be called?

M. Justin
  • 14,487
  • 7
  • 91
  • 130

7 Answers7

2

Redirecting connections to a non-existing proxy should provide the behavior you are asking for.

Networking (system) properties to use:

for HTTP:

  • http.proxyHost
  • http.proxyPort

for HTTPS:

  • https.proxyHost
  • https.proxyPort

for FTP:

  • ftp.proxyHost
  • ftp.proxyPort

SOCKS proxy settings could be used to intercept all TCP traffic:

  • socksProxyHost
  • socksProxyPort

Specifying localhost for any of the *Host properties above will force JVM to try to connect to the host where the build is running. The connection attempt will fail as long as no process is listening on the port. SOCKS port 1080 looks safe for any of the *Port properties above.

Another option would be to use something like WireMock to open a port on the build host and redirect all TCP traffic there but drop the connection on the WireMock side as soon as it is established.

For build systems based on Maven - check Maven Surefire Plugin documentation on how to specify the system properties for the tests:

  <plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.0.0-M5</version>
    <configuration>
      <systemPropertyVariables>
        <socksProxyHost>localhost</socksProxyHost>
        <socksProxyPort>1080</socksProxyPort>
        [...]
      </systemPropertyVariables>
    </configuration>
  </plugin>
Illya Kysil
  • 1,642
  • 10
  • 18
  • Worth noting that the proxy settings (other than perhaps the SOCKS proxy?) specifically exclude the loopback address from being proxied, unless specified otherwise. This is exactly what I want for my use case, since I didn't want localhost requests blocked. – M. Justin May 18 '21 at 18:30
  • I've confirmed setting `http.proxyHost` or `socksProxyHost` to an invalid URL is successfully causing the HTTP call to fail. When using [HTTP Components](https://hc.apache.org/httpcomponents-client-5.1.x/), I did discover that it still tried to resolve the DNS entry of the (non-proxy) endpoint URL (failing if the host was not reachable, or succeeding if it was), though accessing the endpoint still happened through the proxy. On the other hand, `http.proxyHost` did not. I did not see this behavior when using `java.net.URL` directly, so this is a library-specific thing. – M. Justin May 18 '21 at 19:19
  • Checked into it, and the SOCKS proxy did not attempt to proxy `localhost`, despite there being no documentation regarding it. Perhaps it's related to the undocumented [socksNonProxyHosts](https://stackoverflow.com/a/27728848/1108305) property. ‍♂️ – M. Justin May 18 '21 at 19:31
2

You can use Sniffy to disable network in your tests - see example here.

    @Rule public SniffyRule sniffyRule = new SniffyRule();

    @Test
    @DisableSockets
    public void testDisableSockets() throws IOException {
        try {
            new Socket("google.com", 443);
            fail("Sniffy should have thrown ConnectException");
        } catch (ConnectException e) {
            assertNotNull(e);
        }
    }

Some insight on implementation: Sniffy installs a custom SocketImplFactory and other network primitives such as NIO SelectorProvider in order to inject delays or even completelly disable connectivity to selected or all hosts.

Disclaimer: I'm author of Sniffy

bedrin
  • 4,458
  • 32
  • 53
  • Does Sniffy support JUnit 5 "Jupiter" yet? That's what I'm currently using, so something using the JUnit 4 API (`@Rule`) would not be a good fit for me. I'm not seeing anything in the Sniffy [GitHub repo](https://github.com/sniffy/sniffy) or [documentation](https://sniffy.io/docs/latest) indicating JUnit 5 support, nor am I seeing any GitHub tickets (open or otherwise) mentioning JUnit 5 support. – M. Justin May 19 '21 at 17:25
  • My own JUnit 5 requirements aside, this looks like a great solution for JUnit 4, though not a bulletproof one — it would have to be enabled on every test, and I'm guessing it wouldn't catch any network accesses that may occur during Spring shutdown that happens after the tests have run (since identical contexts are shared between tests). But it does look to be a good tool for the testing toolbelt. Thanks for the answer! – M. Justin May 19 '21 at 17:39
  • @M.Justin There's a [ticket to support JUnit 5](https://github.com/sniffy/sniffy/issues/238) but it is not prioritized yet. Spring test framework is [already supported](https://sniffy.io/docs/latest/#_integration_with_spring_framework) but againt it would work only for the scope of single test method. You can raise an issue to support disabling connectivity for the whole context life cycle or something – bedrin May 19 '21 at 21:56
  • 1
    I tested `sniffy-spring-test`, and confirmed that `@DisableSockets` works for my JUnit 5 `@SpringBootTest` test. One place this fails for my specific needs is that `@DisableSockets` also blocks `localhost`, which means using WireMock to provide a local endpoint will not work with this solution. – M. Justin May 19 '21 at 22:39
  • 1
    @M.Justin That's a great idea. I've raised [an issue](https://github.com/sniffy/sniffy/issues/491) to support ignoring localhost when using _@DisableSockets_ – bedrin May 20 '21 at 07:03
0

I don't know of a way to do this within the JVM environment.

It sounds like you are talking more about functional/integration testing than unit testing. I suspect, docker would probably work well since you can isolate the network for a container explicitly. I personally use WireMock with docker compose to do something very similar.

Andrew White
  • 52,720
  • 19
  • 113
  • 137
0

I thought I'd throw another idea on the stack. What if you could disable/alter the DNS settings of the JVM so address resolution was intentionally broken?

Major pro would be that you don't have to disconnect the JVM and you won't have to break the entire computer's network.

Major cons would be that any direct IPs would probably still function and access their specific services. (Although this might be a Pro, in case you find out there's one or two services that DO need to get through the broken-DNS world you set up.)

Given that it feels reasonable that you could use host names to resolve URIs online rather than direct IPs, it seems like one way to get about crippling connectivity purposefully.

So the question is, how would you break DNS consistently? There are probably a dozen ways to do this, but I thought I'd try looking for network settings that can change this on the fly without affecting anything outside of the test environment JVM itself.

What I discovered was I WAS able to set a few properties programmatically in Java 8 that could effectively do this. Here's the result of that attempt.

Caveats

  • This property is not is supported in Java 9 or later (JDK-8134577, JDK-8192780). It does appear in the JDK7 and JDK8 documentation, and I tested mine with JDK8 and it had the desired effect. (Seems as recent as last year a lot of us still use JDK8)
  • I haven't exhaustively checked to see if this method of connecting to things flows to every other socket/network client, but I believe it is wide.

Test Code

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.URL;
import java.net.URLConnection;
import java.time.Duration;
import java.time.Instant;

public class BlockNetworkByProperty {

    public static void main(String[] args) throws Exception {

        System.setProperty("sun.net.spi.nameservice.nameservers", "192.168.0.254"); // Pick any dead IP.
        System.setProperty("sun.net.spi.nameservice.provider.1", "dns,sun");
        
        Instant start = Instant.now();
        System.out.println("Attempting connection.. ");

        try {
            
        URL oracle = new URL("https://www.cnet.com/");
        URLConnection yc = oracle.openConnection();
        BufferedReader in = new BufferedReader(new InputStreamReader(
            yc.getInputStream()));

        String inputLine;
        while ((inputLine = in.readLine()) != null)
            System.out.println(inputLine);
        in.close();

        System.out.println("Should not have gotten here.");
        } finally {
            long secondsPassed = Duration.between(start, Instant.now()).getSeconds();
            System.out.println(String.format("%s seconds have passed.", secondsPassed));
        }
        
    }

}

Test Results Sysout

Attempting connection.. 
60 seconds have passed.
Exception in thread "main" java.net.UnknownHostException: www.cnet.com
at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:184)
...stack trace continues

Other Relevant SOs

How to configure hostname resolution to use a custom DNS server in Java?

How to override DNS in HTTP connections in Java

M. Justin
  • 14,487
  • 7
  • 91
  • 130
Atmas
  • 2,389
  • 5
  • 13
  • 1
    Confirmed this does not work for me on Java 14 (AdoptOpenJDK 14.0.2+12). This exact example prints the site HTML along with "Should not have gotten here." – M. Justin May 18 '21 at 19:40
  • Yep. Suspected as much. That's too bad. I wonder if there's new properties that could achieve the same end, but based on quickly scanning I didn't see them immediately. I think it might be useful for the significant amount of J8 users, so I'll leave the answer up for posterity. Thanks for trying it out! – Atmas May 18 '21 at 20:00
  • 1
    Ok, I just tried checking for your specific JDK too just in case and didn't see an alternative, but it looks like based on Oracle Java discussions, it is definitely dead as of J9, although looks like google engineers at one point offered to spend time to resurrect it in 2017.. So maybe some day! Per issues: https://bugs.java.com/bugdatabase/view_bug.do?bug_id=JDK-8134577 -- and https://bugs.java.com/bugdatabase/view_bug.do?bug_id=8192780 – Atmas May 18 '21 at 20:08
  • I've updated your answer to indicate that the property is not supported in Java 9+. – M. Justin May 18 '21 at 20:43
  • Good improvement. Thanks. – Atmas May 18 '21 at 21:01
0

Network connections in Java appear to all ultimately use SocketImpl to perform the actual network access. These are created by the Socket and ServerSocket classes.

Therefore, network access can be blocked by replacing the SocketImpl used. Specifically, the replacement SocketImpl should block any external network traffic, while delegating any local network traffic to the standard SocketImpl.

To block the SocketImpl globally, a JUnit 5 Extension could be written to replace the socket factories used by Socket and ServerSocket:

public class DisableRemoteSocketsExtension implements BeforeAllCallback {
    private static final AtomicBoolean APPLIED = new AtomicBoolean(false);

    @Override
    public void beforeAll(ExtensionContext context) throws Exception {
        if (!APPLIED.get()) {
            System.out.println("Globally disabling non-loopback sockets");

            Socket.setSocketImplFactory(DisableRemoteSocketImpl::forSocket);
            ServerSocket.setSocketFactory(DisableRemoteSocketImpl::forServerSocket);

            APPLIED.set(true);
        }
    }
}

To ensure that this extension is always used, and not require the developer to annotate each test with @ExtendWith, this extension can be registered automatically using the automatic extension registration functionality:

src/test/resources/META-INF/services/org.junit.jupiter.api.extension.Extension

com.example.DisableRemoteSocketsExtension

src/test/resources/junit-platform.properties

junit.jupiter.extensions.autodetection.enabled=true

As a number of the classes and methods involved are package-private to the java.net package, reflective workarounds are needed to delegate to the underlying SocketImpl methods in order to forward local traffic. Here is one possible implementation:

public class DisableRemoteSocketImpl extends SocketImpl {
    private static final Pattern LOOPBACK_PATTERN =
            Pattern.compile("^localhost$|^127(?:\\.[0-9]+){0,2}\\.[0-9]+$|^(?:0*:)*?:?0*1$");

    private final SocketImpl delegate;

    public DisableRemoteSocketImpl(SocketImpl delegate) {
        this.delegate = delegate;
    }

    public static DisableRemoteSocketImpl forSocket() {
        // Mimics implementation in Socket.setImpl()
        SocketImpl delegate = newSocksSocketImpl(getDefaultSocketImpl());
        return new DisableRemoteSocketImpl(delegate);
    }

    public static DisableRemoteSocketImpl forServerSocket() {
        // Mimics implementation in ServerSocket.setImpl()
        SocketImpl delegate = getDefaultSocketImpl();
        return new DisableRemoteSocketImpl(delegate);
    }

    @Override
    protected void create(boolean stream) throws IOException {
        callDelegate("create", new Class<?>[]{boolean.class}, new Object[]{stream});
    }

    @Override
    protected void connect(String host, int port) throws IOException {
        requireLoopbackAddress(host);
        callDelegate("connect", new Class<?>[]{String.class, int.class}, new Object[]{host, port});
    }

    @Override
    protected void connect(InetAddress address, int port) throws IOException {
        requireLoopbackAddress(address);
        callDelegate("connect", new Class<?>[]{InetAddress.class, int.class}, new Object[]{address, port});
    }

    @Override
    protected void connect(SocketAddress address, int timeout) throws IOException {
        if (!(address instanceof InetSocketAddress)) {
            throw new UnsupportedOperationException("Unsupported address type: " + address);
        }
        requireLoopbackAddress(((InetSocketAddress) address).getHostString());
        callDelegate("connect", new Class<?>[]{SocketAddress.class, int.class}, new Object[]{address, timeout});
    }

    @Override
    protected void bind(InetAddress host, int port) throws IOException {
        requireLoopbackAddress(host);
        callDelegate("bind", new Class<?>[]{InetAddress.class, int.class}, new Object[]{host, port});
    }

    @Override
    protected void listen(int backlog) throws IOException {
        callDelegate("listen", new Class<?>[]{int.class}, new Object[]{backlog});
    }

    @Override
    protected void accept(SocketImpl s) throws IOException {
        callDelegate("accept", new Class<?>[]{SocketImpl.class}, new Object[]{s});
    }

    @Override
    protected InputStream getInputStream() throws IOException {
        return callDelegate("getInputStream", new Class<?>[]{}, new Object[]{});
    }

    @Override
    protected OutputStream getOutputStream() throws IOException {
        return callDelegate("getOutputStream", new Class<?>[]{}, new Object[]{});
    }

    @Override
    protected int available() throws IOException {
        return callDelegate("available", new Class<?>[]{}, new Object[]{});
    }

    @Override
    protected void close() throws IOException {
        callDelegate("close", new Class<?>[]{}, new Object[]{});
    }

    @Override
    protected void sendUrgentData(int data) throws IOException {
        callDelegate("close", new Class<?>[]{int.class}, new Object[]{data});
    }

    @Override
    public void setOption(int optID, Object value) throws SocketException {
        delegate.setOption(optID, value);
    }

    @Override
    public Object getOption(int optID) throws SocketException {
        return delegate.getOption(optID);
    }

    private void requireLoopbackAddress(String host) {
        if (!LOOPBACK_PATTERN.matcher(host).matches()) {
            throw new UnsupportedOperationException("Attempted to connect to remote host: " + host);
        }
    }

    private void requireLoopbackAddress(InetAddress address) {
        if (!address.isLoopbackAddress()) {
            throw new UnsupportedOperationException("Attempted to connect to remote host: " + address);
        }
    }

    private static SocketImpl createDelegate() {
        // Mimics implementation in Socket.setImpl()
        return newSocksSocketImpl(getDefaultSocketImpl());
    }

    private static SocketImpl getDefaultSocketImpl() {
        try {
            Method factoryMethod = SocketImpl.class.getDeclaredMethod("createPlatformSocketImpl", Boolean.TYPE);
            factoryMethod.setAccessible(true);
            return (SocketImpl) factoryMethod.invoke(null, false);
        } catch (ReflectiveOperationException e) {
            throw new RuntimeException(e);
        }
    }

    private static SocketImpl newSocksSocketImpl(SocketImpl delegate) {
        try {
            Constructor<? extends SocketImpl> constructor = Class.forName("java.net.SocksSocketImpl")
                    .asSubclass(SocketImpl.class).getDeclaredConstructor(SocketImpl.class);
            constructor.setAccessible(true);
            return constructor.newInstance(delegate);
        } catch (ReflectiveOperationException e) {
            throw new RuntimeException(e);
        }
    }

    @SuppressWarnings("unchecked")
    private <T> T callDelegate(String methodName, Class<?>[] parameterTypes, Object[] args) throws IOException {
        try {
            Method method = SocketImpl.class.getDeclaredMethod(methodName, parameterTypes);
            method.setAccessible(true);
            return (T) method.invoke(delegate, args);
        } catch (InvocationTargetException invocationTargetException) {
            Throwable e = invocationTargetException.getCause();
            if (e instanceof IOException) {
                throw (IOException) e;
            } else if (e instanceof RuntimeException) {
                throw (RuntimeException) e;
            } else if (e instanceof Error) {
                throw (Error) e;
            } else {
                throw new AssertionError(invocationTargetException);
            }
        } catch (ReflectiveOperationException e) {
            throw new RuntimeException(e);
        }
    }
}
M. Justin
  • 14,487
  • 7
  • 91
  • 130
-1

In Java all TCP network connections are created by the javax.net.SocketFactory. The default instance is stored in javax.net.SocketFactory.theFactory.

I would use Unsafe access to the field (or the corresponding sutff needed for Java 9+) and replace the default socket factory with something that throws Exceptions instead of creating sockets, or at least when sockets are created for somethine else than "127.0.0.1" or "localhost" or whatever name you have mapped to localhost.

I think there is also a javax.net.ssl.SSLSocketFactory, that has to be replaces also. Afterwards you should be safe.

Daniel
  • 27,718
  • 20
  • 89
  • 133
  • As the implementation appears to reflectively access the `getDefault` method in the socket factory, it appears you would be correct about also needing to replace the default in `SSLSocketFactory` despite it extending from `SocketFactory` since it overloads the `SocketFactory.getDefault` method. If any of the other `SocketFactory` classes in use likewise overloaded the socket factory, they'd need to be updated like this as well. – M. Justin May 18 '21 at 19:46
  • Testing this out indicates that not all connections are created by the `SocketFactory`. I tested this approach by downloading from a URL (`Files.copy(new URL("http://www.example.com").openStream(), Files.createTempFile("downloaded", ".html"), StandardCopyOption.REPLACE_EXISTING)`). No `SocketFactory` was used, but instead `sun.net.NetworkClient.createSocket()` created a `Socket` instance directly: `return new java.net.Socket(Proxy.NO_PROXY)`. [NetworkClient.createSocket()](https://github.com/AdoptOpenJDK/openjdk-jdk11/blob/master/src/java.base/share/classes/sun/net/NetworkClient.java#L199) – M. Justin May 18 '21 at 20:30
  • 1
    Just checked. sun.net.NetworkClient.createSocket() calls "new java.net.Socket()", and that constructor uses java.net.Socket.factory as a factory for the SocketImpl. so it's just another Factory to replace I didn't have on my radar. Thanks for noticing. – Daniel May 20 '21 at 08:30
  • I should note I work on Java 8, you seem to have a higher version. – Daniel May 20 '21 at 08:31
  • Yeah, I suspect setting the factory within `Socket` might do the trick. It appears that's what [Sniffy](https://sniffy.io) does, per the [Sniffy anwer](https://stackoverflow.com/a/67598470/1108305) to this question. – M. Justin May 20 '21 at 21:55
  • Incidentally, `Unsafe` doesn't appear to be needed to set the `theFactory` field. Since it's a regular non-final static field, it can be reflectively set so long as it's made accessible first: `Field f = SocketFactory.class.getDeclaredField("theFactory");f.setAccessible(true);f.set(null, new MyCustomSocketFactory());` – M. Justin May 20 '21 at 21:58
-1
docker run --network none

If your tests shouldn't connect to the network, don't rely on the tests being written correctly / specifying a property / using the expected names / etc. Don't even depend on the JVM. Instead, adjust the test environment that the JVM is running in to force the restriction, so that the tests can't possibly pass if someone mistakenly adds a network-based dependency.

The advantage of this is that you probably want some tests (i.e., system tests) to be able to connect to the network, in order to exercise multiple components in a deployed state, so you can selectively apply this lockdown for just the unit tests stage of any automation.

The disadvantage is that you might only detect these failures when running as part of your full pipeline, since if someone writes a networked "unit" test then it might "work on their machine" when run in isolation. You could enforce containers in every place you run the tests, but that is probably more burdensome than is warranted, and it sounds like you're just trying to catch these kinds of unit testing errors, which this would achieve.

Zac Thompson
  • 12,401
  • 45
  • 57