appsec: support custom CA for lapi (#3503)

* apisever, appsec: refact listenAndServe..()

* RemoveAll() -> Remove()

* configure CA for tls auth request

* ignore error from os.Remove(socket) when there's no file

* appsec functional test

* lint
This commit is contained in:
mmetc 2025-03-12 09:33:21 +01:00 committed by GitHub
parent 9bb7ad8c3a
commit a432a6352d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 354 additions and 88 deletions

View file

@ -2,9 +2,12 @@ package appsecacquisition
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net"
"net/http"
"os"
@ -64,6 +67,7 @@ type AppsecSource struct {
AuthCache AuthCache
AppsecRunners []AppsecRunner // one for each go-routine
appsecAllowlistClient *allowlists.AppsecAllowlist
lapiCACertPool *x509.CertPool
}
// Struct to handle cache of authentication
@ -158,6 +162,28 @@ func (w *AppsecSource) GetAggregMetrics() []prometheus.Collector {
return []prometheus.Collector{AppsecReqCounter, AppsecBlockCounter, AppsecRuleHits, AppsecOutbandParsingHistogram, AppsecInbandParsingHistogram, AppsecGlobalParsingHistogram}
}
func loadCertPool(caCertPath string, logger log.FieldLogger) (*x509.CertPool, error) {
caCertPool, err := x509.SystemCertPool()
if err != nil {
logger.Warnf("Error loading system CA certificates: %s", err)
}
if caCertPool == nil {
caCertPool = x509.NewCertPool()
}
if caCertPath != "" {
caCert, err := os.ReadFile(caCertPath)
if err != nil {
return nil, fmt.Errorf("while opening cert file: %w", err)
}
caCertPool.AppendCertsFromPEM(caCert)
}
return caCertPool, nil
}
func (w *AppsecSource) Configure(yamlConfig []byte, logger *log.Entry, metricsLevel int) error {
err := w.UnmarshalConfig(yamlConfig)
if err != nil {
@ -241,8 +267,7 @@ func (w *AppsecSource) Configure(yamlConfig []byte, logger *log.Entry, metricsLe
appsecAllowlistsClient: w.appsecAllowlistClient,
}
err := runner.Init(appsecCfg.GetDataDir())
if err != nil {
if err = runner.Init(appsecCfg.GetDataDir()); err != nil {
return fmt.Errorf("unable to initialize runner: %w", err)
}
@ -254,6 +279,19 @@ func (w *AppsecSource) Configure(yamlConfig []byte, logger *log.Entry, metricsLe
// We don´t use the wrapper provided by coraza because we want to fully control what happens when a rule match to send the information in crowdsec
w.mux.HandleFunc(w.config.Path, w.appsecHandler)
csConfig := csconfig.GetConfig()
caCertPath := ""
if csConfig.API.Server.TLS != nil {
caCertPath = csConfig.API.Server.TLS.CACertPath
}
w.lapiCACertPool, err = loadCertPool(caCertPath, w.logger)
if err != nil {
return fmt.Errorf("unable to load LAPI CA cert pool: %w", err)
}
return nil
}
@ -273,6 +311,103 @@ func (w *AppsecSource) OneShotAcquisition(_ context.Context, _ chan types.Event,
return errors.New("AppSec datasource does not support command line acquisition")
}
func (w *AppsecSource) listenAndServe(ctx context.Context, t *tomb.Tomb) error {
defer trace.CatchPanic("crowdsec/acquis/appsec/listenAndServe")
w.logger.Infof("%d appsec runner to start", len(w.AppsecRunners))
serverError := make(chan error, 2)
startServer := func(listener net.Listener, canTLS bool) {
var err error
if canTLS && (w.config.CertFilePath != "" || w.config.KeyFilePath != "") {
if w.config.KeyFilePath == "" {
serverError <- errors.New("missing TLS key file")
return
}
if w.config.CertFilePath == "" {
serverError <- errors.New("missing TLS cert file")
return
}
err = w.server.ServeTLS(listener, w.config.CertFilePath, w.config.KeyFilePath)
} else {
err = w.server.Serve(listener)
}
switch {
case errors.Is(err, http.ErrServerClosed):
break
case err != nil:
serverError <- err
}
}
// Starting Unix socket listener
go func(socket string) {
if socket == "" {
return
}
if err := os.Remove(w.config.ListenSocket); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
w.logger.Errorf("can't remove socket %s: %s", socket, err)
}
}
w.logger.Infof("creating unix socket %s", socket)
listener, err := net.Listen("unix", socket)
if err != nil {
serverError <- fmt.Errorf("appsec server failed: %w", err)
return
}
w.logger.Infof("Appsec listening on Unix socket %s", socket)
startServer(listener, false)
}(w.config.ListenSocket)
// Starting TCP listener
go func(url string) {
if url == "" {
return
}
listener, err := net.Listen("tcp", url)
if err != nil {
serverError <- fmt.Errorf("listening on %s: %w", url, err)
}
w.logger.Infof("Appsec listening on %s", url)
startServer(listener, true)
}(w.config.ListenAddr)
select {
case err := <-serverError:
return err
case <-t.Dying():
w.logger.Info("Shutting down Appsec server")
// xx let's clean up the appsec runners :)
appsec.AppsecRulesDetails = make(map[int]appsec.RulesDetails)
if err := w.server.Shutdown(ctx); err != nil {
w.logger.Errorf("Error shutting down Appsec server: %s", err.Error())
}
if w.config.ListenSocket != "" {
if err := os.Remove(w.config.ListenSocket); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
w.logger.Errorf("can't remove socket %s: %s", w.config.ListenSocket, err)
}
}
}
}
return nil
}
func (w *AppsecSource) StreamingAcquisition(ctx context.Context, out chan types.Event, t *tomb.Tomb) error {
w.outChan = out
@ -285,13 +420,12 @@ func (w *AppsecSource) StreamingAcquisition(ctx context.Context, out chan types.
if err != nil {
return fmt.Errorf("failed to fetch allowlists: %w", err)
}
w.appsecAllowlistClient.StartRefresh(ctx, t)
t.Go(func() error {
defer trace.CatchPanic("crowdsec/acquis/appsec/live")
w.logger.Infof("%d appsec runner to start", len(w.AppsecRunners))
for _, runner := range w.AppsecRunners {
runner.outChan = out
@ -301,60 +435,7 @@ func (w *AppsecSource) StreamingAcquisition(ctx context.Context, out chan types.
})
}
t.Go(func() error {
if w.config.ListenSocket != "" {
w.logger.Infof("creating unix socket %s", w.config.ListenSocket)
_ = os.RemoveAll(w.config.ListenSocket)
listener, err := net.Listen("unix", w.config.ListenSocket)
if err != nil {
return fmt.Errorf("appsec server failed: %w", err)
}
defer listener.Close()
if w.config.CertFilePath != "" && w.config.KeyFilePath != "" {
err = w.server.ServeTLS(listener, w.config.CertFilePath, w.config.KeyFilePath)
} else {
err = w.server.Serve(listener)
}
if err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("appsec server failed: %w", err)
}
}
return nil
})
t.Go(func() error {
var err error
if w.config.ListenAddr != "" {
w.logger.Infof("creating TCP server on %s", w.config.ListenAddr)
if w.config.CertFilePath != "" && w.config.KeyFilePath != "" {
err = w.server.ListenAndServeTLS(w.config.CertFilePath, w.config.KeyFilePath)
} else {
err = w.server.ListenAndServe()
}
if err != nil && err != http.ErrServerClosed {
return fmt.Errorf("appsec server failed: %w", err)
}
}
return nil
})
<-t.Dying()
w.logger.Info("Shutting down Appsec server")
// xx let's clean up the appsec runners :)
appsec.AppsecRulesDetails = make(map[int]appsec.RulesDetails)
if err := w.server.Shutdown(ctx); err != nil {
w.logger.Errorf("Error shutting down Appsec server: %s", err.Error())
}
return nil
return w.listenAndServe(ctx, t)
})
return nil
@ -373,21 +454,29 @@ func (w *AppsecSource) Dump() interface{} {
}
func (w *AppsecSource) IsAuth(ctx context.Context, apiKey string) bool {
client := &http.Client{
Timeout: 200 * time.Millisecond,
}
req, err := http.NewRequestWithContext(ctx, http.MethodHead, w.lapiURL, http.NoBody)
if err != nil {
log.Errorf("Error creating request: %s", err)
w.logger.Errorf("Error creating request: %s", err)
return false
}
req.Header.Add("X-Api-Key", apiKey)
client := &http.Client{
Timeout: 200 * time.Millisecond,
}
if w.lapiCACertPool != nil {
client.Transport = &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: w.lapiCACertPool,
},
}
}
resp, err := client.Do(req)
if err != nil {
log.Errorf("Error performing request: %s", err)
w.logger.Errorf("Error performing request: %s", err)
return false
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"net"
"net/http"
"os"
@ -407,15 +408,11 @@ func (s *APIServer) Run(apiReady chan bool) error {
// it also updates the URL field with the actual address the server is listening on
// it's meant to be run in a separate goroutine
func (s *APIServer) listenAndServeLAPI(apiReady chan bool) error {
var (
tcpListener net.Listener
unixListener net.Listener
err error
serverError = make(chan error, 2)
listenerClosed = make(chan struct{})
)
serverError := make(chan error, 2)
startServer := func(listener net.Listener, canTLS bool) {
var err error
if canTLS && s.TLS != nil && (s.TLS.CertFilePath != "" || s.TLS.KeyFilePath != "") {
if s.TLS.KeyFilePath == "" {
serverError <- errors.New("missing TLS key file")
@ -441,38 +438,42 @@ func (s *APIServer) listenAndServeLAPI(apiReady chan bool) error {
}
// Starting TCP listener
go func() {
if s.URL == "" {
go func(url string) {
if url == "" {
return
}
tcpListener, err = net.Listen("tcp", s.URL)
listener, err := net.Listen("tcp", url)
if err != nil {
serverError <- fmt.Errorf("listening on %s: %w", s.URL, err)
serverError <- fmt.Errorf("listening on %s: %w", url, err)
return
}
log.Infof("CrowdSec Local API listening on %s", s.URL)
startServer(tcpListener, true)
}()
log.Infof("CrowdSec Local API listening on %s", url)
startServer(listener, true)
}(s.URL)
// Starting Unix socket listener
go func() {
if s.UnixSocket == "" {
go func(socket string) {
if socket == "" {
return
}
_ = os.RemoveAll(s.UnixSocket)
if err := os.Remove(socket); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
log.Errorf("can't remove socket %s: %s", socket, err)
}
}
unixListener, err = net.Listen("unix", s.UnixSocket)
listener, err := net.Listen("unix", socket)
if err != nil {
serverError <- fmt.Errorf("while creating unix listener: %w", err)
return
}
log.Infof("CrowdSec Local API listening on Unix socket %s", s.UnixSocket)
startServer(unixListener, false)
}()
log.Infof("CrowdSec Local API listening on Unix socket %s", socket)
startServer(listener, false)
}(s.UnixSocket)
apiReady <- true
@ -489,10 +490,12 @@ func (s *APIServer) listenAndServeLAPI(apiReady chan bool) error {
log.Errorf("while shutting down http server: %v", err)
}
close(listenerClosed)
case <-listenerClosed:
if s.UnixSocket != "" {
_ = os.RemoveAll(s.UnixSocket)
if err := os.Remove(s.UnixSocket); err != nil {
if !errors.Is(err, fs.ErrNotExist) {
log.Errorf("can't remove socket %s: %s", s.UnixSocket, err)
}
}
}
}

174
test/bats/appsec.bats Normal file
View file

@ -0,0 +1,174 @@
#!/usr/bin/env bats
set -u
setup_file() {
load "../lib/setup_file.sh"
CONFIG_DIR=$(dirname "$CONFIG_YAML")
export CONFIG_DIR
ACQUIS_DIR=$(config_get '.crowdsec_service.acquisition_dir')
export ACQUIS_DIR
}
teardown_file() {
load "../lib/teardown_file.sh"
}
setup() {
load "../lib/setup.sh"
./instance-data load
mkdir -p "$ACQUIS_DIR"
}
teardown() {
./instance-crowdsec stop
}
#----------
@test "invalid configuration" {
config_set '.common.log_media="stdout"'
cat > "$ACQUIS_DIR"/appsec.yaml <<-EOT
source: appsec
EOT
rune -1 wait-for "$CROWDSEC"
assert_stderr --partial "crowdsec init: while loading acquisition config: missing labels in $ACQUIS_DIR/appsec.yaml (position 0)"
cat > "$ACQUIS_DIR"/appsec.yaml <<-EOT
source: appsec
labels:
type: appsec
EOT
rune -1 wait-for "$CROWDSEC"
assert_stderr --partial "crowdsec init: while loading acquisition config: while configuring datasource of type appsec from $ACQUIS_DIR/appsec.yaml (position 0): unable to parse appsec configuration: appsec_config or appsec_config_path must be set"
}
@test "appsec allow and ban" {
config_set '.common.log_media="stdout"'
rune -0 cscli collections install crowdsecurity/appsec-virtual-patching
rune -0 cscli collections install crowdsecurity/appsec-generic-rules
socket="$BATS_TEST_TMPDIR"/sock
cat > "$ACQUIS_DIR"/appsec.yaml <<-EOT
source: appsec
listen_socket: $socket
labels:
type: appsec
appsec_config: crowdsecurity/appsec-default
EOT
rune -0 wait-for \
--err "Appsec Runner ready to process event" \
"$CROWDSEC"
assert_stderr --partial "loading inband rule crowdsecurity/base-config"
assert_stderr --partial "loading inband rule crowdsecurity/vpatch-*"
assert_stderr --partial "loading inband rule crowdsecurity/generic-*"
assert_stderr --partial "Created 1 appsec runners"
assert_stderr --partial "Appsec Runner ready to process event"
./instance-crowdsec start
rune -0 cscli bouncers add appsecbouncer --key appkey
# appsec will perform a HEAD request to validate.
# If it fails, check downstream with:
#
# lapisocket=$(config_get '.api.server.listen_socket')
# rune -0 curl -sS --fail-with-body --unix-socket "$lapisocket" -H "X-Api-Key: appkey" "http://fakehost/v1/decisions/stream"
# assert_json '{deleted:null,new:null}'
rune -0 curl -sS --fail-with-body --unix-socket "$socket" \
-H "x-crowdsec-appsec-api-key: appkey" \
-H "x-crowdsec-appsec-ip: 1.2.3.4" \
-H 'x-crowdsec-appsec-uri: /' \
-H 'x-crowdsec-appsec-host: foo.com' \
-H 'x-crowdsec-appsec-verb: GET' \
'http://fakehost'
assert_json '{action:"allow",http_status:200}'
rune -22 curl -sS --fail-with-body --unix-socket "$socket" \
-H "x-crowdsec-appsec-api-key: appkey" \
-H "x-crowdsec-appsec-ip: 1.2.3.4" \
-H 'x-crowdsec-appsec-uri: /.env' \
-H 'x-crowdsec-appsec-host: foo.com' \
-H 'x-crowdsec-appsec-verb: GET' \
'http://fakehost'
assert_json '{action:"ban",http_status:403}'
}
@test "TLS connection to lapi, own CA" {
tmpdir="$BATS_FILE_TMPDIR"
CFDIR="$BATS_TEST_DIRNAME/testdata/cfssl"
# Root CA
cfssl gencert -loglevel 2 \
--initca "$CFDIR/ca_root.json" \
| cfssljson --bare "$tmpdir/root"
# Intermediate CA
cfssl gencert -loglevel 2 \
--initca "$CFDIR/ca_intermediate.json" \
| cfssljson --bare "$tmpdir/inter"
cfssl sign -loglevel 2 \
-ca "$tmpdir/root.pem" -ca-key "$tmpdir/root-key.pem" \
-config "$CFDIR/profiles.json" -profile intermediate_ca "$tmpdir/inter.csr" \
| cfssljson --bare "$tmpdir/inter"
# Server cert for crowdsec with the intermediate
cfssl gencert -loglevel 2 \
-ca "$tmpdir/inter.pem" -ca-key "$tmpdir/inter-key.pem" \
-config "$CFDIR/profiles.json" -profile=server "$CFDIR/server.json" \
| cfssljson --bare "$tmpdir/server"
cat "$tmpdir/root.pem" "$tmpdir/inter.pem" > "$tmpdir/bundle.pem"
export tmpdir
config_set '
.api.server.tls.cert_file=strenv(tmpdir) + "/server.pem" |
.api.server.tls.key_file=strenv(tmpdir) + "/server-key.pem" |
.api.server.tls.ca_cert_path=strenv(tmpdir) + "/inter.pem"
'
rune -0 cscli collections install crowdsecurity/appsec-virtual-patching
rune -0 cscli collections install crowdsecurity/appsec-generic-rules
socket="$BATS_TEST_TMPDIR"/sock
cat > "$ACQUIS_DIR"/appsec.yaml <<-EOT
source: appsec
listen_socket: $socket
labels:
type: appsec
appsec_config: crowdsecurity/appsec-default
EOT
config_set "$CONFIG_DIR/local_api_credentials.yaml" '
.url="https://127.0.0.1:8080" |
.ca_cert_path=strenv(tmpdir) + "/bundle.pem"
'
./instance-crowdsec start
rune -0 cscli bouncers add appsecbouncer --key appkey
rune -0 curl -sS --fail-with-body --unix-socket "$socket" \
-H "x-crowdsec-appsec-api-key: appkey" \
-H "x-crowdsec-appsec-ip: 1.2.3.4" \
-H 'x-crowdsec-appsec-uri: /' \
-H 'x-crowdsec-appsec-host: foo.com' \
-H 'x-crowdsec-appsec-verb: GET' \
'http://fakehost'
assert_json '{action:"allow",http_status:200}'
}