Skip to content

Commit 56ab6af

Browse files
remove allowed auth uris, add more logging
1 parent ac0eb6b commit 56ab6af

9 files changed

Lines changed: 231 additions & 101 deletions

File tree

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package gov.cms.bfd.server.ng;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import java.net.URLDecoder;
5+
import java.nio.charset.StandardCharsets;
6+
import java.util.Arrays;
7+
import java.util.Optional;
8+
import lombok.RequiredArgsConstructor;
9+
import org.apache.commons.lang3.StringUtils;
10+
import org.jetbrains.annotations.NotNull;
11+
import org.springframework.core.env.Environment;
12+
import org.springframework.stereotype.Component;
13+
14+
/** Utility for handling server certificates. */
15+
@RequiredArgsConstructor
16+
@Component
17+
public class CertificateUtil {
18+
private final Configuration configuration;
19+
private final Environment environment;
20+
21+
private static final String LEAF_CERT_HEADER = "X-Amzn-Mtls-Clientcert";
22+
private static final String CLIENT_CERT_ALIAS_ATTRIBUTE = "CLIENT_CERT_ALIAS";
23+
24+
/**
25+
* Returns whether the current configuration is allowed to bypass auth.
26+
*
27+
* @return boolean
28+
*/
29+
public boolean canBypassAuth() {
30+
return Arrays.stream(environment.getActiveProfiles())
31+
.allMatch(Configuration::canProfileBypassAuth);
32+
}
33+
34+
/**
35+
* Returns the cert alias from the request, if found.
36+
*
37+
* @param request request
38+
* @return cert alias
39+
*/
40+
public Optional<String> getAliasFromCert(@NotNull HttpServletRequest request) {
41+
final var rawLeafCert = request.getHeader(LEAF_CERT_HEADER);
42+
if (rawLeafCert == null) {
43+
return Optional.empty();
44+
}
45+
// We need to replace these characters with their URL-encoding counterparts because AWS
46+
// considers them "safe" and therefore does not encode them when sending the leaf certificate
47+
// from the client certificate in the header. So, when we try to URL Decode them, they get lost.
48+
final var encodedLeafCert =
49+
rawLeafCert.replace("+", "%2b").replace("=", "%3d").replace("/", "%2f");
50+
var leafCert =
51+
StringUtils.deleteWhitespace(URLDecoder.decode(encodedLeafCert, StandardCharsets.UTF_8));
52+
53+
final var clientCertsToAliases = configuration.getClientCertsToAliases();
54+
return Optional.ofNullable(clientCertsToAliases.getOrDefault(leafCert, null));
55+
}
56+
57+
/**
58+
* Attaches the alias to the request, so it can be reused.
59+
*
60+
* @param request request
61+
* @param certAlias alias
62+
*/
63+
public void attachCertAliasToRequest(@NotNull HttpServletRequest request, String certAlias) {
64+
request.setAttribute(CLIENT_CERT_ALIAS_ATTRIBUTE, certAlias);
65+
}
66+
67+
/**
68+
* Gets the alias from the request attribute if it was set previously.
69+
*
70+
* @param request request
71+
* @return alias
72+
*/
73+
public Optional<String> getAliasAttribute(@NotNull HttpServletRequest request) {
74+
var alias = request.getAttribute(CLIENT_CERT_ALIAS_ATTRIBUTE);
75+
return Optional.ofNullable(alias).map(a -> (String) a);
76+
}
77+
}

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/Configuration.java

Lines changed: 0 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,19 +30,6 @@ public class Configuration {
3030
/** Identifies which Spring profiles indicate that the server is being run on a local machine. */
3131
private static final List<String> ALLOWED_LOCAL_PROFILES = List.of("local", "sqlprofile");
3232

33-
/** Identifiers which URLS can bypass auth. */
34-
private static final List<String> BYPASS_AUTH_URLS =
35-
List.of(
36-
"/v3/fhir/swagger-ui",
37-
"/v3/fhir/swagger-ui/*",
38-
"/v3/fhir/api-docs",
39-
"/actuator",
40-
// We can allow the basic health endpoint, but other actuator endpoints may show
41-
// things we don't want to expose, so /actuator/* should not be allowed
42-
"/actuator/health",
43-
"/actuator/health/*",
44-
"/favicon.ico");
45-
4633
// Getters should only be generated for configuration properties, not dependencies
4734
@Getter(value = AccessLevel.NONE)
4835
@Autowired
@@ -80,16 +67,6 @@ public static boolean canProfileBypassAuth(String profile) {
8067
return ALLOWED_LOCAL_PROFILES.contains(profile.toLowerCase());
8168
}
8269

83-
/**
84-
* Determines if the URL requires auth.
85-
*
86-
* @param url current url
87-
* @return boolean
88-
*/
89-
public static boolean canUrlBypassAuth(String url) {
90-
return BYPASS_AUTH_URLS.stream().anyMatch(u -> validateUrlGlob(url, u));
91-
}
92-
9370
private static boolean validateUrlGlob(String url, String glob) {
9471
if (glob.endsWith("*")) {
9572
return url.startsWith(glob.substring(0, glob.length() - 1));

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/V3Server.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import ca.uhn.fhir.rest.server.RestfulServer;
66
import gov.cms.bfd.server.ng.interceptor.BanUnsupportedHttpMethodsInterceptor;
77
import gov.cms.bfd.server.ng.interceptor.ExceptionHandlingInterceptor;
8+
import gov.cms.bfd.server.ng.interceptor.LoggingInterceptor;
89
import gov.cms.bfd.server.openapi.OpenApiInterceptor;
910
import jakarta.servlet.annotation.WebServlet;
1011
import java.util.List;
@@ -36,8 +37,9 @@ public void initialize() {
3637

3738
this.setFhirContext(FhirContext.forR4());
3839
this.registerProviders(resourceProviders);
39-
this.registerInterceptor(new BanUnsupportedHttpMethodsInterceptor());
4040

41+
this.registerInterceptor(new LoggingInterceptor());
42+
this.registerInterceptor(new BanUnsupportedHttpMethodsInterceptor());
4143
this.registerInterceptor(new ExceptionHandlingInterceptor());
4244
this.registerInterceptor(new OpenApiInterceptor());
4345
}

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/controller/GlobalExceptionController.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ public String handleError(HttpServletRequest request) {
3636
.atWarn()
3737
.setMessage(responseMessage)
3838
.addKeyValue("statusCode", statusCode)
39-
.addKeyValue("path", request.getRequestURI())
39+
.addKeyValue("originalUri", originalUri)
4040
.log();
4141
return responseMessage;
4242
}
Lines changed: 10 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
11
package gov.cms.bfd.server.ng.filter;
22

3-
import gov.cms.bfd.server.ng.Configuration;
3+
import gov.cms.bfd.server.ng.CertificateUtil;
44
import jakarta.servlet.FilterChain;
55
import jakarta.servlet.ServletException;
66
import jakarta.servlet.annotation.WebFilter;
77
import jakarta.servlet.http.HttpServletRequest;
88
import jakarta.servlet.http.HttpServletResponse;
99
import java.io.IOException;
10-
import java.net.URLDecoder;
11-
import java.nio.charset.StandardCharsets;
12-
import java.util.Arrays;
1310
import lombok.RequiredArgsConstructor;
14-
import org.apache.commons.lang3.StringUtils;
1511
import org.jetbrains.annotations.NotNull;
16-
import org.springframework.core.env.Environment;
12+
import org.springframework.core.annotation.Order;
1713
import org.springframework.stereotype.Component;
1814
import org.springframework.web.filter.OncePerRequestFilter;
1915

@@ -23,23 +19,17 @@
2319
*/
2420
@Component
2521
@RequiredArgsConstructor
22+
// Ensure this runs first
23+
@Order(1)
2624
@WebFilter(filterName = "AuthenticationFilter")
2725
public class AuthenticationFilter extends OncePerRequestFilter {
2826
private static final String MISSING_INVALID_HEADER_MSG = "Missing or invalid certificate header.";
29-
private static final String LEAF_CERT_HEADER = "X-Amzn-Mtls-Clientcert";
3027

31-
private final Configuration configuration;
32-
private final Environment environment;
28+
private final CertificateUtil certificateUtil;
3329

3430
@Override
35-
protected boolean shouldNotFilter(HttpServletRequest request) {
36-
var path = request.getRequestURI();
37-
// Some URLs like the Swagger UI shouldn't require auth
38-
if (Configuration.canUrlBypassAuth(path)) {
39-
return true;
40-
}
41-
return Arrays.stream(environment.getActiveProfiles())
42-
.allMatch(Configuration::canProfileBypassAuth);
31+
protected boolean shouldNotFilter(@NotNull HttpServletRequest request) {
32+
return certificateUtil.canBypassAuth();
4333
}
4434

4535
@Override
@@ -49,27 +39,13 @@ public void doFilterInternal(
4939
@NotNull FilterChain filterChain)
5040
throws IOException, ServletException {
5141

52-
final var rawLeafCert = request.getHeader(LEAF_CERT_HEADER);
53-
if (rawLeafCert == null) {
54-
response.sendError(400, MISSING_INVALID_HEADER_MSG);
55-
return;
56-
}
57-
58-
final var clientCertsToAliases = configuration.getClientCertsToAliases();
59-
// We need to replace these characters with their URL-encoding counterparts because AWS
60-
// considers them "safe" and therefore does not encode them when sending the leaf certificate
61-
// from the client certificate in the header. So, when we try to URL Decode them, they get lost.
62-
final var encodedLeafCert =
63-
rawLeafCert.replace("+", "%2b").replace("=", "%3d").replace("/", "%2f");
64-
final var leafCert =
65-
StringUtils.deleteWhitespace(URLDecoder.decode(encodedLeafCert, StandardCharsets.UTF_8));
42+
final var certAlias = certificateUtil.getAliasFromCert(request);
6643

67-
final var certAlias = clientCertsToAliases.getOrDefault(leafCert, null);
68-
if (certAlias == null) {
44+
if (certAlias.isEmpty()) {
6945
response.sendError(400, MISSING_INVALID_HEADER_MSG);
7046
return;
7147
}
72-
48+
certificateUtil.attachCertAliasToRequest(request, certAlias.get());
7349
filterChain.doFilter(request, response);
7450
}
7551
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package gov.cms.bfd.server.ng.filter;
2+
3+
import gov.cms.bfd.server.ng.CertificateUtil;
4+
import jakarta.servlet.Filter;
5+
import jakarta.servlet.FilterChain;
6+
import jakarta.servlet.ServletException;
7+
import jakarta.servlet.ServletRequest;
8+
import jakarta.servlet.ServletResponse;
9+
import jakarta.servlet.annotation.WebFilter;
10+
import jakarta.servlet.http.HttpServletRequest;
11+
import java.io.IOException;
12+
import lombok.RequiredArgsConstructor;
13+
import org.slf4j.MDC;
14+
import org.springframework.core.annotation.Order;
15+
import org.springframework.stereotype.Component;
16+
17+
/** Filter for attaching MDC properties. */
18+
@Component
19+
@RequiredArgsConstructor
20+
// This should run directly after the auth filter
21+
@Order(2)
22+
@WebFilter(filterName = "MdcFilter")
23+
public class MdcFilter implements Filter {
24+
private final CertificateUtil certificateUtil;
25+
private static final String URI = "uri";
26+
private static final String REQUEST_ID = "requestId";
27+
private static final String CLIENT = "client";
28+
private static final String REMOTE_ADDRESS = "remoteAddress";
29+
30+
@Override
31+
public void doFilter(
32+
ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
33+
throws IOException, ServletException {
34+
35+
if (servletRequest instanceof HttpServletRequest httpRequest) {
36+
MDC.put(URI, httpRequest.getRequestURI());
37+
MDC.put(REQUEST_ID, httpRequest.getRequestId());
38+
MDC.put(REMOTE_ADDRESS, httpRequest.getRemoteAddr());
39+
var aliasAttribute = certificateUtil.getAliasAttribute(httpRequest);
40+
aliasAttribute.ifPresent((attr) -> MDC.put(CLIENT, attr));
41+
}
42+
filterChain.doFilter(servletRequest, servletResponse);
43+
44+
// Clean up to prevent leaks
45+
MDC.remove(URI);
46+
MDC.remove(REQUEST_ID);
47+
MDC.remove(CLIENT);
48+
MDC.remove(REMOTE_ADDRESS);
49+
}
50+
}

apps/bfd-server-ng/src/main/java/gov/cms/bfd/server/ng/interceptor/ExceptionHandlingInterceptor.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,17 @@
88
import jakarta.servlet.http.HttpServletRequest;
99
import jakarta.servlet.http.HttpServletResponse;
1010
import java.io.IOException;
11+
import lombok.RequiredArgsConstructor;
1112
import org.slf4j.Logger;
1213
import org.slf4j.LoggerFactory;
1314

1415
/***
1516
* Custom exception handling interceptor since the default one is not very customizable.
1617
*/
18+
@RequiredArgsConstructor
1719
@Interceptor
1820
public class ExceptionHandlingInterceptor {
19-
Logger logger = LoggerFactory.getLogger(ExceptionHandlingInterceptor.class);
21+
private static final Logger LOGGER = LoggerFactory.getLogger(ExceptionHandlingInterceptor.class);
2022

2123
/**
2224
* Handles the server exception.
@@ -38,6 +40,7 @@ public boolean handleException(
3840
if (exception.getStatusCode() < 500) {
3941
return true;
4042
}
43+
// Force a default error message for any unexpected errors
4144
response.sendError(500, "An unexpected error occurred");
4245
return false;
4346
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package gov.cms.bfd.server.ng.interceptor;
2+
3+
import ca.uhn.fhir.interceptor.api.Hook;
4+
import ca.uhn.fhir.interceptor.api.Interceptor;
5+
import ca.uhn.fhir.interceptor.api.Pointcut;
6+
import ca.uhn.fhir.rest.api.server.RequestDetails;
7+
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
8+
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
9+
import jakarta.servlet.http.HttpServletRequest;
10+
import jakarta.servlet.http.HttpServletResponse;
11+
import org.slf4j.Logger;
12+
import org.slf4j.LoggerFactory;
13+
import org.slf4j.spi.LoggingEventBuilder;
14+
15+
/**
16+
* Interceptor for logging HAPI FHIR requests. HAPI FHIR's built-in interceptor doesn't support
17+
* setting the log level or structured logging.
18+
*/
19+
@Interceptor
20+
public class LoggingInterceptor {
21+
private static final Logger LOGGER = LoggerFactory.getLogger(LoggingInterceptor.class);
22+
23+
/**
24+
* Log exceptions.
25+
*
26+
* @param requestDetails requestDetails
27+
* @param exception exception
28+
* @param request request
29+
* @param response response
30+
* @return boolean
31+
*/
32+
@Hook(Pointcut.SERVER_HANDLE_EXCEPTION)
33+
public boolean handleException(
34+
RequestDetails requestDetails,
35+
BaseServerResponseException exception,
36+
HttpServletRequest request,
37+
HttpServletResponse response) {
38+
39+
addCommonAttrs(
40+
LOGGER
41+
.atError()
42+
.setMessage(exception.getMessage())
43+
.addKeyValue("stackTrace", exception.getStackTrace())
44+
.addKeyValue("statusCode", exception.getStatusCode()),
45+
requestDetails)
46+
.log();
47+
48+
return true;
49+
}
50+
51+
/**
52+
* Log successful requests.
53+
*
54+
* @param requestDetails request details
55+
*/
56+
@Hook(Pointcut.SERVER_PROCESSING_COMPLETED_NORMALLY)
57+
public void processingCompletedNormally(ServletRequestDetails requestDetails) {
58+
addCommonAttrs(LOGGER.atInfo().setMessage("processed request"), requestDetails).log();
59+
}
60+
61+
private LoggingEventBuilder addCommonAttrs(
62+
LoggingEventBuilder eventBuilder, RequestDetails requestDetails) {
63+
var operationType = requestDetails.getRestOperationType();
64+
return eventBuilder
65+
.addKeyValue("resource", requestDetails.getResourceName())
66+
.addKeyValue("operation", requestDetails.getOperation())
67+
.addKeyValue("operationType", operationType == null ? "" : operationType.getCode());
68+
}
69+
}

0 commit comments

Comments
 (0)