diff --git a/internal/appsec/dyngo/instrumentation/common.go b/internal/appsec/dyngo/instrumentation/common.go index 9d0d66736e..f462577b4f 100644 --- a/internal/appsec/dyngo/instrumentation/common.go +++ b/internal/appsec/dyngo/instrumentation/common.go @@ -102,9 +102,11 @@ func SetEventSpanTags(span TagSetter, events []json.RawMessage) error { // Create the value of the security event tag. // TODO(Julio-Guerra): a future libddwaf version should return something -// avoiding us the following events concatenation logic which currently -// involves unserializing the top-level JSON arrays to concatenate them -// together. +// +// avoiding us the following events concatenation logic which currently +// involves unserializing the top-level JSON arrays to concatenate them +// together. +// // TODO(Julio-Guerra): avoid serializing the json in the request hot path func makeEventTagValue(events []json.RawMessage) (json.RawMessage, error) { var v interface{} diff --git a/internal/appsec/dyngo/instrumentation/httpsec/actions.go b/internal/appsec/dyngo/instrumentation/httpsec/actions.go new file mode 100644 index 0000000000..51bf157d21 --- /dev/null +++ b/internal/appsec/dyngo/instrumentation/httpsec/actions.go @@ -0,0 +1,91 @@ +// 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" +) + +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 *Operation) +} + +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: 403, + Template: "html", + }, + } + // 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 *Operation) { + 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 { + payload := blockedPayload(&p) + op.handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Write(payload) + writer.WriteHeader(p.Status) + }) + } +} + +// ActionParam is used to identify an action parameters data type +type ActionParam interface{} + +// 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 int + // Template is the payload template to use to write the response (html or json) + Template string +} + +func blockedPayload(params *BlockRequestParams) []byte { + payload := BlockedTemplateJSON + if params.Template == "html" { + payload = BlockedTemplateHTML + } + return payload +} diff --git a/internal/appsec/dyngo/instrumentation/httpsec/http.go b/internal/appsec/dyngo/instrumentation/httpsec/http.go index 3809522ebd..dcd885cf8c 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" @@ -81,6 +82,10 @@ 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) + if op.handler != nil { + op.handler.ServeHTTP(w, r) + return + } defer func() { var status int if mw, ok := w.(interface{ Status() int }); ok { @@ -99,7 +104,6 @@ func WrapHandler(handler http.Handler, span ddtrace.Span, pathParams map[string] } SetSecurityEventTags(span, events, remoteIP, args.Headers, w.Header()) }() - handler.ServeHTTP(w, r) }) } @@ -152,6 +156,8 @@ type ( dyngo.Operation instrumentation.TagsHolder instrumentation.SecurityEventsHolder + // handler is the alternate HTTP handler that must be called for a given operation, if not nil + handler http.Handler } // SDKBodyOperation type representing an SDK body. It must be created with @@ -294,3 +300,26 @@ func IPFromRequest(r *http.Request) netaddrIP { return netaddrIP{} } + +var ( + 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 = []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..4b47117749 100644 --- a/internal/appsec/waf.go +++ b/internal/appsec/waf.go @@ -95,9 +95,29 @@ 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) + if len(matches) > 0 { + for _, id := range actionIds { + actionHandler.Exec(id, op) + } + } op.On(httpsec.OnSDKBodyOperationStart(func(op *httpsec.SDKBodyOperation, args httpsec.SDKBodyOperationArgs) { body = args.Body @@ -106,13 +126,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 +155,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 +231,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 +269,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