Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
*/
package org.flowable.rest.app.properties;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;

import org.springframework.boot.context.properties.ConfigurationProperties;
Expand All @@ -31,6 +33,10 @@ public class RestAppProperties {
* Configures the way user credentials are verified when doing a REST API call:
* 'any-user' : the user needs to exist and the password need to match. Any user is allowed to do the call (this is the pre 6.3.0 behavior)
* 'verify-privilege' : the user needs to exist, the password needs to match and the user needs to have the 'rest-api' privilege
* 'pre-auth' : the request is trusted to have been authenticated by a reverse proxy in front of the app, and the user id is
* read from a request header (see {@link PreAuth}) instead of HTTP Basic. The password is not checked; privileges
* are still loaded from the IDM engine so authorization behaves as with 'verify-privilege'. Only use this when the
* app cannot be reached except through a trusted proxy that strips any client-supplied copy of the header.
* If nothing set, defaults to 'verify-privilege'
*/
private String authenticationMode = "verify-privilege";
Expand All @@ -51,6 +57,9 @@ public class RestAppProperties {
@NestedConfigurationProperty
private final Admin admin = new Admin();

@NestedConfigurationProperty
private final PreAuth preAuth = new PreAuth();

/**
* The default role prefix that needs to be used by Spring Security.
*/
Expand Down Expand Up @@ -88,6 +97,10 @@ public Admin getAdmin() {
return admin;
}

public PreAuth getPreAuth() {
return preAuth;
}

public String getRolePrefix() {
return rolePrefix;
}
Expand Down Expand Up @@ -139,6 +152,53 @@ public void setLastName(String lastName) {
}
}

/**
* Settings for the 'pre-auth' authentication mode, where a trusted reverse proxy has already
* authenticated the caller and passes the user id in a request header.
*/
public static class PreAuth {

/**
* The request header that carries the already-authenticated user id. Defaults to
* {@code X-Forwarded-User}, which is what most authenticating reverse proxies emit
* (oauth2-proxy, and Databricks Apps also forwards {@code X-Forwarded-Email} /
* {@code X-Forwarded-Preferred-Username}).
*/
private String principalHeader = "X-Forwarded-User";

/**
* Optional defence-in-depth allowlist of trusted proxy source addresses, as IPs or CIDR
* ranges (e.g. {@code 10.0.0.0/8}, {@code 192.168.1.5}). Empty by default, which keeps
* the behaviour of trusting the principal header on every request.
*
* <p>When set, the principal header is only honoured if the request's <em>transport
* peer</em> ({@code ServletRequest#getRemoteAddr()}, not an {@code X-Forwarded-For}
* value) matches one of these entries; otherwise the request is treated as if it carried
* no header and is denied. This binds the trusted identity to the proxy it came from
* rather than to the header alone, so a single misconfiguration (the app becoming
* reachable off the proxy, or a proxy that forwards an inbound {@code X-Forwarded-*}
* header) cannot be exploited from an arbitrary source. It complements, and does not
* replace, the requirement that the proxy strip client-supplied copies of the header.
*/
private List<String> trustedProxies = new ArrayList<>();

public String getPrincipalHeader() {
return principalHeader;
}

public void setPrincipalHeader(String principalHeader) {
this.principalHeader = principalHeader;
}

public List<String> getTrustedProxies() {
return trustedProxies;
}

public void setTrustedProxies(List<String> trustedProxies) {
this.trustedProxies = trustedProxies;
}
}

public static class Cors {
/**
* Enable/disable CORS filter.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,14 @@
*/
package org.flowable.rest.conf;

import java.util.ArrayList;
import java.util.List;

import org.apache.commons.lang3.StringUtils;
import org.flowable.idm.api.IdmIdentityService;
import org.flowable.rest.app.properties.RestAppProperties;
import org.flowable.rest.security.BasicAuthenticationProvider;
import org.flowable.rest.security.PreAuthenticatedUserDetailsService;
import org.flowable.rest.security.SecurityConstants;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.health.actuate.endpoint.HealthEndpoint;
Expand All @@ -28,25 +33,42 @@
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider;
import org.springframework.security.web.authentication.preauth.RequestHeaderAuthenticationFilter;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.IpAddressMatcher;

@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class SecurityConfiguration {


protected static final String MODE_PRE_AUTH = "pre-auth";
protected static final String MODE_VERIFY_PRIVILEGE = "verify-privilege";

protected final RestAppProperties restAppProperties;

public SecurityConfiguration(RestAppProperties restAppProperties) {
this.restAppProperties = restAppProperties;
}

@Bean
public AuthenticationProvider authenticationProvider() {
public AuthenticationProvider authenticationProvider(IdmIdentityService idmIdentityService) {
if (isPreAuth()) {
// The reverse proxy has already authenticated the caller; this provider only loads
// the user's privileges from IDM. No password is checked.
PreAuthenticatedUserDetailsService userDetailsService = new PreAuthenticatedUserDetailsService(idmIdentityService);
userDetailsService.setVerifyRestApiPrivilege(isVerifyRestApiPrivilege());

PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
provider.setPreAuthenticatedUserDetailsService(userDetailsService);
return provider;
}

BasicAuthenticationProvider basicAuthenticationProvider = new BasicAuthenticationProvider();
basicAuthenticationProvider.setVerifyRestApiPrivilege(isVerifyRestApiPrivilege());
return basicAuthenticationProvider;
}

@Bean
public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProvider authenticationProvider) throws Exception {
HttpSecurity httpSecurity = http.authenticationProvider(authenticationProvider)
Expand All @@ -67,7 +89,7 @@ public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProv
httpSecurity
.authorizeHttpRequests(
authorizeRequests -> authorizeRequests.requestMatchers(PathPatternRequestMatcher.withDefaults().matcher("/docs/**")).denyAll());

}

httpSecurity
Expand All @@ -82,25 +104,77 @@ public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProv
if (isVerifyRestApiPrivilege()) {
httpSecurity
.authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().hasAuthority(SecurityConstants.PRIVILEGE_ACCESS_REST_API));

} else {
httpSecurity
.authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated());
}

httpSecurity.httpBasic(Customizer.withDefaults());
if (isPreAuth()) {
// Identity comes from a header set by a trusted proxy, not HTTP Basic. The filter
// builds a PreAuthenticatedAuthenticationToken from the header, which the
// PreAuthenticatedAuthenticationProvider above resolves against IDM.
RequestHeaderAuthenticationFilter preAuthFilter = trustedProxyAware(
new RequestHeaderAuthenticationFilter());
preAuthFilter.setPrincipalRequestHeader(restAppProperties.getPreAuth().getPrincipalHeader());
// Missing header simply yields an anonymous request that the authorization rules
// above reject with 401/403, rather than a 500.
preAuthFilter.setExceptionIfHeaderMissing(false);
preAuthFilter.setAuthenticationManager(authentication -> authenticationProvider.authenticate(authentication));
httpSecurity.addFilterBefore(preAuthFilter, org.springframework.security.web.authentication.AnonymousAuthenticationFilter.class);
} else {
httpSecurity.httpBasic(Customizer.withDefaults());
}

return http.build();
}

protected boolean isVerifyRestApiPrivilege() {
String authMode = restAppProperties.getAuthenticationMode();
if (StringUtils.isNotEmpty(authMode)) {
return "verify-privilege".equals(authMode);
// 'pre-auth' keeps privilege verification on: identity is trusted, authorization is not.
return MODE_VERIFY_PRIVILEGE.equals(authMode) || MODE_PRE_AUTH.equals(authMode);
}
return true; // checking privilege is the default
}


protected boolean isPreAuth() {
return MODE_PRE_AUTH.equals(restAppProperties.getAuthenticationMode());
}

/**
* Wraps the pre-auth filter so that, when a trusted-proxy allowlist is configured, the
* principal header is only read from requests whose transport peer address matches the
* allowlist. A request from any other source is treated as if it carried no header
* (principal resolves to {@code null}) and is denied by the authorization rules, exactly
* like a missing header. With no allowlist configured the plain filter is returned and
* behaviour is unchanged.
*/
protected RequestHeaderAuthenticationFilter trustedProxyAware(RequestHeaderAuthenticationFilter delegate) {
List<String> trustedProxies = restAppProperties.getPreAuth().getTrustedProxies();
if (trustedProxies == null || trustedProxies.isEmpty()) {
return delegate;
}
List<IpAddressMatcher> matchers = new ArrayList<>(trustedProxies.size());
for (String entry : trustedProxies) {
matchers.add(new IpAddressMatcher(entry));
}
return new RequestHeaderAuthenticationFilter() {

@Override
protected Object getPreAuthenticatedPrincipal(jakarta.servlet.http.HttpServletRequest request) {
String remoteAddr = request.getRemoteAddr();
for (IpAddressMatcher matcher : matchers) {
if (matcher.matches(remoteAddr)) {
return super.getPreAuthenticatedPrincipal(request);
}
}
// Untrusted source: ignore the header entirely.
return null;
}
};
}

protected boolean isSwaggerDocsEnabled() {
return restAppProperties.isSwaggerDocsEnabled();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* 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.flowable.rest.app;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

/**
* Complements {@link FlowableRestApplicationPreAuthUntrustedProxyTest}: with an allowlist that
* DOES contain the test client's loopback source, a request carrying a privileged principal
* header is honoured and succeeds. Together the two tests pin both sides of the trusted-proxy
* contract — trusted source honoured, untrusted source ignored.
*
* @author Arief Hidayat
*/
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"flowable.rest.app.authentication-mode=pre-auth",
"flowable.rest.app.pre-auth.principal-header=X-Forwarded-User",
// Both IPv4 and IPv6 loopback, since the test client may connect over either.
"flowable.rest.app.pre-auth.trusted-proxies=127.0.0.1/32,::1"
}
)
@AutoConfigureTestRestTemplate
public class FlowableRestApplicationPreAuthTrustedProxyTest {

@LocalServerPort
private int serverPort;

@Autowired
private TestRestTemplate restTemplate;

@Test
public void principalHeaderFromTrustedSourceIsHonoured() {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Forwarded-User", "rest-admin");
HttpEntity<?> request = new HttpEntity<>(headers);

String url = "http://localhost:" + serverPort + "/flowable-rest/service/repository/process-definitions";
ResponseEntity<String> entity = restTemplate.exchange(url, HttpMethod.GET, request, String.class);

assertThat(entity.getStatusCode())
.as("principal header from a trusted source address")
.isEqualTo(HttpStatus.OK);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/* 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.flowable.rest.app;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

/**
* Pins the trusted-proxy contract: with an allowlist configured that does NOT contain the test
* client's source address, a request carrying a valid principal header must still be rejected.
* This is the case a plain header-present / header-absent suite cannot distinguish — a spoofed
* header from an untrusted source looks identical to a legitimate one unless the source address
* is checked.
*
* <p>The allowlist is {@code 10.0.0.0/8}; the {@link TestRestTemplate} connects from loopback
* ({@code 127.0.0.1}), which is outside that range, so the header is ignored and the request is
* denied.
*
* @author Arief Hidayat
*/
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"flowable.rest.app.authentication-mode=pre-auth",
"flowable.rest.app.pre-auth.principal-header=X-Forwarded-User",
"flowable.rest.app.pre-auth.trusted-proxies=10.0.0.0/8"
}
)
@AutoConfigureTestRestTemplate
public class FlowableRestApplicationPreAuthUntrustedProxyTest {

@LocalServerPort
private int serverPort;

@Autowired
private TestRestTemplate restTemplate;

@Test
public void principalHeaderFromUntrustedSourceIsIgnored() {
HttpHeaders headers = new HttpHeaders();
// A valid, privileged user id -- but arriving from a source outside the allowlist.
headers.set("X-Forwarded-User", "rest-admin");
HttpEntity<?> request = new HttpEntity<>(headers);

String url = "http://localhost:" + serverPort + "/flowable-rest/service/repository/process-definitions";
ResponseEntity<String> entity = restTemplate.exchange(url, HttpMethod.GET, request, String.class);

assertThat(entity.getStatusCode())
.as("spoofed principal header from an untrusted source address")
.isEqualTo(HttpStatus.FORBIDDEN);
}
}
Loading