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

feat: appsec lib #42

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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
259 changes: 259 additions & 0 deletions appsec.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
package csbouncer

import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"

"gopkg.in/yaml.v2"

log "github.com/sirupsen/logrus"
)

const (
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"
crowdsecAppsecUserAgent = "X-Crowdsec-Appsec-User-Agent"
)

type Timeout struct {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe rename to AppsecTimeout as Timeout might be misunderstood as also applying to the connection to LAPI itself ?

ConnectTimeout *int `yaml:"connect_timeout"`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should probably transform those to time.Duration (and let the user of the library handle the parsing/validation).
With the way things are currently, it's not possible to have a timeout lower than 1s, which is likely way too long for the majority of users.

TLSHandshakeTimeout *int `yaml:"tls_handshake_timeout"`
ResponseHeaderTimeout *int `yaml:"response_header_timeout"`
}

func (t *Timeout) SetDefaults() {
if t.ConnectTimeout == nil {
t.ConnectTimeout = new(int)
*t.ConnectTimeout = 5
}
if t.TLSHandshakeTimeout == nil {
t.TLSHandshakeTimeout = new(int)
*t.TLSHandshakeTimeout = 5
}
if t.ResponseHeaderTimeout == nil {
t.ResponseHeaderTimeout = new(int)
*t.ResponseHeaderTimeout = 5
}
}

// AppSecConfig is a struct that holds the configuration for the AppSec.
type AppSecConfig struct {
Url string `yaml:"url"`
InsecureSkipVerify *bool `yaml:"insecure_skip_verify"`
CAPath string `yaml:"ca_cert_path"`
ParsedUrl *url.URL `yaml:"-"`
Timeout Timeout `yaml:"timeout"`
}

// AppSec is a struct that holds the configuration for the AppSec. Inherits the API key from the bouncer config.
type AppSec struct {
APIKey string `yaml:"api_key"`
AppSecConfig *AppSecConfig `yaml:"appsec_config"`
Client *http.Client `yaml:"-"`
}

type AppSecResponse struct {
Response *http.Response
Action string `json:"action"`
HTTPStatus int `json:"http_status"`
}

// Config() fills the struct with configuration values from a file. It is not
// aware of .yaml.local files so it is recommended to use ConfigReader() instead.
func (w *AppSec) Config(configPath string) error {
reader, err := os.Open(configPath)
if err != nil {
return fmt.Errorf("unable to read config file '%s': %w", configPath, err)
}

return w.ConfigReader(reader)
}

func (w *AppSec) ConfigReader(configReader io.Reader) error {
content, err := io.ReadAll(configReader)
if err != nil {
return fmt.Errorf("unable to read configuration: %w", err)
}

err = yaml.Unmarshal(content, w)
if err != nil {
return fmt.Errorf("unable to unmarshal config file: %w", err)
}

return nil
}

func (w *AppSec) Init() error {
var err error

if w.AppSecConfig.Url == "" {
return fmt.Errorf("config does not contain AppSec url")
}

if w.AppSecConfig.ParsedUrl, err = url.Parse(w.AppSecConfig.Url); err != nil {
return fmt.Errorf("unable to parse AppSec url: %w", err)
}

if w.AppSecConfig.InsecureSkipVerify == nil {
w.AppSecConfig.InsecureSkipVerify = new(bool)
*w.AppSecConfig.InsecureSkipVerify = false
}

w.AppSecConfig.Timeout.SetDefaults()

caCertPool, err := getCertPool(w.AppSecConfig.CAPath, log.StandardLogger())
if err != nil {
return err
}

dialContext := (&net.Dialer{
Timeout: time.Duration(*w.AppSecConfig.Timeout.ConnectTimeout) * time.Second,
}).DialContext

if w.AppSecConfig.ParsedUrl.Scheme == "unix" || strings.HasPrefix(w.AppSecConfig.ParsedUrl.String(), "/") {
dialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return net.Dial("unix", strings.TrimSuffix(w.AppSecConfig.ParsedUrl.Path, "/"))
}
w.AppSecConfig.ParsedUrl.Host = "unix"
w.AppSecConfig.ParsedUrl.Scheme = "http"
}

w.Client = &http.Client{
Transport: &http.Transport{
DialContext: dialContext,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: *w.AppSecConfig.InsecureSkipVerify,
RootCAs: caCertPool,
},
TLSHandshakeTimeout: time.Duration(*w.AppSecConfig.Timeout.TLSHandshakeTimeout) * time.Second,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hardcoded second is way too long (see my comment above in the Timeout struct)

ResponseHeaderTimeout: time.Duration(*w.AppSecConfig.Timeout.ResponseHeaderTimeout) * time.Second,
},
}

return nil
}

// ParseClientReq parses the client request and returns a new request that is ready to be forwarded to the AppSec.
// You can override the IP address with the ipOverride parameter.
// This function should not be used directly, use Forward() instead.
func (w *AppSec) ParseClientReq(ctx context.Context, clientReq *http.Request, ipOverride string) (*http.Request, error) {
var req *http.Request

if clientReq.Body != nil && clientReq.ContentLength > 0 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need some kind of override for the body: in some situations, we do not want to read the entire body in memory (for example, a file upload of a few GB).
To keep things simple in the library, it could just be a true/false flag, and we let the user set it (main drawback being this means different bouncers are very likely to have different configuration/support).

Cleaner solution would be to provide additional configuration with for example body size, URL prefix, source IP and so on

(this is an issue we already have in the nginx appsec integration)

bodyBytes, err := io.ReadAll(clientReq.Body)
if err != nil {
return nil, err
}
clientReq.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
req, _ = http.NewRequestWithContext(ctx, http.MethodPost, w.AppSecConfig.ParsedUrl.String(), bytes.NewBuffer(bodyBytes))
} else {
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, w.AppSecConfig.ParsedUrl.String(), nil)
}

for key, headers := range clientReq.Header {
for _, value := range headers {
req.Header.Add(key, value)
}
}

if ipOverride != "" {
req.Header.Set(crowdsecAppsecIPHeader, ipOverride)
} else {
req.Header.Set(crowdsecAppsecIPHeader, clientReq.RemoteAddr)
}

req.Header.Set(crowdsecAppsecHeader, w.APIKey)
req.Header.Set(crowdsecAppsecVerbHeader, clientReq.Method)
req.Header.Set(crowdsecAppsecHostHeader, clientReq.Host)
req.Header.Set(crowdsecAppsecURIHeader, clientReq.URL.String())
req.Header.Set(crowdsecAppsecUserAgent, clientReq.Header.Get("User-Agent"))

return req, nil
}

// Internal forward function that sends the request to the AppSec and returns the response.
func (w *AppSec) forward(req *http.Request) (*AppSecResponse, error) {
res, err := w.Client.Do(req)

if err != nil {
return &AppSecResponse{Response: res}, fmt.Errorf("appsecQuery %w", err)
}
defer res.Body.Close()

wr := &AppSecResponse{Response: res}

if res.StatusCode == http.StatusInternalServerError {
return wr, fmt.Errorf("appsecQuery: unexpected status code %d", res.StatusCode)
}

if err := json.NewDecoder(res.Body).Decode(wr); err != nil {
return wr, fmt.Errorf("appsecQuery %w", err)
}

return wr, nil
}

// ForwardCTXWithIP forwards the request to the AppSec and returns the response.
// You can override the IP address with the IP parameter.
// You do not need to parse the client request, just pass it as an argument.
// You can pass a context as the first argument. This is useful if you want to cancel the request.
func (w *AppSec) ForwardCTXWithIP(ctx context.Context, clientReq *http.Request, IP string) (*AppSecResponse, error) {
req, err := w.ParseClientReq(ctx, clientReq, IP)

if err != nil {
return nil, fmt.Errorf("appsecQuery %w", err)
}

return w.forward(req)
}

// ForwardWithIP forwards the request to the AppSec and returns the response.
// You can override the IP address with the IP parameter.
// You do not need to parse the client request, just pass it as an argument.
func (w *AppSec) ForwardWithIP(clientReq *http.Request, IP string) (*AppSecResponse, error) {
req, err := w.ParseClientReq(context.Background(), clientReq, IP)

if err != nil {
return nil, fmt.Errorf("appsecQuery %w", err)
}

return w.forward(req)
}

// ForwardCTX forwards the request to the AppSec and returns the response.
// You do not need to parse the client request, just pass it as an argument.
// You can pass a context as the first argument. This is useful if you want to cancel the request.
func (w *AppSec) ForwardCTX(ctx context.Context, clientReq *http.Request) (*AppSecResponse, error) {
req, err := w.ParseClientReq(ctx, clientReq, "")

if err != nil {
return nil, fmt.Errorf("appsecQuery %w", err)
}

return w.forward(req)
}

// Forward forwards the request to the AppSec and returns the response.
// You do not need to parse the client request, just pass it as an argument.
func (w *AppSec) Forward(clientReq *http.Request) (*AppSecResponse, error) {
req, err := w.ParseClientReq(context.Background(), clientReq, "")

if err != nil {
return nil, fmt.Errorf("appsecQuery %w", err)
}

return w.forward(req)
}
Loading