Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ add support for appsec in crowdsec #123

Merged
merged 11 commits into from
Jan 24, 2024
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,24 @@ Only one instance of the plugin is *possible*.
- Enabled
- bool
- default: false
- enable the plugin
- Enable the plugin
- LogLevel
- string
- default: `INFO`, expected values are: `INFO`, `DEBUG`
- CrowdsecMode
- string
- default: `live`, expected values are: `none`, `live`, `stream`, `alone`
- CrowdsecAppsecEnabled
- bool
- default: false
- Enable Crowdsec Appsec Server (WAF)
- CrowdsecAppsecScheme
mathieuHa marked this conversation as resolved.
Show resolved Hide resolved
- string
- default: `http`, expected values are: `http`, `https`
mathieuHa marked this conversation as resolved.
Show resolved Hide resolved
- CrowdsecAppsecHost
- string
- default: "crowdsec:7422"
- Crowdsec Appsec Server available on which host and port.
- CrowdsecLapiScheme
- string
- default: `http`, expected values are: `http`, `https`
Expand Down
9 changes: 9 additions & 0 deletions acquis.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
---
filenames:
- /var/log/traefik/access.log
labels:
type: traefik

---
listen_addr: 0.0.0.0:7422
appsec_config: crowdsecurity/virtual-patching
name: myAppSecComponent
source: appsec
labels:
type: appsec
98 changes: 85 additions & 13 deletions bouncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,19 @@ import (
)

const (
crowdsecLapiHeader = "X-Api-Key"
crowdsecCapiHeader = "Authorization"
crowdsecLapiRoute = "v1/decisions"
crowdsecLapiStreamRoute = "v1/decisions/stream"
crowdsecCapiLogin = "v2/watchers/login"
crowdsecCapiStreamRoute = "v2/decisions/stream"
cacheTimeoutKey = "updated"
crowdsecAppsecIPHeader = "X-Crowdsec-Appsec-Ip"
crowdsecAppsecURIHeader = "X-Crowdsec-Appsec-Uri"
crowdsecAppsecHostHeader = "X-Crowdsec-Appsec-Host"
crowdsecAppsecVerbHeader = "X-Crowdsec-Appsec-Verb"
crowdsecAppsecHeader = "X-Crowdsec-Appsec-Api-Key"
crowdsecLapiHeader = "X-Api-Key"
crowdsecLapiRoute = "v1/decisions"
crowdsecLapiStreamRoute = "v1/decisions/stream"
crowdsecCapiHost = "api.crowdsec.net"
crowdsecCapiHeader = "Authorization"
crowdsecCapiLoginRoute = "v2/watchers/login"
crowdsecCapiStreamRoute = "v2/decisions/stream"
cacheTimeoutKey = "updated"
)

//nolint:gochecknoglobals
Expand All @@ -50,6 +56,9 @@ type Bouncer struct {
template *template.Template

enabled bool
appsecEnabled bool
appsecScheme string
appsecHost string
crowdsecScheme string
crowdsecHost string
crowdsecKey string
Expand Down Expand Up @@ -86,9 +95,9 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n
if config.CrowdsecMode == configuration.AloneMode {
config.CrowdsecCapiMachineID, _ = configuration.GetVariable(config, "CrowdsecCapiMachineID")
config.CrowdsecCapiPassword, _ = configuration.GetVariable(config, "CrowdsecCapiPassword")
config.CrowdsecLapiHost = "api.crowdsec.net"
config.CrowdsecLapiHost = crowdsecCapiHost
config.CrowdsecLapiScheme = "https"
config.UpdateIntervalSeconds = 7200
config.UpdateIntervalSeconds = 7200 // 2 hours
crowdsecStreamRoute = crowdsecCapiStreamRoute
crowdsecHeader = crowdsecCapiHeader
} else {
Expand All @@ -114,6 +123,9 @@ func New(ctx context.Context, next http.Handler, config *configuration.Config, n

enabled: config.Enabled,
crowdsecMode: config.CrowdsecMode,
appsecEnabled: config.CrowdsecAppsecEnabled,
appsecScheme: config.CrowdsecAppsecScheme,
appsecHost: config.CrowdsecAppsecHost,
crowdsecScheme: config.CrowdsecLapiScheme,
crowdsecHost: config.CrowdsecLapiHost,
crowdsecKey: config.CrowdsecLapiKey,
Expand Down Expand Up @@ -214,14 +226,15 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
} else {
bouncer.next.ServeHTTP(rw, req)
}
handleNextServeHTTP(bouncer, remoteIP, rw, req)
return
}
}

// Right here if we cannot join the stream we forbid the request to go on.
if bouncer.crowdsecMode == configuration.StreamMode || bouncer.crowdsecMode == configuration.AloneMode {
if isCrowdsecStreamHealthy {
bouncer.next.ServeHTTP(rw, req)
handleNextServeHTTP(bouncer, remoteIP, rw, req)
} else {
logger.Debug(fmt.Sprintf("ServeHTTP isCrowdsecStreamHealthy:false ip:%s", remoteIP))
rw.WriteHeader(http.StatusForbidden)
Expand All @@ -232,8 +245,7 @@ func (bouncer *Bouncer) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
logger.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:true %s", remoteIP, err.Error()))
rw.WriteHeader(http.StatusForbidden)
} else {
logger.Debug(fmt.Sprintf("ServeHTTP:handleNoStreamCache ip:%s isBanned:false", remoteIP))
bouncer.next.ServeHTTP(rw, req)
handleNextServeHTTP(bouncer, remoteIP, rw, req)
}
}
}
Expand Down Expand Up @@ -266,6 +278,18 @@ type Login struct {
Expire string `json:"expire"`
}

func handleNextServeHTTP(bouncer *Bouncer, remoteIP string, rw http.ResponseWriter, req *http.Request) {
if bouncer.appsecEnabled {
err := appsecQuery(bouncer, remoteIP, req)
if err != nil {
logger.Debug(fmt.Sprintf("handleNextServeHTTP ip:%s isWaf:true %s", remoteIP, err.Error()))
rw.WriteHeader(http.StatusForbidden)
return
}
}
bouncer.next.ServeHTTP(rw, req)
}

func handleStreamTicker(bouncer *Bouncer) {
if err := handleStreamCache(bouncer); err != nil {
isCrowdsecStreamHealthy = false
Expand Down Expand Up @@ -342,7 +366,7 @@ func getToken(bouncer *Bouncer) error {
loginURL := url.URL{
Scheme: bouncer.crowdsecScheme,
Host: bouncer.crowdsecHost,
Path: crowdsecCapiLogin,
Path: crowdsecCapiLoginRoute,
}
body, err := crowdsecQuery(bouncer, loginURL.String(), true)
if err != nil {
Expand Down Expand Up @@ -444,3 +468,51 @@ func crowdsecQuery(bouncer *Bouncer, stringURL string, isPost bool) ([]byte, err
}
return body, nil
}

func appsecQuery(bouncer *Bouncer, ip string, httpReq *http.Request) error {
routeURL := url.URL{
Scheme: bouncer.appsecScheme,
Host: bouncer.appsecHost,
Path: "/",
}
var req *http.Request
if httpReq.Body != nil && httpReq.ContentLength > 0 {
bodyBytes, err := io.ReadAll(httpReq.Body)
if err != nil {
return fmt.Errorf("appsecQuery:GetBody %w", err)
}
httpReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
maxlerebourg marked this conversation as resolved.
Show resolved Hide resolved
req, _ = http.NewRequest(http.MethodPost, routeURL.String(), bytes.NewBuffer(bodyBytes))
} else {
req, _ = http.NewRequest(http.MethodGet, routeURL.String(), nil)
}

for key, headers := range httpReq.Header {
for _, value := range headers {
req.Header.Add(key, value)
maxlerebourg marked this conversation as resolved.
Show resolved Hide resolved
}
}
req.Header.Add(crowdsecAppsecHeader, bouncer.crowdsecKey)
req.Header.Add(crowdsecAppsecIPHeader, ip)
req.Header.Add(crowdsecAppsecVerbHeader, httpReq.Method)
req.Header.Add(crowdsecAppsecHostHeader, httpReq.Host)
req.Header.Add(crowdsecAppsecURIHeader, httpReq.URL.Path)

res, err := bouncer.httpClient.Do(req)
if err != nil {
return fmt.Errorf("appsecQuery %w", err)
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("appsecQuery statusCode:%d", res.StatusCode)
mathieuHa marked this conversation as resolved.
Show resolved Hide resolved
}
defer func() {
if err = res.Body.Close(); err != nil {
logger.Error(fmt.Sprintf("appsecQuery:closeBody %s", err.Error()))
}
}()

if err != nil {
return fmt.Errorf("appsecQuery:readBody %w", err)
}
return nil
}
28 changes: 21 additions & 7 deletions pkg/configuration/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ type Config struct {
Enabled bool `json:"enabled,omitempty"`
LogLevel string `json:"logLevel,omitempty"`
CrowdsecMode string `json:"crowdsecMode,omitempty"`
CrowdsecAppsecEnabled bool `json:"crowdsecAppsecEnabled,omitempty"`
CrowdsecAppsecScheme string `json:"crowdsecAppsecScheme,omitempty"`
CrowdsecAppsecHost string `json:"crowdsecAppsecHost,omitempty"`
CrowdsecLapiScheme string `json:"crowdsecLapiScheme,omitempty"`
CrowdsecLapiHost string `json:"crowdsecLapiHost,omitempty"`
CrowdsecLapiKey string `json:"crowdsecLapiKey,omitempty"`
Expand All @@ -51,7 +54,7 @@ type Config struct {
UpdateIntervalSeconds int64 `json:"updateIntervalSeconds,omitempty"`
DefaultDecisionSeconds int64 `json:"defaultDecisionSeconds,omitempty"`
HTTPTimeoutSeconds int64 `json:"httpTimeoutSeconds,omitempty"`
ForwardedHeadersCustomName string `json:"forwardedheaderscustomheader,omitempty"`
ForwardedHeadersCustomName string `json:"forwardedHeadersCustomHeader,omitempty"`
maxlerebourg marked this conversation as resolved.
Show resolved Hide resolved
ForwardedHeadersTrustedIPs []string `json:"forwardedHeadersTrustedIps,omitempty"`
ClientTrustedIPs []string `json:"clientTrustedIps,omitempty"`
RedisCacheEnabled bool `json:"redisCacheEnabled,omitempty"`
Expand All @@ -76,6 +79,9 @@ func New() *Config {
Enabled: false,
LogLevel: "INFO",
CrowdsecMode: LiveMode,
CrowdsecAppsecEnabled: false,
CrowdsecAppsecScheme: HTTP,
CrowdsecAppsecHost: "crowdsec:7422",
CrowdsecLapiScheme: HTTP,
CrowdsecLapiHost: "crowdsec:8080",
CrowdsecLapiKey: "",
Expand Down Expand Up @@ -149,13 +155,12 @@ func ValidateParams(config *Config) error {
return nil
}

// This only check that the format of the URL scheme:// is correct and do not make requests
testURL := url.URL{
Scheme: config.CrowdsecLapiScheme,
Host: config.CrowdsecLapiHost,
if err := validateURL("CrowdsecLapi", config.CrowdsecLapiScheme, config.CrowdsecLapiHost); err != nil {
return err
}
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
return fmt.Errorf("CrowdsecLapiScheme://CrowdsecLapiHost: '%v://%v' must be an URL", config.CrowdsecLapiScheme, config.CrowdsecLapiHost)

if err := validateURL("CrowdsecAppsec", config.CrowdsecAppsecScheme, config.CrowdsecAppsecHost); err != nil {
return err
}

lapiKey, err := GetVariable(config, "CrowdsecLapiKey")
Expand Down Expand Up @@ -190,6 +195,15 @@ func ValidateParams(config *Config) error {
return nil
}

func validateURL(variable, scheme, host string) error {
// This only check that the format of the URL scheme://host is correct and do not make requests
testURL := url.URL{Scheme: scheme, Host: host}
if _, err := http.NewRequest(http.MethodGet, testURL.String(), nil); err != nil {
return fmt.Errorf("%sScheme://%sHost: '%v://%v' must be an URL", variable, variable, scheme, host)
}
return nil
}

// validHeaderFieldByte reports whether b is a valid byte in a header
// field name. RFC 7230 says:
// valid ! # $ % & ' * + - . ^ _ ` | ~ DIGIT ALPHA
Expand Down
Loading