Skip to content

Add pre-authenticated (trusted reverse-proxy) mode to the REST app - #4256

Open
arief-hidayat wants to merge 2 commits into
flowable:mainfrom
arief-hidayat:feat/rest-pre-authenticated-mode
Open

Add pre-authenticated (trusted reverse-proxy) mode to the REST app#4256
arief-hidayat wants to merge 2 commits into
flowable:mainfrom
arief-hidayat:feat/rest-pre-authenticated-mode

Conversation

@arief-hidayat

Copy link
Copy Markdown

Add a pre-authenticated (trusted reverse-proxy) mode to the REST app

Problem

The REST app's Spring Security chain only supports HTTP Basic, and its SecurityConfiguration is a plain @configuration with no way to switch that off. That makes flowable-app-rest impossible to run unchanged behind a reverse proxy that has already authenticated the caller and owns the Authorization header (oauth2-proxy, nginx auth_request, AWS ALB OIDC, Databricks Apps, …): the proxy's bearer token collides with Flowable's Basic prompt and every request is rejected with 401.

Change

Add a third flowable.rest.app.authentication-mode value, pre-auth. In this mode the chain reads the already-authenticated user id from a request header (flowable.rest.app.pre-auth.principal-header, default X-Forwarded-User) via Spring Security's RequestHeaderAuthenticationFilter instead of HTTP Basic. No password is checked; privileges are still loaded from the IDM engine, so the access-rest-api / access-admin authorization rules behave exactly as in verify-privilege. Identity flows to the engine unchanged, because SpringSecurityAuthenticationContext reads the principal name generically.

This mirrors the pluggable security the UI apps already have via flowable.common.app.security.type. It is off by default and changes nothing for existing HTTP Basic deployments.

A request that arrives without the principal header is denied 403 (not 401): in pre-auth mode there is no Basic challenge to issue, so an identity-less request is an authorization failure rather than a prompt. A trusted proxy is expected to always set the header.

Security note

pre-auth trusts the principal header, so it is only safe when the app is unreachable except through a proxy that strips any client-supplied copy of that header. This is documented on the new classes.

Configuration example

flowable.rest.app.authentication-mode=pre-auth
flowable.rest.app.pre-auth.principal-header=X-Forwarded-User

How it was verified

  • Unit test on the authority mapping (PreAuthenticatedUserDetai
  • @SpringBootTest integration test booting the REST app in pre-auth mode (FlowableRestApplicationPreAuthenticationTest, 5 tests): header → 200, missing
    header → 403, header for a user without access-rest-api → 403, admin actuator → 403.
  • Deployed the built WAR behind a real reverse proxy (Databricks Apps): the patched app returns 200 where the unmodified WAR returns 401.

Check List:

  • Unit tests: YES — PreAuthenticatedUserDetailsServiceTest (3) and FlowableRestApplicationPreAuthenticationTest (5), all passing.
  • Documentation: YES — property Javadoc on RestAppProperties (bd the new pre-auth.principal-header) and the security note onSecurityConfiguration / PreAuthenticatedUserDetailsService.

The REST app's Spring Security chain only supports HTTP Basic, and its
SecurityConfiguration is a plain @configuration with no way to switch that off.
That makes flowable-app-rest impossible to run unchanged behind a reverse proxy
that has already authenticated the caller and owns the Authorization header
(oauth2-proxy, nginx auth_request, AWS ALB OIDC, Databricks Apps, ...): the
proxy's bearer token collides with Flowable's Basic prompt and every request is
rejected.

Add a third flowable.rest.app.authentication-mode value, 'pre-auth'. In this
mode the chain reads the already-authenticated user id from a request header
(flowable.rest.app.pre-auth.principal-header, default X-Forwarded-User) via
Spring Security's RequestHeaderAuthenticationFilter instead of HTTP Basic. No
password is checked; privileges are still loaded from the IDM engine, so the
access-rest-api / access-admin authorization rules behave exactly as in
'verify-privilege'. Identity flows to the engine unchanged, because
SpringSecurityAuthenticationContext reads the principal name generically.

This mirrors the pluggable security the UI apps already have via
flowable.common.app.security.type. It is off by default and changes nothing for
existing HTTP Basic deployments.

Security note documented on the new classes: 'pre-auth' trusts the principal
header, so it is only safe when the app is unreachable except through a proxy
that strips any client-supplied copy of that header.

@nikhiln64 nikhiln64 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mode is cleanly built and I like that the trust assumption is stated plainly in the Javadoc, that this is only safe when the app cannot be reached except through a proxy that strips a client supplied copy of the header. The privilege loading mirrors BasicAuthenticationProvider so authorization behaves the same, and failing a missing header down to an anonymous request that the rules deny with 403 rather than a 500 is the right call.

What I would raise is that the whole security boundary rests on that one deployment assumption with no backstop inside the app. If the app ever becomes reachable off the proxy, or the proxy is misconfigured to pass an inbound X-Forwarded-User through, any caller sending X-Forwarded-User rest-admin is authenticated as rest-admin with full privileges, and the default header being an X-Forwarded name is exactly the family many proxies forward unless told to strip. Since this ships as a framework option enabled from a property, a defence in depth layer would cut the blast radius of a single misconfiguration. Spring Security's preauth stack lets you reject the request unless the peer address is a configured trusted proxy, through an AuthenticationDetailsSource that checks the remote address. An optional trusted proxy allowlist, off by default, would let a careful operator bind identity to the proxy it came from rather than to the header alone.

On the tests, they pin the header present and header absent behaviour well, but the case that decides whether this feature is safe, a request arriving with a spoofed principal header, is the same request the passing tests already make, so the suite cannot tell a trusted header from a forged one. If you add the IP guard, a test that a request carrying the header from an untrusted source address is rejected would pin the actual contract.

Defence in depth for the pre-auth authentication mode, addressing review
feedback: the mode's safety otherwise rests entirely on the deployment
assumption that the proxy strips client-supplied copies of the principal header,
with no backstop in the app. If the app becomes reachable off the proxy, or the
proxy forwards an inbound X-Forwarded-* header, any caller could assert an
arbitrary identity.

New optional property flowable.rest.app.pre-auth.trusted-proxies takes a list of
IPs / CIDR ranges. When set, the principal header is only honoured if the
request's transport peer address (ServletRequest#getRemoteAddr(), not an
X-Forwarded-For value) matches the allowlist; otherwise the header is ignored and
the request is denied exactly like a missing header. Empty by default, so
existing deployments are unchanged. This binds the trusted identity to the proxy
it came from rather than to the header alone, and complements — does not replace
— the header-stripping requirement.

Implemented by wrapping RequestHeaderAuthenticationFilter with an
IpAddressMatcher check in getPreAuthenticatedPrincipal.

Tests: FlowableRestApplicationPreAuthUntrustedProxyTest asserts a valid,
privileged principal header from a source outside the allowlist is rejected with
403 — the case the earlier header-present/absent suite could not distinguish from
a legitimate request. FlowableRestApplicationPreAuthTrustedProxyTest asserts a
header from an allowlisted (loopback) source is honoured with 200.
@arief-hidayat

Copy link
Copy Markdown
Author

Thanks — this is a fair call, and I've pushed a change (23113438) that adds the backstop you described.

New optional property flowable.rest.app.pre-auth.trusted-proxies takes a list of IPs / CIDR ranges. When set, the pre-auth filter only honours the principal header if the request's transport peer address — ServletRequest#getRemoteAddr(), i.e. the immediate TCP peer, not an X-Forwarded-For value — matches the allowlist; otherwise the header is ignored and the request falls through to anonymous and is denied 403, exactly as a missing header is. It's empty by default, so existing deployments and meshes with dynamic egress IPs are unchanged, but a careful operator can bind the trusted identity to the proxy it came from rather than to the header alone. As you say, this cuts the blast radius of a single misconfiguration; it complements rather than replaces the header-stripping requirement, and I've noted that in the Javadoc (getRemoteAddr() must be the real peer — the platform must not let clients spoof the transport source).

Implementation is an IpAddressMatcher check wired into RequestHeaderAuthenticationFilter#getPreAuthenticatedPrincipal, as you suggested.

On the tests: agreed the earlier suite couldn't tell a forged header from a trusted one. Added two cases — FlowableRestApplicationPreAuthUntrustedProxyTest sends a valid, privileged principal header (X-Forwarded-User: rest-admin) from a source outside the allowlist and asserts 403, which is the case that actually pins the trust contract; FlowableRestApplicationPreAuthTrustedProxyTest asserts a header from an allowlisted (loopback) source is honoured with 200.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants