Add a SaslTermination filter - #124
Conversation
4c4b59e to
80fbff1
Compare
|
Please can someone review this PR? @k-wall @SamBarker @robobario |
SamBarker
left a comment
There was a problem hiding this comment.
Thanks for putting this together Tom — there's clearly a lot of thorough thinking here.
I'm going to be honest: I've spent a good amount of time on this and I haven't made it much past the SaslTerminationConfig section. There's a wall of detail that makes it hard to form a high-level picture of the design before getting pulled into component-level specifics.
I think the proposal would benefit from a 40,000 ft overview early on — something showing how the components compose, what calls what, and where the module boundaries are. Without that, I'm left assembling the big picture from seven component descriptions, and I'm not confident I have it right.
I'll continue working through the rest of the proposal, but wanted to get these comments posted rather than sit on them.
|
|
||
| **`MechanismHandler`** -- handles the authentication exchange for a single connection. Instances are per-connection and not thread-safe. | ||
|
|
||
| ```java |
There was a problem hiding this comment.
The name MechanismHandler doesn't convey what this component actually does. From Kroxylicious's perspective, the filter's job is to validate client credentials — whether that takes one round (OAUTHBEARER) or multiple (SCRAM) is an implementation detail of how the validation happens, not a different responsibility.
I understand "mechanism" comes from the SASL spec, but in our codebase the meaningful concept is credential validation. Something like CredentialValidator / CredentialValidatorFactory would make the role immediately clear to someone reading the code without SASL RFC context.
I'm also a bit wary of "Handler" given our Netty context — it invites confusion with ChannelHandler, which is a different thing entirely.
There was a problem hiding this comment.
The thing which defined and uses the MechanismHandler interface is the SaslTermination filter. That's the relevant perspective by which we should judge the suitability of names. From that PoV SASL is a given, and so using SASL specific terms, like 'mechanism', in named seems entirely appropriate.
I'm open to suggestions about 'handler'. 'Callback' is okayish, but doesn't quite feel right given how it's used.
There was a problem hiding this comment.
I still think naming in terms of roles (what the component does) rather than protocol concepts (where it comes from) would serve readers better, but I don't feel strongly enough to hold up the proposal. A few alternatives to "Handler" if you're open to it:
MechanismAuthenticator— direct role-based, it authenticatesMechanismExchange— leans into SASL spec language, one instance per connection maps to one exchange per connectionMechanismValidator— what it ultimately decides: are these credentials valid?
For what it's worth, "Mechanism" feels redundant in the impl names — you already know it's SCRAM-SHA-256:
| Interface | Impl |
|---|---|
MechanismAuthenticator |
ScramSha256Authenticator |
MechanismExchange |
ScramSha256Exchange |
MechanismValidator |
ScramSha256Validator |
There was a problem hiding this comment.
Noted. If we drop the service loading part these are gonna be internal interfaces (not public API), so we're at liberty to decide when a code PR shows up.
|
Thanks for taking a look @SamBarker, I'll try to address your comments next week, but:
I added a UML diagram which renders when you view the rendered page (but not in the code review view): |
|
Thanks Tom. I was working from a local checkout and only found the updates after I'd submitted my comments. I found the single UML diagram hard to parse — it covers module boundaries, inheritance hierarchies, and runtime interactions all in one. I had a go at breaking it down into separate views at different levels of detail: https://gist.github.com/SamBarker/eddb68a6797c43bda4cbaee4bf9043bf I think some of these would be worth including in the proposal but not necessarily all of them. Have a look and pick whichever ones you think are strongest? |
c34ac50 to
0d5f655
Compare
SamBarker
left a comment
There was a problem hiding this comment.
Continuing the review — comments on the remaining components, credential store implications, and system-level failure modes.
| The SASL termination filter emits additional metrics for authentication latency and session expiry. | ||
|
|
||
| | Metric | Type | Tags | Description | | ||
| |--------|------|------|-------------| |
There was a problem hiding this comment.
Might be useful to have a counter for unauthorized RPCs that arrive before the user is authenticated.
| - The security barrier is enforced for all non-SASL request types. Unauthenticated requests are rejected and the connection is closed. | ||
| - On authentication failure, the connection is closed immediately. | ||
|
|
||
| ### CLI tool security |
There was a problem hiding this comment.
we mention security quite a bit, should we put something up top near the summary as a statement about our overall security posture for this solution. It feels like implicitly we value this solution being secure because of the Authn/Authz realm it sits in, and we are choosing to diverge from what Kafka offers (SCRAM password minimum lengths, SASL Plain) because of this unwritten posture. I think it's worth being explicit.
There was a problem hiding this comment.
It feels like implicitly we value this solution being secure because of the Authn/Authz realm it sits in
Well, yes, there's not point string to terminate SASL if we do so with glaring flaws.
and we are choosing to diverge from what Kafka offers (SCRAM password minimum lengths, SASL Plain)
FWIW:
- Minimum password length is just enforcing NIST recommendations, IIRC. We don't have to do that, but I think it moves us closer to being able to claim "secure by default". Also, since NIST now recommends against forced password rotation the JSON lacks a
last_changed_date. But these things are always trade-offs. It seems possible that someone will come along with a compliance requirement to enforce password ageing. It doesn't have to be perfect right now. - The choice to use a larger iteration count comes from OWASP. It basically boils down to making brute force attacks (whether online or offline) both and slower and more mostly.
- SASL Plain: We could offer it, of course. It's not ruled out by this proposal. But I'd rather keep this proposal small. SCRAM provides a simple, password-based mechanism without requiring additional infra (c.f.
OAUTHBEARER) which is just as usable from the PoV of a app developer writing a Kafka application. So I don't see a good use case for PLAIN.
There was a problem hiding this comment.
👍 I should have been clearer, I agree with the proposal diverging from Kafka, using more up to date security requirements than whatever Kafka chose back in the day. Was just asking for a sentence somewhere saying, "since this is in the security domain we're going to adopt current/stronger security advice rather than support everything Apache Kafka does"
There was a problem hiding this comment.
Added an explicit security posture statement at line 62. Addressed in 30f58af.
|
|
||
| | From state | Triggering event | To state | | ||
| |------------|------------------|----------| | ||
| | **RequiringHandshake** | `SASL_HANDSHAKE` with supported mechanism | **RequiringAuthenticate** | |
There was a problem hiding this comment.
might be worth adding the unsupported mechanism case stating we remain in RequiringHandshake and returns UNSUPPORTED_SASL_MECHANISM with supported list. Which looks like what's in the PoC
| | Threat | Mitigation | | ||
| |--------|------------| | ||
| | Token from wrong audience or issuer -- a JWT issued for a different service or identity provider is presented to the proxy. | Both `expectedAudience` and `expectedIssuer` are required fields. The handler rejects tokens that do not match. | | ||
| | JWKS endpoint compromise -- an attacker controls the JWKS endpoint and supplies signing keys for forged tokens. | Mitigated operationally: the JWKS endpoint URL is set by the proxy administrator, not by clients. TLS protects the endpoint in transit (using the JVM's default trust store). | |
There was a problem hiding this comment.
something claude picked out of your PoC is that we should be careful with logging or returning error messages to the client which could contain details like "JWT expired at...", "wrong audience", or "wrong issuer" . Giving the client information about whether they've got the right IdP but wrong claims and so on could be a risk. Similar to scram I think these should be hidden behind a generic "Authentication failed".
There was a problem hiding this comment.
Added to the OAUTHBEARER threats table: the filter returns a generic "Authentication failed" to the client, matching the SCRAM approach. Addressed in 2e29fe2.
|
|
||
| --- | ||
|
|
||
| ### Component 4: OAUTHBEARER mechanism handler |
There was a problem hiding this comment.
In the PoC the oauth handler factory is manipulating the org.apache.kafka.sasl.oauthbearer.allowed.urls system property as part of the interaction with the apache kafka components, I think this is worth calling out somewhere as a challenge of integrating with the kafka internals and why we diverge from using our own sys prop or other proxy-wide configuration.
|
The proposal probably ought to say the validating legal compositions of SASL Filters within the filter chains is not in scope for this proposal. |
|
Clients in authentication fail loops are common place in production systems. There's also DoS issue possibility. As the proposal stands the client's reuath attempt will be slowed by |
k-wall
left a comment
There was a problem hiding this comment.
Very thorough work @tombentley. I've left some comments to consider.
tombentley
left a comment
There was a problem hiding this comment.
Thanks for the reviews @SamBarker @robobario @k-wall, I think I've addressed many of them, so please take another look when you have time.
|
Thanks @tombentley, happy to approve but for the absence of a response on the DoS possibility. It would be good to see unsupport mech handling in the state machine too. Can I also ask you add a very quick summary to the description field of this PR too explain the motivation etc? |
|
Sorry @k-wall:
|
AlterUserScramCredentials is rejected (credential store SPI is read-only). DescribeUserScramCredentials can be answered by the filter from its own credential store since the response contains only mechanism and iteration count. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
SASL termination splits authentication into two independent exchanges. The client can succeed against the proxy while the proxy fails to authenticate upstream. Discusses potential solutions (triggering upstream auth verification, SaslInitiator filter) but defers to future work due to complexity. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Upstream auth failure visibility is an open design question, not a known limitation. Moved to a new Open Questions section describing the problem and potential approaches (SaslInitiator filter, indistinguishable failure responses) without making a decision. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
The filter intercepts all requests, not just SASL ones. Non-SASL requests are checked for authentication state and session expiry as a security barrier. The unauthenticated check is defence in depth since failed auth already closes the connection. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Reject and close the connection if SASL_HANDSHAKE or SASL_AUTHENTICATE arrives with an API version outside the range known to the filter, preventing future protocol versions from bypassing security logic. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Add optional subjectBuilder config option defaulting to DEFAULT_SUBJECT_BUILDER, consistent with SaslInspection filter. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
…nator Replace Map<String, MechanismConfig> with List<MechanismConfig> using Jackson name-based polymorphism on a 'mechanism' property containing the IANA-registered mechanism name. Gives better error messages and is consistent with other definition lists in the project. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
ScramMechanismConfig becomes an abstract sealed base class whose constructor accepts the mechanism name. ScramSha256MechanismConfig and ScramSha512MechanismConfig are trivial subclasses, allowing Jackson name-based polymorphism to distinguish the two variants. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Once authenticated with no session expiry, the filter uses shouldHandleRequest to skip deserialization of subsequent requests, avoiding unnecessary filter overhead in steady state. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Note that Kafka's ScramSaslServer.dispose() is a no-op, confirming that the lack of dispose() on raw connection close is safe for the built-in handlers. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Acknowledge the asymmetry between SCRAM's pluggable credential store and OAUTHBEARER's hardcoded validator. A TokenValidator SPI is planned as future work to enable token introspection and custom claim validators. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Kafka does not expose __cluster_metadata as a consumable topic, and DescribeUserScramCredentials deliberately does not return credential material. SCRAM credentials are write-only by design, so a broker-backed credential store is not possible. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Use Base64URL-encoded SHA-256 hash of the username as the KeyStore alias instead of the raw username. JKS/PKCS12 are case-insensitive, keytool fails on special characters, non-ASCII is corrupted across tools, and Kafka does not apply SASLprep so any UTF-8 bytes can appear in usernames. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Base64 is unsuitable because JKS/PKCS12 are case-insensitive. Use lowercase hex-encoded SHA-256 instead (64 chars, no case sensitivity issues). Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Include a version field in the serialized JSON to allow the format to evolve while maintaining backwards compatibility. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Explicitly name the trust boundary shift: the proxy is now the authentication boundary and custodian of credential material. Recommend a comprehensive threat model as a separate activity. Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
…nism to state machine, restructure filter description Make mechanism handling internal to the filter rather than describing it as an SPI. Remove Component 2 (MechanismHandler extension point) and all references to internal types (MechanismHandler, MechanismHandlerFactory, MechanismConfig). Renumber components 3-7 to 2-6. Add unsupported mechanism handling to the state machine table and diagram: RequiringHandshake stays in RequiringHandshake, responds with UNSUPPORTED_SASL_MECHANISM and the supported mechanism list. Restructure Component 1 around request types (API_VERSIONS, SASL_HANDSHAKE, SASL_AUTHENTICATE, other) so the filter behaviour follows the client request sequence rather than listing features in isolation. Assisted-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
State upfront that the proposal adopts current security guidance (NIST SP 800-63B, OWASP) rather than matching Kafka's historical defaults. Assisted-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
JWT validation failure details (expired token, wrong audience, wrong issuer) must not be returned to the client. Use a generic "Authentication failed" message, matching the SCRAM approach. Assisted-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Kafka's OAuthBearerValidatorCallbackHandler requires the oauthbearer.allowed.urls system property to be set. This is a JVM-global side effect that must be called out as a Kafka internal API dependency. Assisted-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Add --iterations option to add-user and update-password CLI commands (default 10,000, minimum 4,096). Update list-users to show mechanism and iteration count for credential auditing. Assisted-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Document the risk of offline password recovery after credential store exfiltration, with mitigations: configurable iteration count, file permission checks, and KeyStore encryption for in-transit protection. Assisted-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Clarify that fixedAuthDelay is a minimum duration (floor), not an additive delay. For SCRAM, unknown users must not fail fast on the first round but continue into the second round to prevent round-trip-based user enumeration. Assisted-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
Add per-mechanism authBytes limits (SCRAM: 4KB, OAUTHBEARER: 128KB) and a 255-character username length limit enforced in both the SCRAM handler and CLI tool. Assisted-by: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: Tom Bentley <tbentley@redhat.com>
882ec44 to
7725263
Compare
k-wall
left a comment
There was a problem hiding this comment.
Nice work @tombentley. Happy to see this merged.
Summary
This proposal adds a
SaslTerminationfilter that authenticates Kafka clients directly at the proxy, without forwarding SASL exchanges to the upstream broker. This enables credential isolation, authentication protocol translation, and centralized credential management.Motivation
Some routing implementations depend on knowing the client's
Subject, but mechanisms like SCRAM-SHA are incompatible with the existing SASL inspection technique (each server issues a unique challenge, so SASL Authenticate can't be fanned out). SASL termination solves this by having the proxy perform the full SASL exchange itself.Scope
The proposal covers:
fixedAuthDelayDoS protection (rate limiting / exponential backoff) and validation of legal SASL filter compositions within filter chains are out of scope.
Part of kroxylicious/kroxylicious#4391