From 566a6c551f5922f9a9da13da3bec7de3364825fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Mazeau?= Date: Thu, 3 Nov 2022 12:10:25 +0100 Subject: [PATCH] internal/appsec: internal/appsec: add support for WAF actions ASM now instanciates an action handler that will perform various actions commanded by the WAF after a match is performed. The "block_request" action type is the only kind of action currently supported, allowing to block an HTTP request. --- contrib/google.golang.org/grpc/appsec.go | 6 ++ .../dyngo/instrumentation/grpcsec/actions.go | 85 +++++++++++++++++++ .../dyngo/instrumentation/grpcsec/grpc.go | 4 + .../dyngo/instrumentation/httpsec/actions.go | 84 ++++++++++++++++++ .../dyngo/instrumentation/httpsec/http.go | 51 ++++++++++- internal/appsec/waf.go | 49 +++++++---- 6 files changed, 261 insertions(+), 18 deletions(-) create mode 100644 internal/appsec/dyngo/instrumentation/grpcsec/actions.go create mode 100644 internal/appsec/dyngo/instrumentation/httpsec/actions.go diff --git a/contrib/google.golang.org/grpc/appsec.go b/contrib/google.golang.org/grpc/appsec.go index 8006107f67..a75a7ff884 100644 --- a/contrib/google.golang.org/grpc/appsec.go +++ b/contrib/google.golang.org/grpc/appsec.go @@ -25,6 +25,9 @@ func appsecUnaryHandlerMiddleware(span ddtrace.Span, handler grpc.UnaryHandler) return func(ctx context.Context, req interface{}) (interface{}, error) { md, _ := metadata.FromIncomingContext(ctx) op := grpcsec.StartHandlerOperation(grpcsec.HandlerOperationArgs{Metadata: md}, nil) + if op.UnaryHandler != nil { + return op.UnaryHandler(ctx, req) + } defer func() { events := op.Finish(grpcsec.HandlerOperationRes{}) instrumentation.SetTags(span, op.Tags()) @@ -44,6 +47,9 @@ func appsecStreamHandlerMiddleware(span ddtrace.Span, handler grpc.StreamHandler return func(srv interface{}, stream grpc.ServerStream) error { md, _ := metadata.FromIncomingContext(stream.Context()) op := grpcsec.StartHandlerOperation(grpcsec.HandlerOperationArgs{Metadata: md}, nil) + if op.StreamHandler != nil { + return op.StreamHandler(srv, stream) + } defer func() { events := op.Finish(grpcsec.HandlerOperationRes{}) instrumentation.SetTags(span, op.Tags()) diff --git a/internal/appsec/dyngo/instrumentation/grpcsec/actions.go b/internal/appsec/dyngo/instrumentation/grpcsec/actions.go new file mode 100644 index 0000000000..35dec93330 --- /dev/null +++ b/internal/appsec/dyngo/instrumentation/grpcsec/actions.go @@ -0,0 +1,85 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2022 Datadog, Inc. + +//go:build appsec +// +build appsec + +package grpcsec + +import ( + "context" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type ActionParam interface{} + +type action struct { + id string + params ActionParam +} + +// ActionHandler handles WAF actions registration and execution +type ActionHandler interface { + RegisterAction(id string, params ActionParam) + Exec(id string, op *HandlerOperation) +} + +type actionsHandler struct { + actions map[string]action +} + +// NewActionsHandler returns an action handler holding the default ASM actions. +// Currently, only the default "block" action is supported +func NewActionsHandler() ActionHandler { + defaultBlockAction := action{ + id: "block", + params: BlockRequestParams{ + Status: codes.Aborted, + }, + } + // Register the default "block" action as specified in the RFC for HTTP blocking + actions := map[string]action{defaultBlockAction.id: defaultBlockAction} + + return &actionsHandler{ + actions: actions, + } +} + +// RegisterAction registers a specific action to the actions handler. If the action kind is unknown +// the action will have no effect +func (h *actionsHandler) RegisterAction(id string, params ActionParam) { + h.actions[id] = action{ + id: id, + params: params, + } +} + +// Exec executes the action identified by `id` +func (h *actionsHandler) Exec(id string, op *HandlerOperation) { + a, ok := h.actions[id] + if !ok { + return + } + // Currently, only the "block_request" type is supported, so we only need to check for blockRequestParams + if p, ok := a.params.(BlockRequestParams); ok { + err := status.Error(p.Status, "Request blocked") + op.UnaryHandler = func(ctx context.Context, req interface{}) (interface{}, error) { + return nil, err + } + op.StreamHandler = func(srv interface{}, stream grpc.ServerStream) error { + return err + } + } +} + +// BlockRequestParams is the parameter struct used to perform actions of kind ActionBlockRequest +type BlockRequestParams struct { + ActionParam + // Status is the return code to use when blocking the request + Status codes.Code +} diff --git a/internal/appsec/dyngo/instrumentation/grpcsec/grpc.go b/internal/appsec/dyngo/instrumentation/grpcsec/grpc.go index 8eb2c638f3..ff65b25501 100644 --- a/internal/appsec/dyngo/instrumentation/grpcsec/grpc.go +++ b/internal/appsec/dyngo/instrumentation/grpcsec/grpc.go @@ -15,6 +15,8 @@ import ( "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo" "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo/instrumentation" + + "google.golang.org/grpc" ) // Abstract gRPC server handler operation definitions. It is based on two @@ -39,6 +41,8 @@ type ( dyngo.Operation instrumentation.TagsHolder instrumentation.SecurityEventsHolder + UnaryHandler grpc.UnaryHandler + StreamHandler grpc.StreamHandler } // HandlerOperationArgs is the grpc handler arguments. HandlerOperationArgs struct { diff --git a/internal/appsec/dyngo/instrumentation/httpsec/actions.go b/internal/appsec/dyngo/instrumentation/httpsec/actions.go new file mode 100644 index 0000000000..46cf84cb7b --- /dev/null +++ b/internal/appsec/dyngo/instrumentation/httpsec/actions.go @@ -0,0 +1,84 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2022 Datadog, Inc. + +//go:build appsec +// +build appsec + +package httpsec + +import ( + "net/http" +) + +// Action is used to identify any action kind +type Action interface { + isAction() +} + +// BlockRequestAction is the parameter struct used to perform actions of kind ActionBlockRequest +type BlockRequestAction struct { + Action + // Status is the return code to use when blocking the request + Status int + // Template is the payload template to use to write the response (html or json) + Template string + // handler is the http handler be used to block the request (see wrap()) + handler http.Handler +} + +func (*BlockRequestAction) isAction() {} + +func blockedPayload(a *BlockRequestAction) []byte { + payload := BlockedTemplateJSON + if a.Template == "html" { + payload = BlockedTemplateHTML + } + return payload +} + +// ActionsHandler handles actions registration and their application to operations +type ActionsHandler struct { + actions map[string]Action +} + +// NewActionsHandler returns an action handler holding the default ASM actions. +// Currently, only the default "block" action is supported +func NewActionsHandler() *ActionsHandler { + handler := ActionsHandler{ + actions: map[string]Action{}, + } + // Register the default "block" action as specified in the RFC for HTTP blocking + handler.RegisterAction("block", &BlockRequestAction{ + Status: 403, + Template: "html", + }) + + return &handler +} + +// RegisterAction registers a specific action to the handler. If the action kind is unknown +// the action will not be registered +func (h *ActionsHandler) RegisterAction(id string, action Action) { + switch a := action.(type) { + case *BlockRequestAction: + payload := blockedPayload(a) + a.handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Write(payload) + writer.WriteHeader(a.Status) + }) + h.actions[id] = a + default: + break + } +} + +// Apply applies the action identified by `id` for the given operation +func (h *ActionsHandler) Apply(id string, op *Operation) { + a, ok := h.actions[id] + if !ok { + return + } + op.actions = append(op.actions, a) +} diff --git a/internal/appsec/dyngo/instrumentation/httpsec/http.go b/internal/appsec/dyngo/instrumentation/httpsec/http.go index 3809522ebd..95319cd1a3 100644 --- a/internal/appsec/dyngo/instrumentation/httpsec/http.go +++ b/internal/appsec/dyngo/instrumentation/httpsec/http.go @@ -15,6 +15,7 @@ import ( "encoding/json" "net" "net/http" + "os" "reflect" "strings" @@ -74,6 +75,17 @@ func MonitorParsedBody(ctx context.Context, body interface{}) { // HandlerOperationRes. func WrapHandler(handler http.Handler, span ddtrace.Span, pathParams map[string]string) http.Handler { instrumentation.SetAppSecEnabledTags(span) + applyActions := func(op *Operation) { + for _, action := range op.Actions() { + switch a := action.(type) { + case *BlockRequestAction: + handler = a.handler + op.AddTag("appsec.blocked", true) + default: + log.Warn("appsec: unsupported action %v. Ignoring.", a) + } + } + } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { SetIPTags(span, r) @@ -81,18 +93,24 @@ func WrapHandler(handler http.Handler, span ddtrace.Span, pathParams map[string] args := MakeHandlerOperationArgs(r, pathParams) ctx, op := StartOperation(r.Context(), args) r = r.WithContext(ctx) + + events := op.Events() + if len(events) > 0 { + applyActions(op) + } defer func() { var status int if mw, ok := w.(interface{ Status() int }); ok { status = mw.Status() } - events := op.Finish(HandlerOperationRes{Status: status}) + events = append(events, op.Finish(HandlerOperationRes{Status: status})...) instrumentation.SetTags(span, op.Tags()) if len(events) == 0 { return } + applyActions(op) remoteIP, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { remoteIP = r.RemoteAddr @@ -101,6 +119,7 @@ func WrapHandler(handler http.Handler, span ddtrace.Span, pathParams map[string] }() handler.ServeHTTP(w, r) + }) } @@ -152,6 +171,7 @@ type ( dyngo.Operation instrumentation.TagsHolder instrumentation.SecurityEventsHolder + actions []Action } // SDKBodyOperation type representing an SDK body. It must be created with @@ -190,6 +210,10 @@ func (op *Operation) Finish(res HandlerOperationRes) []json.RawMessage { return op.Events() } +func (op *Operation) Actions() []Action { + return op.actions +} + // StartSDKBodyOperation starts the SDKBody operation and emits a start event func StartSDKBodyOperation(parent *Operation, args SDKBodyOperationArgs) *SDKBodyOperation { op := &SDKBodyOperation{Operation: dyngo.NewOperation(parent)} @@ -294,3 +318,28 @@ func IPFromRequest(r *http.Request) netaddrIP { return netaddrIP{} } + +var ( + // BlockedTemplateJSON is the default JSON template used to write responses for blocked requests + BlockedTemplateJSON = []byte(`{"errors": [{"title": "You've been blocked", "detail": "Sorry, you cannot access this page. Please contact the customer service team. Security provided by Datadog."}]}`) + // BlockedTemplateHTML is the default HTML template used to write responses for blocked requests + BlockedTemplateHTML = []byte(` You've been blocked

Sorry, you cannot access this page. Please contact the customer service team.

`) +) + +const ( + envBlockedTemplateHTML = "DD_APPSEC_HTTP_BLOCKED_TEMPLATE_HTML" + envBlockedTemplateJSON = "DD_APPSEC_HTTP_BLOCKED_TEMPLATE_JSON" +) + +func init() { + for env, template := range map[string]*[]byte{envBlockedTemplateJSON: &BlockedTemplateJSON, envBlockedTemplateHTML: &BlockedTemplateHTML} { + if path, ok := os.LookupEnv(env); ok { + if t, err := os.ReadFile(path); err != nil { + log.Warn("Could not read template at %s: %v", path, err) + } else { + *template = t + } + } + + } +} diff --git a/internal/appsec/waf.go b/internal/appsec/waf.go index 1d0f58ec51..4229223376 100644 --- a/internal/appsec/waf.go +++ b/internal/appsec/waf.go @@ -95,9 +95,35 @@ func registerWAF(rules []byte, timeout time.Duration, limiter Limiter, obfCfg *O // newWAFEventListener returns the WAF event listener to register in order to enable it. func newHTTPWAFEventListener(handle *waf.Handle, addresses []string, timeout time.Duration, limiter Limiter) dyngo.EventListener { var monitorRulesOnce sync.Once // per instantiation + actionHandler := httpsec.NewActionsHandler() return httpsec.OnHandlerOperationStart(func(op *httpsec.Operation, args httpsec.HandlerOperationArgs) { var body interface{} + wafCtx := waf.NewContext(handle) + if wafCtx == nil { + // The WAF event listener got concurrently released + return + } + + values := map[string]interface{}{} + for _, addr := range addresses { + if addr == httpClientIP && args.ClientIP.IsValid() { + values[httpClientIP] = args.ClientIP.String() + } + } + + matches, actionIds := runWAF(wafCtx, values, timeout) + actionIds = append(actionIds, "block") + if len(matches) > 0 { + for _, id := range actionIds { + actionHandler.Apply(id, op) + } + op.AddSecurityEvents(matches) + log.Debug("appsec: WAF detected an attack before executing the request") + if len(actionIds) > 0 { + return + } + } op.On(httpsec.OnSDKBodyOperationStart(func(op *httpsec.SDKBodyOperation, args httpsec.SDKBodyOperationArgs) { body = args.Body @@ -106,13 +132,7 @@ func newHTTPWAFEventListener(handle *waf.Handle, addresses []string, timeout tim // At the moment, AppSec doesn't block the requests, and so we can use the fact we are in monitoring-only mode // to call the WAF only once at the end of the handler operation. op.On(httpsec.OnHandlerOperationFinish(func(op *httpsec.Operation, res httpsec.HandlerOperationRes) { - wafCtx := waf.NewContext(handle) - if wafCtx == nil { - // The WAF event listener got concurrently released - return - } defer wafCtx.Close() - // Run the WAF on the rule addresses available in the request args values := make(map[string]interface{}, len(addresses)) for _, addr := range addresses { @@ -141,14 +161,9 @@ func newHTTPWAFEventListener(handle *waf.Handle, addresses []string, timeout tim } case serverResponseStatusAddr: values[serverResponseStatusAddr] = res.Status - - case httpClientIP: - if args.ClientIP.IsValid() { - values[httpClientIP] = args.ClientIP.String() - } } } - matches := runWAF(wafCtx, values, timeout) + matches, _ := runWAF(wafCtx, values, timeout) // Add WAF metrics. rInfo := handle.RulesetInfo() @@ -222,7 +237,7 @@ func newGRPCWAFEventListener(handle *waf.Handle, _ []string, timeout time.Durati if md := handlerArgs.Metadata; len(md) > 0 { values[grpcServerRequestMetadata] = md } - event := runWAF(wafCtx, values, timeout) + event, _ := runWAF(wafCtx, values, timeout) // WAF run durations are WAF context bound. As of now we need to keep track of those externally since // we use a new WAF context for each callback. When we are able to re-use the same WAF context across @@ -260,17 +275,17 @@ func newGRPCWAFEventListener(handle *waf.Handle, _ []string, timeout time.Durati }) } -func runWAF(wafCtx *waf.Context, values map[string]interface{}, timeout time.Duration) []byte { - matches, _, err := wafCtx.Run(values, timeout) +func runWAF(wafCtx *waf.Context, values map[string]interface{}, timeout time.Duration) ([]byte, []string) { + matches, actions, err := wafCtx.Run(values, timeout) if err != nil { if err == waf.ErrTimeout { log.Debug("appsec: waf timeout value of %s reached", timeout) } else { log.Error("appsec: unexpected waf error: %v", err) - return nil + return nil, nil } } - return matches + return matches, actions } // HTTP rule addresses currently supported by the WAF