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

internal/appsec: implement WAF actions for http/grpc #1533

Merged
merged 34 commits into from Dec 12, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
2b83f16
internal/appsec: implement IPFromRequest
Hellzy Oct 20, 2022
f224b27
internal/appsec: pass http.clientIP through operation args
Hellzy Oct 20, 2022
c1c1364
internal/appsec: pass htpt.client_ip address to the WAF
Hellzy Oct 20, 2022
566a6c5
internal/appsec: internal/appsec: add support for WAF actions
Hellzy Nov 3, 2022
dea3413
internal/appsec/dyngo: write response header before body in blocking …
Hellzy Nov 16, 2022
e6f2902
internal/appsec/dyngo: clear actions/events before second waf run
Hellzy Nov 16, 2022
b7e1d8a
internal/appsec/httpsec: remove appsec build tag constraint for actions
Hellzy Nov 21, 2022
733a143
Apply suggestions from code review
Hellzy Nov 23, 2022
9f24219
internal/appsec/dyngo: simplify BlockRequestAction struct
Hellzy Nov 23, 2022
36db486
internal/appsec: actionHandler.Apply returns true if blocking
Hellzy Nov 23, 2022
17010e7
internal/appsec: add mutex for Operation actions
Hellzy Nov 23, 2022
aad08f8
internal/appsec: add mutex for action handler
Hellzy Nov 23, 2022
8a48b47
internal/appsec: add RLock call to security events holder
Hellzy Nov 23, 2022
9f6411e
appsec: grpc: remove unary/stream handlers and use status code
Hellzy Nov 23, 2022
4a75723
internal/appsec: go lint
Hellzy Nov 24, 2022
49a7c27
internal/appsec: move applyActions() to a dedicated function
Hellzy Nov 24, 2022
4320cd0
appsec: grpc: retrieve http.client_ip and use action handler
Hellzy Nov 24, 2022
9663fac
internal/appsec: dyngo: use go embed for blocked response default tem…
Hellzy Dec 5, 2022
6c03b89
Merge branch 'main' into francois.mazeau/ip-blocking
Hellzy Dec 6, 2022
11a4cfb
internal/appsec: defer wafCtx.Close()
Hellzy Dec 6, 2022
62e93b0
internal/appsec: add request blocking test case
Hellzy Dec 6, 2022
ca98bff
internal/appsec: golint
Hellzy Dec 6, 2022
e4379ba
appsec: test grpc IP blocking
Hellzy Dec 8, 2022
2286b11
internal/appsec: fix content type when blocking
Hellzy Dec 8, 2022
9272678
grpcsec: report the http.client_ip span tag
Julio-Guerra Dec 8, 2022
c7377af
grpcsec: report the http.client_ip span tag
Julio-Guerra Dec 8, 2022
cf3ac2f
Merge branch 'main' into francois.mazeau/ip-blocking
Julio-Guerra Dec 9, 2022
7f24120
internal/appsec: close waf ctx when blocking
Hellzy Dec 9, 2022
71c6c73
internal/appsec: add actions_test.go
Hellzy Dec 9, 2022
7281ec4
internal/appsec: report private IP if no global IP found
Hellzy Dec 9, 2022
fde2717
appsec: retrieve remote addr for grpc
Hellzy Dec 9, 2022
a266362
github: update codeowners file to better cover appsec files in contrib
Julio-Guerra Dec 11, 2022
3d9d7d4
appsec: fix client ip tags with peer ip addresses
Julio-Guerra Dec 12, 2022
e7a01b3
Merge branch 'main' into francois.mazeau/ip-blocking
Julio-Guerra Dec 12, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
6 changes: 6 additions & 0 deletions contrib/google.golang.org/grpc/appsec.go
Expand Up @@ -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())
Expand All @@ -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())
Expand Down
15 changes: 12 additions & 3 deletions internal/appsec/dyngo/instrumentation/common.go
Expand Up @@ -65,6 +65,13 @@ func (s *SecurityEventsHolder) Events() []json.RawMessage {
return s.events
}

// ClearEvents clears the list of stored events
func (s *SecurityEventsHolder) ClearEvents() {
s.mu.Lock()
defer s.mu.Unlock()
s.events = []json.RawMessage{}
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
}

// SetTags fills the span tags using the key/value pairs found in `tags`
func SetTags(span TagSetter, tags map[string]interface{}) {
for k, v := range tags {
Expand Down Expand Up @@ -102,9 +109,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{}
Expand Down
85 changes: 85 additions & 0 deletions 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
}
4 changes: 4 additions & 0 deletions internal/appsec/dyngo/instrumentation/grpcsec/grpc.go
Expand Up @@ -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
Expand All @@ -39,6 +41,8 @@ type (
dyngo.Operation
instrumentation.TagsHolder
instrumentation.SecurityEventsHolder
UnaryHandler grpc.UnaryHandler
StreamHandler grpc.StreamHandler
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
}
// HandlerOperationArgs is the grpc handler arguments.
HandlerOperationArgs struct {
Expand Down
81 changes: 81 additions & 0 deletions internal/appsec/dyngo/instrumentation/httpsec/actions.go
@@ -0,0 +1,81 @@
// 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.

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
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
// 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())
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
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
Julio-Guerra marked this conversation as resolved.
Show resolved Hide resolved
}

// 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",
})
Hellzy marked this conversation as resolved.
Show resolved Hide resolved

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.WriteHeader(a.Status)
writer.Write(payload)
})
h.actions[id] = a
Julio-Guerra marked this conversation as resolved.
Show resolved Hide resolved
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
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
}
op.actions = append(op.actions, a)
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
}
92 changes: 91 additions & 1 deletion internal/appsec/dyngo/instrumentation/httpsec/http.go
Expand Up @@ -15,6 +15,7 @@ import (
"encoding/json"
"net"
"net/http"
"os"
"reflect"
"strings"

Expand All @@ -38,6 +39,8 @@ type (
Query map[string][]string
// PathParams corresponds to the address `server.request.path_params`
PathParams map[string]string
// ClientIP corresponds to the addres `http.client_ip`
ClientIP netaddrIP
}

// HandlerOperationRes is the HTTP handler operation results.
Expand Down Expand Up @@ -72,25 +75,44 @@ 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) {
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
for _, action := range op.Actions() {
switch a := action.(type) {
case *BlockRequestAction:
handler = a.handler
op.AddTag(tagBlockedRequest, true)
default:
log.Warn("appsec: unsupported action %v. Ignoring.", a)
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
SetIPTags(span, r)

args := MakeHandlerOperationArgs(r, pathParams)
ctx, op := StartOperation(r.Context(), args)
r = r.WithContext(ctx)

events := op.Events()
if len(events) > 0 {
applyActions(op)
op.ClearEvents()
op.ClearActions()
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
}
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})...)
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
instrumentation.SetTags(span, op.Tags())
if len(events) == 0 {
return
}

applyActions(op)
remoteIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
remoteIP = r.RemoteAddr
Expand All @@ -99,6 +121,7 @@ func WrapHandler(handler http.Handler, span ddtrace.Span, pathParams map[string]
}()

handler.ServeHTTP(w, r)

})
}

Expand All @@ -123,6 +146,7 @@ func MakeHandlerOperationArgs(r *http.Request, pathParams map[string]string) Han
Cookies: cookies,
Query: r.URL.Query(), // TODO(Julio-Guerra): avoid actively parsing the query values thanks to dynamic instrumentation
PathParams: pathParams,
ClientIP: IPFromRequest(r),
}
}

Expand All @@ -149,6 +173,7 @@ type (
dyngo.Operation
instrumentation.TagsHolder
instrumentation.SecurityEventsHolder
actions []Action
}

// SDKBodyOperation type representing an SDK body. It must be created with
Expand Down Expand Up @@ -187,6 +212,16 @@ func (op *Operation) Finish(res HandlerOperationRes) []json.RawMessage {
return op.Events()
}

// Actions returns the actions linked to the operation
func (op *Operation) Actions() []Action {
return op.actions
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
}

// ClearActions clears all the actions linked to the operation
func (op *Operation) ClearActions() {
op.actions = []Action{}
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
}

// StartSDKBodyOperation starts the SDKBody operation and emits a start event
func StartSDKBodyOperation(parent *Operation, args SDKBodyOperationArgs) *SDKBodyOperation {
op := &SDKBodyOperation{Operation: dyngo.NewOperation(parent)}
Expand Down Expand Up @@ -261,3 +296,58 @@ func (OnSDKBodyOperationFinish) ListenedType() reflect.Type { return sdkBodyOper
func (f OnSDKBodyOperationFinish) Call(op dyngo.Operation, v interface{}) {
f(op.(*SDKBodyOperation), v.(SDKBodyOperationRes))
}

// IPFromRequest returns the resolved client IP for a specific request. The returned IP can be invalid.
func IPFromRequest(r *http.Request) netaddrIP {
ipHeaders := defaultIPHeaders
if len(clientIPHeader) > 0 {
ipHeaders = []string{clientIPHeader}
}
var headers []string
var ips []string
for _, hdr := range ipHeaders {
if v := r.Header.Get(hdr); v != "" {
headers = append(headers, hdr)
ips = append(ips, v)
}
}
if len(ips) == 0 {
if remoteIP := parseIP(r.RemoteAddr); remoteIP.IsValid() && isGlobal(remoteIP) {
return remoteIP
}
} else if len(ips) == 1 {
for _, ipstr := range strings.Split(ips[0], ",") {
ip := parseIP(strings.TrimSpace(ipstr))
if ip.IsValid() && isGlobal(ip) {
return ip
}
}
}

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(`<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width,initial-scale=1"> <title>You've been blocked</title> <style>a, body, div, html, span{margin: 0; padding: 0; border: 0; font-size: 100%; font: inherit; vertical-align: baseline}body{background: -webkit-radial-gradient(26% 19%, circle, #fff, #f4f7f9); background: radial-gradient(circle at 26% 19%, #fff, #f4f7f9); display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; -webkit-box-align: center; -ms-flex-align: center; align-items: center; -ms-flex-line-pack: center; align-content: center; width: 100%; min-height: 100vh; line-height: 1; flex-direction: column}p{display: block}main{text-align: center; flex: 1; display: -webkit-box; display: -ms-flexbox; display: flex; -webkit-box-pack: center; -ms-flex-pack: center; justify-content: center; -webkit-box-align: center; -ms-flex-align: center; align-items: center; -ms-flex-line-pack: center; align-content: center; flex-direction: column}p{font-size: 18px; line-height: normal; color: #646464; font-family: sans-serif; font-weight: 400}a{color: #4842b7}footer{width: 100%; text-align: center}footer p{font-size: 16px}</style></head><body> <main> <p>Sorry, you cannot access this page. Please contact the customer service team.</p></main> <footer> <p>Security provided by <a href="https://www.datadoghq.com/product/security-platform/application-security-monitoring/" target="_blank">Datadog</a></p></footer></body></html>`)
Julio-Guerra marked this conversation as resolved.
Show resolved Hide resolved
Julio-Guerra marked this conversation as resolved.
Show resolved Hide resolved
)

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
}
}

}
}