Skip to content

Commit

Permalink
Allow bouncers to share API keys (#3323)
Browse files Browse the repository at this point in the history
  • Loading branch information
blotus authored Nov 19, 2024
1 parent 36e2c6c commit fb733ee
Show file tree
Hide file tree
Showing 19 changed files with 389 additions and 39 deletions.
2 changes: 1 addition & 1 deletion cmd/crowdsec-cli/clibouncer/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func (cli *cliBouncers) add(ctx context.Context, bouncerName string, key string)
}
}

_, err = cli.db.CreateBouncer(ctx, bouncerName, "", middlewares.HashSHA512(key), types.ApiKeyAuthType)
_, err = cli.db.CreateBouncer(ctx, bouncerName, "", middlewares.HashSHA512(key), types.ApiKeyAuthType, false)
if err != nil {
return fmt.Errorf("unable to create bouncer: %w", err)
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/crowdsec-cli/clibouncer/bouncers.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ type bouncerInfo struct {
AuthType string `json:"auth_type"`
OS string `json:"os,omitempty"`
Featureflags []string `json:"featureflags,omitempty"`
AutoCreated bool `json:"auto_created"`
}

func newBouncerInfo(b *ent.Bouncer) bouncerInfo {
Expand All @@ -92,6 +93,7 @@ func newBouncerInfo(b *ent.Bouncer) bouncerInfo {
AuthType: b.AuthType,
OS: clientinfo.GetOSNameAndVersion(b),
Featureflags: clientinfo.GetFeatureFlagList(b),
AutoCreated: b.AutoCreated,
}
}

Expand Down
62 changes: 55 additions & 7 deletions cmd/crowdsec-cli/clibouncer/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,73 @@ import (
"context"
"errors"
"fmt"
"strings"

log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"

"github.com/crowdsecurity/crowdsec/pkg/database"
"github.com/crowdsecurity/crowdsec/pkg/database/ent"
"github.com/crowdsecurity/crowdsec/pkg/types"
)

func (cli *cliBouncers) findParentBouncer(bouncerName string, bouncers []*ent.Bouncer) (string, error) {
bouncerPrefix := strings.Split(bouncerName, "@")[0]
for _, bouncer := range bouncers {
if strings.HasPrefix(bouncer.Name, bouncerPrefix) && !bouncer.AutoCreated {
return bouncer.Name, nil
}
}

return "", errors.New("no parent bouncer found")
}

func (cli *cliBouncers) delete(ctx context.Context, bouncers []string, ignoreMissing bool) error {
for _, bouncerID := range bouncers {
if err := cli.db.DeleteBouncer(ctx, bouncerID); err != nil {
var notFoundErr *database.BouncerNotFoundError
allBouncers, err := cli.db.ListBouncers(ctx)
if err != nil {
return fmt.Errorf("unable to list bouncers: %w", err)
}
for _, bouncerName := range bouncers {
bouncer, err := cli.db.SelectBouncerByName(ctx, bouncerName)
if err != nil {
var notFoundErr *ent.NotFoundError
if ignoreMissing && errors.As(err, &notFoundErr) {
return nil
continue
}
return fmt.Errorf("unable to delete bouncer %s: %w", bouncerName, err)
}

// For TLS bouncers, always delete them, they have no parents
if bouncer.AuthType == types.TlsAuthType {
if err := cli.db.DeleteBouncer(ctx, bouncerName); err != nil {
return fmt.Errorf("unable to delete bouncer %s: %w", bouncerName, err)
}
continue
}

if bouncer.AutoCreated {
parentBouncer, err := cli.findParentBouncer(bouncerName, allBouncers)
if err != nil {
log.Errorf("bouncer '%s' is auto-created, but couldn't find a parent bouncer", err)
continue
}
log.Warnf("bouncer '%s' is auto-created and cannot be deleted, delete parent bouncer %s instead", bouncerName, parentBouncer)
continue
}
//Try to find all child bouncers and delete them
for _, childBouncer := range allBouncers {
if strings.HasPrefix(childBouncer.Name, bouncerName+"@") && childBouncer.AutoCreated {
if err := cli.db.DeleteBouncer(ctx, childBouncer.Name); err != nil {
return fmt.Errorf("unable to delete bouncer %s: %w", childBouncer.Name, err)
}
log.Infof("bouncer '%s' deleted successfully", childBouncer.Name)
}
}

return fmt.Errorf("unable to delete bouncer: %w", err)
if err := cli.db.DeleteBouncer(ctx, bouncerName); err != nil {
return fmt.Errorf("unable to delete bouncer %s: %w", bouncerName, err)
}

log.Infof("bouncer '%s' deleted successfully", bouncerID)
log.Infof("bouncer '%s' deleted successfully", bouncerName)
}

return nil
Expand Down
1 change: 1 addition & 0 deletions cmd/crowdsec-cli/clibouncer/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func (cli *cliBouncers) inspectHuman(out io.Writer, bouncer *ent.Bouncer) {
{"Last Pull", lastPull},
{"Auth type", bouncer.AuthType},
{"OS", clientinfo.GetOSNameAndVersion(bouncer)},
{"Auto Created", bouncer.AutoCreated},
})

for _, ff := range clientinfo.GetFeatureFlagList(bouncer) {
Expand Down
3 changes: 3 additions & 0 deletions pkg/apiserver/alerts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ func (l *LAPI) RecordResponse(t *testing.T, ctx context.Context, verb string, ur
t.Fatal("auth type not supported")
}

// Port is required for gin to properly parse the client IP
req.RemoteAddr = "127.0.0.1:1234"

l.router.ServeHTTP(w, req)

return w
Expand Down
56 changes: 51 additions & 5 deletions pkg/apiserver/api_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,74 @@ func TestAPIKey(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.RemoteAddr = "127.0.0.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, 403, w.Code)
assert.Equal(t, `{"message":"access forbidden"}`, w.Body.String())
assert.Equal(t, http.StatusForbidden, w.Code)
assert.JSONEq(t, `{"message":"access forbidden"}`, w.Body.String())

// Login with invalid token
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", "a1b2c3d4e5f6")
req.RemoteAddr = "127.0.0.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, 403, w.Code)
assert.Equal(t, `{"message":"access forbidden"}`, w.Body.String())
assert.Equal(t, http.StatusForbidden, w.Code)
assert.JSONEq(t, `{"message":"access forbidden"}`, w.Body.String())

// Login with valid token
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", APIKey)
req.RemoteAddr = "127.0.0.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, 200, w.Code)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "null", w.Body.String())

// Login with valid token from another IP
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", APIKey)
req.RemoteAddr = "4.3.2.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "null", w.Body.String())

// Make the requests multiple times to make sure we only create one
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", APIKey)
req.RemoteAddr = "4.3.2.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "null", w.Body.String())

// Use the original bouncer again
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", APIKey)
req.RemoteAddr = "127.0.0.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "null", w.Body.String())

// Check if our second bouncer was properly created
bouncers := GetBouncers(t, config.API.Server.DbConfig)

assert.Len(t, bouncers, 2)
assert.Equal(t, "test@4.3.2.1", bouncers[1].Name)
assert.Equal(t, bouncers[0].APIKey, bouncers[1].APIKey)
assert.Equal(t, bouncers[0].AuthType, bouncers[1].AuthType)
assert.False(t, bouncers[0].AutoCreated)
assert.True(t, bouncers[1].AutoCreated)
}
16 changes: 15 additions & 1 deletion pkg/apiserver/apiserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
middlewares "github.com/crowdsecurity/crowdsec/pkg/apiserver/middlewares/v1"
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
"github.com/crowdsecurity/crowdsec/pkg/database"
"github.com/crowdsecurity/crowdsec/pkg/database/ent"
"github.com/crowdsecurity/crowdsec/pkg/models"
"github.com/crowdsecurity/crowdsec/pkg/types"
)
Expand Down Expand Up @@ -62,6 +63,7 @@ func LoadTestConfig(t *testing.T) csconfig.Config {
}
apiServerConfig := csconfig.LocalApiServerCfg{
ListenURI: "http://127.0.0.1:8080",
LogLevel: ptr.Of(log.DebugLevel),
DbConfig: &dbconfig,
ProfilesPath: "./tests/profiles.yaml",
ConsoleConfig: &csconfig.ConsoleConfig{
Expand Down Expand Up @@ -206,6 +208,18 @@ func GetMachineIP(t *testing.T, machineID string, config *csconfig.DatabaseCfg)
return ""
}

func GetBouncers(t *testing.T, config *csconfig.DatabaseCfg) []*ent.Bouncer {
ctx := context.Background()

dbClient, err := database.NewClient(ctx, config)
require.NoError(t, err)

bouncers, err := dbClient.ListBouncers(ctx)
require.NoError(t, err)

return bouncers
}

func GetAlertReaderFromFile(t *testing.T, path string) *strings.Reader {
alertContentBytes, err := os.ReadFile(path)
require.NoError(t, err)
Expand Down Expand Up @@ -290,7 +304,7 @@ func CreateTestBouncer(t *testing.T, ctx context.Context, config *csconfig.Datab
apiKey, err := middlewares.GenerateAPIKey(keyLength)
require.NoError(t, err)

_, err = dbClient.CreateBouncer(ctx, "test", "127.0.0.1", middlewares.HashSHA512(apiKey), types.ApiKeyAuthType)
_, err = dbClient.CreateBouncer(ctx, "test", "127.0.0.1", middlewares.HashSHA512(apiKey), types.ApiKeyAuthType, false)
require.NoError(t, err)

return apiKey
Expand Down
80 changes: 62 additions & 18 deletions pkg/apiserver/middlewares/v1/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func (a *APIKey) authTLS(c *gin.Context, logger *log.Entry) *ent.Bouncer {

logger.Infof("Creating bouncer %s", bouncerName)

bouncer, err = a.DbClient.CreateBouncer(ctx, bouncerName, c.ClientIP(), HashSHA512(apiKey), types.TlsAuthType)
bouncer, err = a.DbClient.CreateBouncer(ctx, bouncerName, c.ClientIP(), HashSHA512(apiKey), types.TlsAuthType, true)
if err != nil {
logger.Errorf("while creating bouncer db entry: %s", err)
return nil
Expand All @@ -114,18 +114,69 @@ func (a *APIKey) authPlain(c *gin.Context, logger *log.Entry) *ent.Bouncer {
return nil
}

clientIP := c.ClientIP()

ctx := c.Request.Context()

hashStr := HashSHA512(val[0])

bouncer, err := a.DbClient.SelectBouncer(ctx, hashStr)
// Appsec case, we only care if the key is valid
// No content is returned, no last_pull update or anything
if c.Request.Method == http.MethodHead {
bouncer, err := a.DbClient.SelectBouncers(ctx, hashStr, types.ApiKeyAuthType)
if err != nil {
logger.Errorf("while fetching bouncer info: %s", err)
return nil
}
return bouncer[0]
}

// most common case, check if this specific bouncer exists
bouncer, err := a.DbClient.SelectBouncerWithIP(ctx, hashStr, clientIP)
if err != nil && !ent.IsNotFound(err) {
logger.Errorf("while fetching bouncer info: %s", err)
return nil
}

// We found the bouncer with key and IP, we can use it
if bouncer != nil {
if bouncer.AuthType != types.ApiKeyAuthType {
logger.Errorf("bouncer isn't allowed to auth by API key")
return nil
}
return bouncer
}

// We didn't find the bouncer with key and IP, let's try to find it with the key only
bouncers, err := a.DbClient.SelectBouncers(ctx, hashStr, types.ApiKeyAuthType)
if err != nil {
logger.Errorf("while fetching bouncer info: %s", err)
return nil
}

if bouncer.AuthType != types.ApiKeyAuthType {
logger.Errorf("bouncer %s attempted to login using an API key but it is configured to auth with %s", bouncer.Name, bouncer.AuthType)
if len(bouncers) == 0 {
logger.Debugf("no bouncer found with this key")
return nil
}

logger.Debugf("found %d bouncers with this key", len(bouncers))

// We only have one bouncer with this key and no IP
// This is the first request made by this bouncer, keep this one
if len(bouncers) == 1 && bouncers[0].IPAddress == "" {
return bouncers[0]
}

// Bouncers are ordered by ID, first one *should* be the manually created one
// Can probably get a bit weird if the user deletes the manually created one
bouncerName := fmt.Sprintf("%s@%s", bouncers[0].Name, clientIP)

logger.Infof("Creating bouncer %s", bouncerName)

bouncer, err = a.DbClient.CreateBouncer(ctx, bouncerName, clientIP, hashStr, types.ApiKeyAuthType, true)

if err != nil {
logger.Errorf("while creating bouncer db entry: %s", err)
return nil
}

Expand Down Expand Up @@ -156,27 +207,20 @@ func (a *APIKey) MiddlewareFunc() gin.HandlerFunc {
return
}

logger = logger.WithField("name", bouncer.Name)

if bouncer.IPAddress == "" {
if err := a.DbClient.UpdateBouncerIP(ctx, clientIP, bouncer.ID); err != nil {
logger.Errorf("Failed to update ip address for '%s': %s\n", bouncer.Name, err)
c.JSON(http.StatusForbidden, gin.H{"message": "access forbidden"})
c.Abort()

return
}
// Appsec request, return immediately if we found something
if c.Request.Method == http.MethodHead {
c.Set(BouncerContextKey, bouncer)
return
}

// Don't update IP on HEAD request, as it's used by the appsec to check the validity of the API key provided
if bouncer.IPAddress != clientIP && bouncer.IPAddress != "" && c.Request.Method != http.MethodHead {
log.Warningf("new IP address detected for bouncer '%s': %s (old: %s)", bouncer.Name, clientIP, bouncer.IPAddress)
logger = logger.WithField("name", bouncer.Name)

// 1st time we see this bouncer, we update its IP
if bouncer.IPAddress == "" {
if err := a.DbClient.UpdateBouncerIP(ctx, clientIP, bouncer.ID); err != nil {
logger.Errorf("Failed to update ip address for '%s': %s\n", bouncer.Name, err)
c.JSON(http.StatusForbidden, gin.H{"message": "access forbidden"})
c.Abort()

return
}
}
Expand Down
Loading

0 comments on commit fb733ee

Please sign in to comment.