Skip to content
Merged
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
3 changes: 3 additions & 0 deletions cli/linter/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -1131,6 +1131,9 @@
"type": "object"
}
},
"allow_unsafe_dynamic_mtls_token": {
"type": "boolean"
},
"certificates": {
"type": ["object", "null"],
"additionalProperties": false,
Expand Down
5 changes: 5 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,11 @@ type SecurityConfig struct {
// Specify public keys used for Certificate Pinning on global level.
PinnedPublicKeys map[string]string `json:"pinned_public_keys"`

// AllowUnsafeDynamicMTLSToken controls whether certificate presence is required for
// dynamic mTLS authentication. If set to false (default), requests with a token but
// no certificate will be rejected for APIs using dynamic mTLS.
AllowUnsafeDynamicMTLSToken bool `json:"allow_unsafe_dynamic_mtls_token"`

Certificates CertificatesConfig `json:"certificates"`

// CertificateExpiryMonitor configures the certificate expiry monitoring and notification feature
Expand Down
2 changes: 1 addition & 1 deletion gateway/auth_manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ func TestHashKeyFunctionChanged(t *testing.T) {
Data: session, Client: client, Code: http.StatusOK})

client = GetTLSClient(&clientCert, nil)
testChangeHashFunc(t, nil, client, http.StatusForbidden)
testChangeHashFunc(t, nil, client, http.StatusUnauthorized)
})

}
Expand Down
25 changes: 21 additions & 4 deletions gateway/cert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1387,7 +1387,7 @@ func TestKeyWithCertificateTLS(t *testing.T) {
client := GetTLSClient(&clientCert, nil)

t.Run("Cert unknown", func(t *testing.T) {
_, _ = ts.Run(t, test.TestCase{Code: 403, Client: client})
_, _ = ts.Run(t, test.TestCase{Code: 401, Client: client})
})

t.Run("Cert known", func(t *testing.T) {
Expand Down Expand Up @@ -1451,7 +1451,7 @@ func TestKeyWithCertificateTLS(t *testing.T) {
t.Run("Cert unknown", func(t *testing.T) {
_, _ = ts.Run(t,
test.TestCase{Code: 404, Path: "/test1", Client: client},
test.TestCase{Code: 403, Path: "/test1", Domain: "localhost", Client: client},
test.TestCase{Code: 401, Path: "/test1", Domain: "localhost", Client: client},
)
})

Expand Down Expand Up @@ -1492,7 +1492,14 @@ func TestKeyWithCertificateTLS(t *testing.T) {
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
globalConf := ts.Gw.GetConfig()
globalConf.Security.AllowUnsafeDynamicMTLSToken = true
ts.Gw.SetConfig(globalConf)
_, _ = ts.Run(t, test.TestCase{Path: "/test1", Headers: header, Code: http.StatusOK, Domain: "localhost", Client: client})

globalConf = ts.Gw.GetConfig()
globalConf.Security.AllowUnsafeDynamicMTLSToken = false
ts.Gw.SetConfig(globalConf)
})
})

Expand Down Expand Up @@ -1526,8 +1533,8 @@ func TestKeyWithCertificateTLS(t *testing.T) {

_, _ = ts.Run(t, []test.TestCase{
{Code: http.StatusNotFound, Path: "/test1", Client: client},
{Code: http.StatusForbidden, Path: "/test1", Domain: "host1", Client: client},
{Code: http.StatusForbidden, Path: "/test1", Domain: "host2", Client: client},
{Code: http.StatusUnauthorized, Path: "/test1", Domain: "host1", Client: client},
{Code: http.StatusUnauthorized, Path: "/test1", Domain: "host2", Client: client},
}...)

_, _ = ts.CreateSession(func(s *user.SessionState) {
Expand Down Expand Up @@ -1579,6 +1586,16 @@ func TestKeyWithCertificateTLS(t *testing.T) {

// check that key has been updated with wrong certificate
t.Run("Key has been updated with wrong certificate key", func(t *testing.T) {
globalConf := ts.Gw.GetConfig()
globalConf.Security.AllowUnsafeDynamicMTLSToken = true
ts.Gw.SetConfig(globalConf)

defer func() {
globalConf = ts.Gw.GetConfig()
globalConf.Security.AllowUnsafeDynamicMTLSToken = false
ts.Gw.SetConfig(globalConf)
}()

clientPEM, _, _, clientCert := crypto.GenCertificate(&x509.Certificate{}, false)
clientCertID, err := ts.Gw.CertificateManager.Add(clientPEM, orgId)

Expand Down
1 change: 1 addition & 0 deletions gateway/handler_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const (

MsgAuthFieldMissing = "Authorization field missing"
MsgApiAccessDisallowed = "Access to this API has been disallowed"
MsgAuthCertRequired = "Client certificate required"
MsgBearerMailformed = "Bearer token malformed"
MsgKeyNotAuthorized = "Key not authorised"
MsgOauthClientRevoked = "Key not authorised. OAuth client access was revoked"
Expand Down
99 changes: 72 additions & 27 deletions gateway/mw_auth_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,9 @@
ErrAuthKeyNotFound = "auth.key_not_found"
ErrAuthCertNotFound = "auth.cert_not_found"
ErrAuthCertExpired = "auth.cert_expired"
ErrAuthCertMismatch = "auth.cert_mismatch"
ErrAuthKeyIsInvalid = "auth.key_is_invalid"
ErrAuthCertRequired = "auth.cert_required"
ErrAuthCertMismatch = "auth.cert_mismatch"

MsgNonExistentKey = "Attempted access with non-existent key."
MsgNonExistentCert = "Attempted access with non-existent cert."
Expand Down Expand Up @@ -63,9 +64,14 @@
Code: http.StatusForbidden,
}

TykErrors[ErrAuthCertRequired] = config.TykError{
Message: MsgAuthCertRequired,
Code: http.StatusUnauthorized,
}

TykErrors[ErrAuthCertMismatch] = config.TykError{
Message: MsgApiAccessDisallowed,
Code: http.StatusForbidden,
Code: http.StatusUnauthorized,
}
}

Expand All @@ -92,50 +98,89 @@
}

// getAuthType overrides BaseMiddleware.getAuthType.
func (k *AuthKey) getAuthType() string {
return apidef.AuthTokenType
}

// checkSessionWithCertFallback attempts to find a session using a generated token from certHash,
// falling back to checking with the certHash directly if the token lookup fails.
func (k *AuthKey) checkSessionWithCertFallback(r *http.Request, certHash string) (user.SessionState, bool) {
key := k.Gw.generateToken(k.Spec.OrgID, certHash)

Check warning on line 108 in gateway/mw_auth_key.go

View check run for this annotation

probelabs / Visor: performance

performance Issue

The function `checkSessionWithCertFallback` performs two sequential lookups to the session store (`CheckSessionAndIdentityForValidKey`) if the first key is not found. For requests to dynamic mTLS endpoints, this can introduce an additional network round-trip on a critical authentication path, potentially increasing latency.
Raw output
To optimize, consider using a batch lookup command (e.g., Redis MGET) if the session store driver supports it, allowing both potential keys to be checked in a single network call. This would reduce the latency impact of the fallback mechanism.
session, keyExists := k.CheckSessionAndIdentityForValidKey(key, r)
if !keyExists {
session, keyExists = k.CheckSessionAndIdentityForValidKey(certHash, r)
}
return session, keyExists
}

func (k *AuthKey) ProcessRequest(_ http.ResponseWriter, r *http.Request, _ interface{}) (error, int) {
if ctxGetRequestStatus(r) == StatusOkAndIgnore {
return nil, http.StatusOK
}

// skip auth key check if the request is looped.
if ses := ctxGetSession(r); ses != nil && httpctx.IsSelfLooping(r) {
return nil, http.StatusOK
}

key, authConfig := k.getAuthToken(k.getAuthType(), r)
var certHash string

keyExists := false
var session user.SessionState
updateSession := false
if key != "" {
key = stripBearer(key)
} else if authConfig.UseCertificate && key == "" && r.TLS != nil && len(r.TLS.PeerCertificates) > 0 {
log.Debug("Trying to find key by client certificate")
certHash = k.Spec.OrgID + crypto.HexSHA256(r.TLS.PeerCertificates[0].Raw)
if time.Now().After(r.TLS.PeerCertificates[0].NotAfter) {
return errorAndStatusCode(ErrAuthCertExpired)
}

key = k.Gw.generateToken(k.Spec.OrgID, certHash)
} else {
k.Logger().Info("Attempted access with malformed header, no auth header found.")
return errorAndStatusCode(ErrAuthAuthorizationFieldMissing)
}
var keyExists, updateSession bool
var certHash string
var session user.SessionState

session, keyExists = k.CheckSessionAndIdentityForValidKey(key, r)
key = session.KeyID
if !keyExists {
// fallback to search by cert
session, keyExists = k.CheckSessionAndIdentityForValidKey(certHash, r)
if !authConfig.UseCertificate {
if key == "" {
k.Logger().Info("Attempted access with malformed header, no auth header found.")
return errorAndStatusCode(ErrAuthAuthorizationFieldMissing)
}
if !keyExists {
return k.reportInvalidKey(key, r, MsgNonExistentKey, ErrAuthKeyNotFound)
}
}
if authConfig.UseCertificate && r.TLS != nil {
if len(r.TLS.PeerCertificates) > 0 {
if time.Now().After(r.TLS.PeerCertificates[0].NotAfter) {
return errorAndStatusCode(ErrAuthCertExpired)
}
certHash = k.Spec.OrgID + crypto.HexSHA256(r.TLS.PeerCertificates[0].Raw)
}

if !k.Gw.GetConfig().Security.AllowUnsafeDynamicMTLSToken {
if certHash == "" {
return errorAndStatusCode(ErrAuthCertRequired)
}
session, keyExists = k.checkSessionWithCertFallback(r, certHash)
if !keyExists {
return errorAndStatusCode(ErrAuthCertMismatch)
}
} else {
if certHash != "" {
session, keyExists = k.checkSessionWithCertFallback(r, certHash)
if !keyExists {
return errorAndStatusCode(ErrAuthCertMismatch)
}
}
if key != "" {
session, keyExists = k.CheckSessionAndIdentityForValidKey(key, r)
if !keyExists {
return k.reportInvalidKey(key, r, MsgNonExistentKey, ErrAuthKeyNotFound)
}

Check failure on line 171 in gateway/mw_auth_key.go

View check run for this annotation

probelabs / Visor: architecture

architecture Issue

The authentication logic for the insecure dynamic mTLS mode (`AllowUnsafeDynamicMTLSToken: true`) contains a flaw that alters the legacy behavior it is intended to preserve, and it also includes an unnecessary, redundant session lookup. 1. **Logic Flaw**: If a request provides an invalid client certificate, the function rejects it at line 162 (`ErrAuthCertMismatch`) without checking for a valid authentication token that might also be present. The legacy behavior allowed a valid token to grant access, treating the certificate as a secondary factor. This change breaks compatibility for clients who might send a valid token alongside an expired or incorrect certificate. 2. **Redundancy**: The call to `k.CheckSessionAndIdentityForValidKey(key, r)` at line 166 is redundant. The session is already checked for the token at the beginning of the function (line 132). This causes an unnecessary roundtrip to the session store, impacting performance.
Raw output
Refactor the logic for the insecure mode to correctly replicate the legacy behavior and remove the redundant call. The logic should prioritize the token if present, and only use the certificate as a fallback or if no token is provided. A possible flow:

1. Use the result of the initial session lookup (from line 132).
2. If the token is valid, authentication should succeed.
3. If the token is invalid or not present, *then* attempt to authenticate using the client certificate if one is provided.
4. This ensures that a valid token is always honored, preserving the intended legacy behavior.
}
}
} else {
if !keyExists {
// fallback to search by cert
session, keyExists = k.CheckSessionAndIdentityForValidKey(certHash, r)
if !keyExists {
return k.reportInvalidKey(key, r, MsgNonExistentKey, ErrAuthKeyNotFound)
}
}
}

Check warning on line 182 in gateway/mw_auth_key.go

View check run for this annotation

probelabs / Visor: architecture

architecture Issue

The `ProcessRequest` function has become overly complex and difficult to maintain due to the introduction of the new security flag. The deeply nested conditional logic for handling secure vs. insecure modes, combined with certificate vs. token authentication, significantly increases the cognitive load required to understand the control flow.
Raw output
Refactor the `ProcessRequest` function to improve clarity and separation of concerns. Extract the complex logic for secure and insecure dynamic mTLS into separate, well-named private helper functions. For example:

- `processSecureDynamicMTLS(r *http.Request) (user.SessionState, bool, error, int)`
- `processInsecureDynamicMTLS(r *http.Request, key string, initialSession user.SessionState, initialKeyExists bool) (user.SessionState, bool, error, int)`

This would transform the main `ProcessRequest` function into a clearer dispatcher, making the distinct logic for each authentication path easier to read, test, and maintain.
key = session.KeyID

Check failure on line 183 in gateway/mw_auth_key.go

View check run for this annotation

probelabs / Visor: quality

logic Issue

In insecure mode (`AllowUnsafeDynamicMTLSToken: true`), if an API is configured for dynamic mTLS, a request with no credentials (no token and no client certificate) is not rejected. The logic skips both credential checks and proceeds with an unauthenticated session, effectively bypassing authentication. The function should fail fast if no valid credentials are provided.
Raw output
After checking for `certHash` and `key`, add a final check to ensure that a session was actually found. If no credentials were provided or if they were invalid, the request should be rejected with an `ErrAuthAuthorizationFieldMissing` error. The current implementation allows the function to proceed with a `false` value for `keyExists`, which leads to the authentication bypass.

// Validate certificate binding or dynamic mTLS
// Certificate binding validation runs when:
Expand Down Expand Up @@ -313,17 +358,17 @@

// validateWithTLSCertificate handles validation when a TLS client certificate is provided
func (k *AuthKey) validateWithTLSCertificate(ctx *certValidationContext) (int, error) {
// Check certificate expiry when a token is provided (not checked earlier in the flow)
if code, err := k.checkCertificateExpiry(ctx.request, ctx.key); err != nil {
return code, err
}

// Compute cert hash for comparison
*ctx.certHash = k.computeCertHash(ctx.request, *ctx.certHash)

// Use binding mode if the session has static certificate bindings configured
// Otherwise, use legacy mode for backward compatibility with dynamic mTLS
if ctx.useCertBinding {
// Check certificate expiry for cert binding mode
// (not checked earlier in the flow, unlike when authConfig.UseCertificate is true)
if code, err := k.checkCertificateExpiry(ctx.request, ctx.key); err != nil {
return code, err
}
return k.validateCertificateBinding(ctx.request, ctx.key, ctx.session, *ctx.certHash)
}

Expand Down
3 changes: 1 addition & 2 deletions gateway/mw_auth_key_certificate_binding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ func TestCertificateTokenBinding(t *testing.T) {
spec.ClientCertificates = []string{clientCertID1, clientCertID2}
authConf := apidef.AuthConfig{
Name: "authToken",
UseCertificate: true,
AuthHeaderName: "Authorization",
}
spec.AuthConfigs = map[string]apidef.AuthConfig{
Expand Down Expand Up @@ -112,7 +111,7 @@ func TestCertificateTokenBinding(t *testing.T) {
Client: client2,
Path: "/cert-binding",
Headers: map[string]string{"Authorization": key},
Code: http.StatusForbidden,
Code: http.StatusUnauthorized,
BodyMatch: MsgApiAccessDisallowed,
})
})
Expand Down
31 changes: 22 additions & 9 deletions gateway/mw_auth_key_mtls_combined_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func TestStaticAndDynamicMTLS(t *testing.T) {
globalConf.HttpServerOptions.UseSSL = true
globalConf.HttpServerOptions.SSLInsecureSkipVerify = true
globalConf.HttpServerOptions.SSLCertificates = []string{"default" + certID}
globalConf.Security.AllowUnsafeDynamicMTLSToken = true
}
ts := StartTest(conf)
defer ts.Close()
Expand Down Expand Up @@ -111,7 +112,6 @@ func TestStaticAndDynamicMTLS(t *testing.T) {
t.Run("Legacy dynamic mTLS auto-updates session certificate", func(t *testing.T) {
// In legacy mode (using session.Certificate without MtlsStaticCertificateBindings),
// the session certificate gets auto-updated with the current cert hash.
// This means ANY allowlisted certificate will work - no strict binding enforcement.
key := CreateSession(ts.Gw, func(s *user.SessionState) {
s.AccessRights = map[string]user.AccessDefinition{"combined-mtls-api": {
APIID: "combined-mtls-api",
Expand All @@ -121,15 +121,14 @@ func TestStaticAndDynamicMTLS(t *testing.T) {

assert.NotEmpty(t, key, "Should create key with certificate binding")

// Request with cert2 (in allowlist) will succeed and auto-update session to cert2
// This is the legacy behavior - no strict binding enforcement
// Request will fail because client2 is invalid and is being used to access the api
client2 := GetTLSClient(&clientCert2, serverCertPem)
_, _ = ts.Run(t, test.TestCase{
Domain: "localhost",
Client: client2,
Path: "/combined-mtls",
Headers: map[string]string{"Authorization": key},
Code: http.StatusOK, // Passes in legacy mode
Code: http.StatusUnauthorized, // Passes in legacy mode
})
})

Expand Down Expand Up @@ -181,6 +180,8 @@ func TestStaticAndDynamicMTLS(t *testing.T) {

t.Run("Fail: invalid auth token with valid cert", func(t *testing.T) {
// Request with valid cert but invalid token
// should the token still be validated if the cert is passed
// disable insecure
client1 := GetTLSClient(&clientCert1, serverCertPem)
_, _ = ts.Run(t, test.TestCase{
Domain: "localhost",
Expand Down Expand Up @@ -232,7 +233,7 @@ func TestStaticAndDynamicMTLS(t *testing.T) {
Client: client1,
Path: "/combined-mtls",
Headers: map[string]string{"Authorization": key},
Code: http.StatusForbidden,
Code: http.StatusUnauthorized,
BodyMatch: MsgApiAccessDisallowed,
})
})
Expand Down Expand Up @@ -279,15 +280,14 @@ func TestStaticAndDynamicMTLS(t *testing.T) {
})

t.Run("different certificate auto-updates session in legacy mode", func(t *testing.T) {
// In legacy dynamic mTLS mode, the session.Certificate gets auto-updated
// So using a different certificate will succeed and update the session
// should fail, certificate is invalid
wrongCertClient := GetTLSClient(&clientCert2, serverCertPem)
_, _ = ts.Run(t, test.TestCase{
Domain: "localhost",
Client: wrongCertClient,
Path: "/dynamic-only",
Headers: map[string]string{"Authorization": key},
Code: http.StatusOK, // Legacy mode allows any valid cert
Code: http.StatusUnauthorized, // Legacy mode allows any valid cert
})
})
})
Expand Down Expand Up @@ -360,6 +360,7 @@ func TestCertificateCheckMW_StrictAllowlistValidation(t *testing.T) {
globalConf.HttpServerOptions.UseSSL = true
globalConf.HttpServerOptions.SSLInsecureSkipVerify = true
globalConf.HttpServerOptions.SSLCertificates = []string{"default" + certID}
globalConf.Security.AllowUnsafeDynamicMTLSToken = true
}
ts := StartTest(conf)
defer ts.Close()
Expand Down Expand Up @@ -517,6 +518,7 @@ func TestStaticAndDynamicMTLS_ExpiredCertificate(t *testing.T) {
globalConf.HttpServerOptions.UseSSL = true
globalConf.HttpServerOptions.SSLInsecureSkipVerify = true
globalConf.HttpServerOptions.SSLCertificates = []string{"default" + certID}
globalConf.Security.AllowUnsafeDynamicMTLSToken = true
}
ts := StartTest(conf)
defer ts.Close()
Expand Down Expand Up @@ -618,6 +620,7 @@ func TestDynamicMTLS_InvalidCertificateInSession(t *testing.T) {
globalConf.HttpServerOptions.UseSSL = true
globalConf.HttpServerOptions.SSLInsecureSkipVerify = true
globalConf.HttpServerOptions.SSLCertificates = []string{"default" + certID}
globalConf.Security.AllowUnsafeDynamicMTLSToken = true
}
ts := StartTest(conf)
defer ts.Close()
Expand Down Expand Up @@ -709,6 +712,7 @@ func TestMultipleCertificateBindings(t *testing.T) {
globalConf.HttpServerOptions.UseSSL = true
globalConf.HttpServerOptions.SSLInsecureSkipVerify = true
globalConf.HttpServerOptions.SSLCertificates = []string{"default" + certID}
globalConf.Security.AllowUnsafeDynamicMTLSToken = true
}
ts := StartTest(conf)
defer ts.Close()
Expand Down Expand Up @@ -757,10 +761,19 @@ func TestMultipleCertificateBindings(t *testing.T) {
s.AccessRights = map[string]user.AccessDefinition{"multi-binding-api": {
APIID: "multi-binding-api",
}}
s.Certificate = certHash1
// Bind multiple certificates to the same token
s.MtlsStaticCertificateBindings = []string{certHash1, certHash2}
})

key2 := CreateSession(ts.Gw, func(s *user.SessionState) {
s.AccessRights = map[string]user.AccessDefinition{"multi-binding-api": {
APIID: "multi-binding-api",
}}
s.Certificate = certHash2
s.MtlsStaticCertificateBindings = []string{certHash2, certHash1}
})

assert.NotEmpty(t, key, "Should create key with multiple certificate bindings")

// Both certificates should work
Expand All @@ -778,7 +791,7 @@ func TestMultipleCertificateBindings(t *testing.T) {
Domain: "localhost",
Client: client2,
Path: "/multi-binding",
Headers: map[string]string{"Authorization": key},
Headers: map[string]string{"Authorization": key2},
Code: http.StatusOK,
})
})
Expand Down
Loading
Loading