Random tech thoughts

I better write some things down before there're forgotten

Private and Public Apis on Different Ports With Spring

I recently had to create a (micro-) service, offering APIs to a user frontend and to other internal services. The frontend apis were supposed to require authentication by the user/browser and the internal apis were to work without authentication. But because the internal apis represented sensitive functionality, they should only by available to internal services.

When creating a HTTP/REST microservice with Spring, the service usually binds to one tcp port. So every client which is able to connect to this port is also able call all the endpoints of the services. This poses a problem, when one needs to offer private and publics apis on the same service.

There are several options how to control access to different endpoints. One could create different services and bind them to different hosts or ports and have a firewall control the access. Or one might have a reverse proxy in front of the service, and regulate access with it.

One easy way is to have the same service offer different apis on different ports. It requires some more advanced configuration of spring, but has the advantage of keeping all functionality in one service. The only external requirement for limitting access to the private apis is some kind of firewalling to restrict access to the private tcp port.

Here is how it’s done with Spring.

Different path-prefixes for internal apis

It all starts with two controller methods, one offering an internal endpoint, the other one offering an external endpoint.

1
2
3
4
5
6
7
@Controller
public class ExternalApiController {
    @GetMapping("/external/hello")
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("Hello stranger");
    }
}
1
2
3
4
5
6
7
@Controller
public class InternalApiController {
    @GetMapping("/internal/hello")
    public ResponseEntity<String> hello() {
        return ResponseEntity.ok("Hello friend");
    }
}

Note, that one controller serves under a path starting with /external, the other under /internal. This will be used later to systematically distinguish requests.

By default, these two endpoints are available at the same port and therefor it’s difficult to restrict access to the internal endpoint.

Listen on multiple ports

To change this, we first have to make spring listen on a second port. The internal tomcat server allows to listen on additional ports through a WebServerFactoryCustomizer, which can be provided as a bean.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
@Configuration
public class TrustedPortConfiguration {

    @Value("${server.port:8080}")
    private String serverPort;


    @Value("${management.port:${server.port:8080}}")
    private String managementPort;


    @Value("${server.trustedPort:null}")
    private String trustedPort;

    @Bean
    public WebServerFactoryCustomizer servletContainer() {

        Connector[] additionalConnectors = this.additionalConnector();

        ServerProperties serverProperties = new ServerProperties();
        return new TomcatMultiConnectorServletWebServerFactoryCustomizer(serverProperties, additionalConnectors);
    }


    private Connector[] additionalConnector() {

        if (StringUtils.isEmpty(this.trustedPort) || "null".equals(trustedPort)) {
            return null;
        }

        Set<String> defaultPorts = new HashSet<>();
        defaultPorts.add(serverPort);
        defaultPorts.add(managementPort);

        if (!defaultPorts.contains(trustedPort)) {
            Connector connector = new Connector("org.apache.coyote.http11.Http11NioProtocol");
            connector.setScheme("http");
            connector.setPort(Integer.valueOf(trustedPort));
            return new Connector[]{connector};
        } else {
            return new Connector[]{};
        }
    }

    private class TomcatMultiConnectorServletWebServerFactoryCustomizer extends TomcatServletWebServerFactoryCustomizer {
        private final Connector[] additionalConnectors;

        TomcatMultiConnectorServletWebServerFactoryCustomizer(ServerProperties serverProperties, Connector[] additionalConnectors) {
            super(serverProperties);
            this.additionalConnectors = additionalConnectors;
        }

        @Override
        public void customize(TomcatServletWebServerFactory factory) {
            super.customize(factory);

            if (additionalConnectors != null && additionalConnectors.length > 0) {
                factory.addAdditionalTomcatConnectors(additionalConnectors);
            }
        }
    }
}

With this configuration, the internal tomcat listens on two ports, and all endpoints are available on them. Half way there. All that’s left to do is restricting endpoints to a certain port.

Filter requests based on path and port

With spring, all incoming requests can be filtered, before they reach a controller. Filtering means, requests can be modified and answered, and the filter can decide, if the request either should be passed on for further processing, or if processing ends with it.

To write a filter, one must implement Filter and provide public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain). A Response can be generated and the request can be analyzed. If the processing of the request should continue, filterChain.doFilter must be called. Otherwise, the filter just returns.

Here’s one way how to implement a filter in order to filter requests to a internal api based on internal/external ports.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class TrustedEndpointsFilter implements Filter {

    private int trustedPortNum = 0;
    private String trustedPathPrefix;
    private final Logger log = LoggerFactory.getLogger(getClass().getName());

    TrustedEndpointsFilter(String trustedPort, String trustedPathPrefix) {
        if (trustedPort != null && trustedPathPrefix != null && !"null".equals(trustedPathPrefix)) {
            trustedPortNum = Integer.valueOf(trustedPort);
            this.trustedPathPrefix = trustedPathPrefix;
        }
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        if (trustedPortNum != 0) {

            if (isRequestForTrustedEndpoint(servletRequest) && servletRequest.getLocalPort() != trustedPortNum) {
                log.warn("denying request for trusted endpoint on untrusted port");
                ((ResponseFacade) servletResponse).setStatus(404);
                servletResponse.getOutputStream().close();
                return;
            }

            if (!isRequestForTrustedEndpoint(servletRequest) && servletRequest.getLocalPort() == trustedPortNum) {
                log.warn("denying request for untrusted endpoint on trusted port");
                ((ResponseFacade) servletResponse).setStatus(404);
                servletResponse.getOutputStream().close();
                return;
            }
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    private boolean isRequestForTrustedEndpoint(ServletRequest servletRequest) {
        return ((RequestFacade) servletRequest).getRequestURI().startsWith(trustedPathPrefix);
    }
}

The filter decides based on a configurable prefix of the request path, if this request is for an internal or an external endpoint and checks, if it arrived on the right port. If so, the request is passed on. If not, it is rejected with a status 404.

To activate the filter, it must be provided as a bean.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Value("${server.trustedPort:null}")
    private String trustedPort;

    @Value("${server.trustedPathPrefix:null}")
    private String trustedPathPrefix;

    @Bean
    public FilterRegistrationBean<TrustedEndpointsFilter> trustedEndpointsFilter() {
        return new FilterRegistrationBean<>(new TrustedEndpointsFilter(trustedPort, trustedPathPrefix));
    }
}

Configuration

Finally, some configuration has to be provided, to define ports and the paths.

1
2
3
server.port=8002
server.trustedPort=8003
server.trustedPathPrefix=/internal/

All requests to a path starting with server.trustedPathPrefix are served on server.trustedPort and all other requests are served on server.port.

Conclusion

Spring is incredible flexible and allows quite sophisticated configurations. It is easily possible to configure it to serve different endpoints on different tcp ports. With that one can serve internal apis and external apis using the same spring server instance and control access to them using firewalling.

You can find the complete example at my Github.

Comments