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
69 changes: 69 additions & 0 deletions gateway/auth_manager.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gateway

import (
"context"
"encoding/base64"
"encoding/json"
"strings"
Expand All @@ -21,6 +22,7 @@ type SessionHandler interface {
UpdateSession(keyName string, session *user.SessionState, resetTTLTo int64, hashed bool) error
RemoveSession(orgID string, keyName string, hashed bool) bool
SessionDetail(orgID string, keyName string, hashed bool) (user.SessionState, bool)
SessionDetailContext(ctx context.Context, orgID string, keyName string, hashed bool) (user.SessionState, bool)
KeyExpired(newSession *user.SessionState) bool
Sessions(filter string) []string
ResetQuota(string, *user.SessionState, bool)
Expand Down Expand Up @@ -215,6 +217,73 @@ func (b *DefaultSessionManager) SessionDetail(orgID string, keyName string, hash
return session.Clone(), true
}

// SessionDetailContext returns the session detail using the storage engine with context support for cancellation
func (b *DefaultSessionManager) SessionDetailContext(ctx context.Context, orgID string, keyName string, hashed bool) (user.SessionState, bool) {
select {
case <-ctx.Done():
log.WithFields(logrus.Fields{
"prefix": "auth-mgr",
"inbound-key": b.Gw.obfuscateKey(keyName),
}).Debug("Context cancelled")
return user.SessionState{}, false
default:
}

var jsonKeyVal string
var err error
keyId := keyName

if hashed {
jsonKeyVal, err = b.store.GetRawKeyContext(ctx, b.store.GetKeyPrefix()+keyName)
} else {
if storage.TokenOrg(keyName) != orgID {
// try to get legacy and new format key at once
toSearchList := []string{}
if !b.Gw.GetConfig().DisableKeyActionsByUsername {
toSearchList = append(toSearchList, b.Gw.generateToken(orgID, keyName))
}

toSearchList = append(toSearchList, keyName)
for _, fallback := range b.Gw.GetConfig().HashKeyFunctionFallback {
if !b.Gw.GetConfig().DisableKeyActionsByUsername {
toSearchList = append(toSearchList, b.Gw.generateToken(orgID, keyName, fallback))
}
}

var jsonKeyValList []string

jsonKeyValList, err = b.store.GetMultiKeyContext(ctx, toSearchList)
// pick the 1st non empty from the returned list
for idx, val := range jsonKeyValList {
if val != "" {
jsonKeyVal = val
keyId = toSearchList[idx]
break
}
}
} else {
// key is not an imported one
jsonKeyVal, err = b.store.GetKeyContext(ctx, keyName)
}
}

if err != nil {
log.WithFields(logrus.Fields{
"prefix": "auth-mgr",
"inbound-key": b.Gw.obfuscateKey(keyName),
"err": err,
}).Debug("Could not get session detail, key not found")
return user.SessionState{}, false
}
session := &user.SessionState{}
if err := json.Unmarshal([]byte(jsonKeyVal), &session); err != nil {
log.Error("Couldn't unmarshal session object (may be cache miss): ", err)
return user.SessionState{}, false
}
session.KeyID = keyId
return session.Clone(), true
}

func (b *DefaultSessionManager) Stop() {}

// Sessions returns all sessions in the key store that match a filter key (a prefix)
Expand Down
13 changes: 13 additions & 0 deletions gateway/auth_manager_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gateway

import (
"context"
"crypto/x509"
"encoding/json"
"fmt"
Expand Down Expand Up @@ -578,3 +579,15 @@ func (c *countingStorageHandler) AppendToSet(s string, s2 string) {}
func (c *countingStorageHandler) Exists(s string) (bool, error) {
return false, nil
}

func (c *countingStorageHandler) GetKeyContext(ctx context.Context, s string) (string, error) {
return "", nil
}

func (c *countingStorageHandler) GetRawKeyContext(ctx context.Context, key string) (string, error) {
return "", nil
}

func (c *countingStorageHandler) GetMultiKeyContext(ctx context.Context, keys []string) ([]string, error) {
return nil, nil
}
16 changes: 16 additions & 0 deletions gateway/ldap_auth_handler.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gateway

import (
"context"
"errors"
"strings"

Expand Down Expand Up @@ -242,3 +243,18 @@
log.Error("Not implemented")
return false, nil
}

// GetKeyContext retrieves a key with context support.
func (l *LDAPStorageHandler) GetKeyContext(ctx context.Context, keyName string) (string, error) {
return l.GetKey(keyName)
}

Check failure on line 250 in gateway/ldap_auth_handler.go

View check run for this annotation

probelabs / Visor: architecture

architecture Issue

The context-aware method `GetKeyContext` does not propagate the context to the underlying blocking call (`l.GetKey`). This defeats the purpose of using a context for cancellation and timeouts, leading to a goroutine leak if the backend is unresponsive. The goroutine spawned in `fetchOrgSessionWithTimeout` will block indefinitely even after its parent context times out.
Raw output
Update the underlying LDAP client call to respect the context's deadline or cancellation signal. If the client library does not support contexts, this implementation is fundamentally unsafe for concurrent, time-bound operations.

// GetRawKeyContext retrieves a raw key with context support.
func (l *LDAPStorageHandler) GetRawKeyContext(ctx context.Context, keyName string) (string, error) {
return l.GetRawKey(keyName)
}

// GetMultiKeyContext retrieves multiple keys with context support.
func (l *LDAPStorageHandler) GetMultiKeyContext(ctx context.Context, keyNames []string) ([]string, error) {
return l.GetMultiKey(keyNames)
}
113 changes: 86 additions & 27 deletions gateway/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@

const (
DEFAULT_ORG_SESSION_EXPIRATION = int64(604800)
orgSessionFetchTimeout = 2 * time.Second
)

Check warning on line 43 in gateway/middleware.go

View check run for this annotation

probelabs / Visor: architecture

architecture Issue

The timeout for fetching an organization's session is hardcoded to 2 seconds. Hardcoding configuration values like this makes the system less adaptable to different operational environments and violates the principle of separating code from configuration.
Raw output
Externalize this timeout value by making it a configurable parameter in the gateway's main configuration file (e.g., `tyk.conf`). This allows operators to tune the behavior for their specific environment without requiring a code change.

var (
GlobalRate = ratecounter.NewRateCounter(1 * time.Second)
Expand Down Expand Up @@ -343,55 +344,113 @@
return t.Spec
}

func (t *BaseMiddleware) OrgSession(orgID string) (user.SessionState, bool) {
// fetchOrgSessionWithTimeout fetches org session with a timeout to prevent hanging
func (t *BaseMiddleware) fetchOrgSessionWithTimeout(orgID string) (user.SessionState, bool) {
timeoutCtx, cancel := context.WithTimeout(context.Background(), orgSessionFetchTimeout)
defer cancel()

resultChan := make(chan struct {
session user.SessionState
found bool
}, 1)

go func() {
defer func() {
if r := recover(); r != nil {
t.Logger().Errorf("Panic recovered during org session fetch for org %s: %v", orgID, r)
select {
case resultChan <- struct {
session user.SessionState
found bool
}{session: user.SessionState{}, found: false}:
case <-timeoutCtx.Done():
}
}
}()

if rpc.IsEmergencyMode() {
session, found := t.Spec.OrgSessionManager.SessionDetailContext(timeoutCtx, orgID, orgID, false)
if found && t.Spec.GlobalConfig.EnforceOrgDataAge {
t.Logger().Debug("Setting data expiry: ", orgID)
t.Gw.ExpiryCache.Set(session.OrgID, session.DataExpires, cache.DefaultExpiration)
}
session.SetKeyHash(storage.HashKey(orgID, t.Gw.GetConfig().HashKeys))

Check failure on line 376 in gateway/middleware.go

View check run for this annotation

probelabs / Visor: security

security Issue

The `fetchOrgSessionWithTimeout` function spawns a goroutine to fetch session details, but the context is ignored by several storage handlers (`MdcbStorage`, `RPCStorageHandler`, `LDAPStorageHandler`). If a backend is unresponsive, the goroutine will block indefinitely and leak. A sustained backend issue could lead to resource exhaustion and a gateway crash, constituting a denial-of-service vulnerability.
Raw output
The `context.Context` must be honored in all implementations of the `storage.Handler` interface. Update the `...Context` methods in `storage/mdcb_storage.go`, `gateway/rpc_storage_handler.go`, and `gateway/ldap_auth_handler.go` to use the context to enforce timeouts and cancellation on their underlying network client operations.

select {
case resultChan <- struct {
session user.SessionState
found bool
}{session: session.Clone(), found: found}:
case <-timeoutCtx.Done():
return
}
}()

Check failure on line 386 in gateway/middleware.go

View check run for this annotation

probelabs / Visor: performance

performance Issue

The `fetchOrgSessionWithTimeout` function spawns a goroutine with a timeout, but the context is not propagated to all underlying storage client calls (LDAP, RPC, MDCB). If a backend is unresponsive, the goroutine will block indefinitely and leak, even after the parent function times out. A sustained backend issue could lead to resource exhaustion and a gateway crash.
Raw output
To fix this leak, the `context` must be propagated down to the underlying network calls in all `storage.Handler` implementations. For `gateway/rpc_storage_handler.go`, update the HTTP client logic to use `http.NewRequestWithContext()`. For `gateway/ldap_auth_handler.go` and `storage/mdcb_storage.go`, the underlying client libraries must be updated to support context-based cancellation. If a storage backend's client library does not support cancellation, this timeout pattern is unsafe and should be conditionally disabled for that backend.

select {
case res := <-resultChan:
return res.session, res.found
case <-timeoutCtx.Done():
t.Logger().Warning("Org session fetch timed out after 2s")
return user.SessionState{}, false
}
}

// Try and get the session from the session store
session, found := t.Spec.OrgSessionManager.SessionDetail(orgID, orgID, false)
if found && t.Spec.GlobalConfig.EnforceOrgDataAge {
// If exists, assume it has been authorized and pass on
// We cache org expiry data
t.Logger().Debug("Setting data expiry: ", orgID)

t.Gw.ExpiryCache.Set(session.OrgID, session.DataExpires, cache.DefaultExpiration)
func (t *BaseMiddleware) OrgSession(orgID string) (user.SessionState, bool) {
if rpc.IsEmergencyMode() {
return user.SessionState{}, false
}

session.SetKeyHash(storage.HashKey(orgID, t.Gw.GetConfig().HashKeys))
// Fetch with timeout to prevent blocking
t.Logger().Debug("Fetching org session with timeout")
session, found := t.fetchOrgSessionWithTimeout(orgID)

return session.Clone(), found
return session, found
}

func (t *BaseMiddleware) SetOrgExpiry(orgid string, expiry int64) {
t.Gw.ExpiryCache.Set(orgid, expiry, cache.DefaultExpiration)
}

Check warning on line 412 in gateway/middleware.go

View check run for this annotation

probelabs / Visor: security

security Issue

When an organization's session expiry is not found in the cache, the function immediately returns a hardcoded 7-day default (`DEFAULT_ORG_SESSION_EXPIRATION`) and refreshes in the background. If the backend is unavailable, this fail-open behavior overrides any organization-specific shorter session lifetimes configured for security, increasing the risk window for compromised tokens.
Raw output
Introduce a configuration option to allow administrators to choose the fail-safe behavior: either fail open with a configurable default TTL (current behavior) or fail closed by rejecting requests. The default TTL itself should also be configurable to allow for more conservative security postures.
func (t *BaseMiddleware) OrgSessionExpiry(orgid string) int64 {
t.Logger().Debug("Checking: ", orgid)

// Cache failed attempt
id, err, _ := orgSessionExpiryCache.Do(orgid, func() (interface{}, error) {
cachedVal, found := t.Gw.ExpiryCache.Get(orgid)
if found {
return cachedVal, nil
// Check cache first
cachedVal, found := t.Gw.ExpiryCache.Get(orgid)
if found {
val, ok := cachedVal.(int64)
if !ok {
t.Logger().Error("Type assertion failed")
return DEFAULT_ORG_SESSION_EXPIRATION
}
return val
}

s, found := t.OrgSession(orgid)
if found && t.Spec.GlobalConfig.EnforceOrgDataAge {
return s.DataExpires, nil
}
return 0, errors.New("missing session")
// Cache miss
go orgSessionExpiryCache.Do(orgid, func() (interface{}, error) {
return t.refreshOrgSessionExpiry(orgid)
})

if err != nil {
t.Logger().Debug("no cached entry found, returning 7 days")
t.SetOrgExpiry(orgid, DEFAULT_ORG_SESSION_EXPIRATION)
return DEFAULT_ORG_SESSION_EXPIRATION
t.Logger().Debug("no cached entry found, returning 7 days (async refresh started)")
return DEFAULT_ORG_SESSION_EXPIRATION
}

// refreshOrgSessionExpiry fetches org session expiry in the background
func (t *BaseMiddleware) refreshOrgSessionExpiry(orgid string) (interface{}, error) {
defer func() {
if r := recover(); r != nil {
t.Logger().Errorf("Panic recovered during org session expiry refresh for org %s: %v", orgid, r)
}
}()

s, found := t.OrgSession(orgid) // RPC call happens in background
if found && t.Spec.GlobalConfig.EnforceOrgDataAge {
t.Logger().Debug("Background refresh: setting data expiry for org: ", orgid)
t.SetOrgExpiry(orgid, s.DataExpires)
return s.DataExpires, nil
}

return id.(int64)
t.Logger().Debug("Background refresh: org session not found, setting default expiry")
t.SetOrgExpiry(orgid, DEFAULT_ORG_SESSION_EXPIRATION)
return DEFAULT_ORG_SESSION_EXPIRATION, nil
}

func (t *BaseMiddleware) UpdateRequestSession(r *http.Request) bool {
Expand Down
Loading
Loading