reverse_tunnel: add inline JWT authentication for the handshake#46183
reverse_tunnel: add inline JWT authentication for the handshake#46183kanurag94 wants to merge 7 commits into
Conversation
|
CC @envoyproxy/api-shepherds: Your approval is needed for changes made to |
ccfbced to
ee1c9c2
Compare
The responder-side reverse_tunnel filter validates the node, cluster and tenant ids a downstream claims in its handshake, but cannot authenticate them against a verified credential. jwt_authn only runs as an HTTP filter, and fronting the handshake with an HTTP connection manager over an internal-listener splice breaks socket registration: the filter must own the real downstream file descriptor to register the socket for reuse, and a spliced internal connection is user-space with no descriptor. Add an optional `jwt_validation` block that verifies the handshake's bearer token on the real socket, before the connection is accepted and its socket registered, so a forged or expired token cannot establish a usable tunnel. Verification (signature, exp/nbf with configurable clock skew, issuer, audiences) reuses //source/common/jwt; an issuer is required and tokens without an exp claim are rejected. On success the verified claims are published as dynamic metadata, so the existing `validation` block can bind a claimed id to a verified claim, e.g. `tenant_id_format: "%DYNAMIC_METADATA(...reverse_tunnel.jwt:tenant)%"`. The config is scoped rather than reusing jwt_authn's JwtProvider, whose remote/async JWKS, request-param and cookie extraction and upstream-forwarding fields are meaningless for an L4 pre-registration handshake; the security-relevant semantics mirror jwt_authn. `allow_missing_or_failed` runs verification in audit mode: it counts would-be denials via the jwt_would_deny stat without closing the connection. Where the initiator can present a client certificate, mTLS with `%DOWNSTREAM_PEER_URI_SAN%` binds identity without this feature; jwt_validation targets the bearer-token case. Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>
ee1c9c2 to
74d21db
Compare
basundhara-c
left a comment
There was a problem hiding this comment.
This is a useful addition; We'd hoped to keep the tunnel filter lean and do JWT in a preceding filter, but I understand that it might be difficult given the duplicate() semantics and that jwt_authn is L7.
| ENVOY_LOG(debug, "reverse_tunnel: jwt: missing token header '{}'", jwt_token_header_.get()); | ||
| return false; | ||
| } | ||
| absl::string_view token = token_values[0]->value().getStringView(); |
There was a problem hiding this comment.
We ignore additional token headers, worth adding a comment here or, logging a warning if token_values.size() > 1
|
|
||
| // Success: publish verified claims as dynamic metadata so the validation block can bind a claimed | ||
| // handshake id to a verified claim via %DYNAMIC_METADATA(namespace:claim)%. | ||
| stream_info.setDynamicMetadata(jwt_claims_namespace_, jwt.payload_pb_); |
There was a problem hiding this comment.
we publish the claim before validateIdentifiers(), so it's published even if validation fails and a 403 is sent out. Worth a comment.
| // Validate node_id if formatter is configured. | ||
| if (node_id_formatter_) { | ||
| const std::string expected_node_id = node_id_formatter_->format({}, stream_info); | ||
| const std::string expected_node_id = node_id_formatter_->format(context, stream_info); |
There was a problem hiding this comment.
thanks for adding this! We should add a unit test for this as well; add a validation config with %REQ(x-header)%, one handshake where the header matches succeeds and one where it doesn't fails.
There was a problem hiding this comment.
thanks for pointing out, added.
Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>
|
@agrawroh this looks good to me, could you have a final look at it? |
|
/retest |
… auth Inline JWT handshake validation currently accepts only a synchronous `local_jwks`, modeled as a bare required field. The planned follow-up adds asynchronous `remote_jwks`, a mutually-exclusive alternative JWKS source. Wrap `local_jwks` in a `jwks_source_specifier` oneof now, while the feature is experimental and unreleased, so `remote_jwks` can land later as a purely additive arm (field 8) rather than forcing a schema reshuffle. Field number 3 is preserved, so the change is wire-compatible; the field-level `required` rule becomes an oneof-level `(validate.required)`, preserving the "a JWKS source must be present" guarantee. This mirrors jwt_authn's own `jwks_source_specifier`. No behavioral change: with a single arm the `local_jwks()` accessor and the config-load path are unchanged. Add a config-load test asserting that a `jwt_validation` block with no JWKS source is rejected, locking in the oneof contract so a future `remote_jwks` arm cannot silently make the source optional. Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>
Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>
|
/retest |
|
/wait a discussion about the API :) |
|
/retest |
Commit Message: reverse_tunnel: add inline JWT authentication for the handshake
Additional Description:
Adds an optional
jwt_validationblock to the reverse_tunnel network filter.Problem.
The filter can validate the node/cluster/tenant IDs a downstream claims in its handshake, but can't authenticate them against a credential the caller actually proves it holds.
The natural answer is mTLS, and where the initiator terminates TLS directly on the responder it works well (though after taking care of minting certs etc)—bind a claimed ID to
%DOWNSTREAM_PEER_URI_SAN%. But it falls short in two common cases:jwt_authncan't fill the gap either: it's an HTTP filter, and splicing an HCM in front via an internal listener hands the filter a user-space connection with no real fd, which breaks socket registration (the tunnel never becomes usable). So verification has to happen inside the filter, on the real connection.What it does.
When
jwt_validationis set, on each handshake the filter:authorizationheader (header name configurable).local_jwks— signature,exp/nbf(configurableclock_skew_seconds),iss, andaud. Anissueris required, and a token withoutexpis rejected.401and closes — before the socket is registered, so a bad token leaves no usable tunnel.validationblock can bind a claimed ID to a verified claim, e.g.tenant_id_format: "%DYNAMIC_METADATA(...reverse_tunnel.jwt:tenant)%".flowchart LR HS["handshake + Bearer JWT"] --> JWT["verify JWT"] JWT -- fail --> R1["401 · not registered"] JWT -- ok --> BIND["claimed id == verified claim"] BIND -- mismatch --> R2["403 · not registered"] BIND -- ok --> REG["register socket"]Why a scoped config (not
JwtProvider).Most of jwt_authn's
JwtProvider— remote/async JWKS, query-param/cookie extraction, upstream forwarding, route cache — is meaningless for an L4 pre-registration handshake.jwt_validationis a small, purpose-built message; the security-relevant semantics (required issuer andexp, normalized audience, clock skew) mirror jwt_authn.Notes.
jwt_validationconfigured, behavior is unchanged.allow_missing_or_failedis an audit mode: failures are counted (jwt_would_deny) but not blocked.//source/common/jwt(synchronouslocal_jwksonly;remote_jwksis a follow-up). The thin sync wrapper stays in the filter for now, with a TODO to promote it to a shared helper if a second L4 consumer appears.Risk Level:
Low — opt-in experimental field. One change rides along:
%REQ(...)%in avalidationformat string used to render empty and silently pass because the handshake's request headers weren't wired into the formatter; they are now, so it actually evaluates. It was effectively unusable before, so nothing should depend on the old behavior.Testing:
ReverseTunnelJwtTestin//test/extensions/filters/network/reverse_tunnel:filter_unit_test— acceptance (default header, bare token, custom header); every rejection path (missing, malformed, forged signature, expired, no-exp, wrong issuer, wrong audience) asserting the socket isn't registered; claim binding (accept +403on mismatch); audit mode; and config-load failures (missing issuer, unparseable JWKS).Docs Changes:
Proto fields documented inline.
Release Notes:
Changelog fragment under
new_features(newreverse_tunnelarea).Platform Specific Features:
None.
Runtime guard:
None — gated by
jwt_validationproto field.API Considerations:
Additive v3 change — new
JwtHandshakeValidationmessage +ReverseTunnel.jwt_validationfield; nothing existing changes. Uses onlyconfig.core.v3.DataSource(already imported), so the generated API build is untouched. Deliberately notjwt_authn.v3.JwtProvider, but mirrors its security-relevant semantics.