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

appsec: support for SSRF Exploit Prevention #2622

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion contrib/net/http/roundtripper.go
Expand Up @@ -7,6 +7,8 @@ package http

import (
"fmt"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/emitter/httpsec"
"math"
"net/http"
"os"
Expand Down Expand Up @@ -75,7 +77,16 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (res *http.Response, err er
fmt.Fprintf(os.Stderr, "contrib/net/http.Roundtrip: failed to inject http headers: %v\n", err)
}
}
res, err = rt.base.RoundTrip(r2)
if appsec.Enabled() {
span.SetTag("_dd.appsec.rasp", "1")
res, err = httpsec.RoundTrip(httpsec.RoundTripArgs{
Ctx: ctx,
Req: r2,
Rt: rt.base,
})
} else {
res, err = rt.base.RoundTrip(r2)
}
if err != nil {
span.SetTag("http.errors", err.Error())
if rt.cfg.errCheck == nil || rt.cfg.errCheck(err) {
Expand Down
55 changes: 55 additions & 0 deletions contrib/net/http/roundtripper_test.go
Expand Up @@ -8,6 +8,8 @@ package http
import (
"encoding/base64"
"fmt"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/listener/sharedsec"
"net/http"
"net/http/httptest"
"net/url"
Expand Down Expand Up @@ -619,3 +621,56 @@ func TestClientNamingSchema(t *testing.T) {
t.Run("ServiceName", namingschematest.NewServiceNameTest(genSpans, wantServiceNameV0))
t.Run("SpanName", namingschematest.NewSpanNameTest(genSpans, assertOpV0, assertOpV1))
}

type emptyRoundTripper struct{}

func (rt *emptyRoundTripper) RoundTrip(_ *http.Request) (*http.Response, error) {
recorder := httptest.NewRecorder()
recorder.WriteHeader(200)
return recorder.Result(), nil
}

func TestAppsec(t *testing.T) {
mt := mocktracer.Start()
defer mt.Stop()

t.Setenv("DD_APPSEC_RULES", "../../../internal/appsec/testdata/rasp.json")

appsec.Start()
if !appsec.Enabled() {
t.Skip("appsec not enabled")
}

client := WrapRoundTripper(&emptyRoundTripper{})

t.Run("event", func(t *testing.T) {
w := httptest.NewRecorder()
r, err := http.NewRequest("GET", "?value=169.254.169.254", nil)
require.NoError(t, err)

TraceAndServe(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
req, err := http.NewRequest("GET", "http://169.254.169.254", nil)
require.NoError(t, err)

resp, err := client.RoundTrip(req.WithContext(r.Context()))
defer require.NoError(t, err)
defer resp.Body.Close()
}), w, r, &ServeConfig{
Service: "service",
Resource: "resource",
})

spans := mt.FinishedSpans()
require.Len(t, spans, 2) // service entry serviceSpan & http request serviceSpan
serviceSpan := spans[1]

require.Contains(t, serviceSpan.Tags(), "_dd.appsec.json")

appsecJSON := serviceSpan.Tag("_dd.appsec.json")
require.Contains(t, appsecJSON, sharedsec.ServerIoNetURLAddr)

// This is a nested event so it should contain the child span id in the service entry span
// TODO(eliott.bouhana): uncomment this once we have the child span id in the service entry span
// require.Contains(t, appsecJSON, `"span_id":`+strconv.FormatUint(requestSpan.SpanID(), 10))
})
}
2 changes: 1 addition & 1 deletion go.mod
Expand Up @@ -9,7 +9,7 @@ require (
github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0
github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.1
github.com/DataDog/datadog-go/v5 v5.3.0
github.com/DataDog/go-libddwaf/v2 v2.3.2
github.com/DataDog/go-libddwaf/v2 v2.4.1-0.20240315162818-edc4361e741b
github.com/DataDog/gostackparse v0.7.0
github.com/DataDog/sketches-go v1.4.2
github.com/IBM/sarama v1.40.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Expand Up @@ -633,8 +633,8 @@ github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.1/go.mod h1:Vc+snp
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/DataDog/datadog-go/v5 v5.3.0 h1:2q2qjFOb3RwAZNU+ez27ZVDwErJv5/VpbBPprz7Z+s8=
github.com/DataDog/datadog-go/v5 v5.3.0/go.mod h1:XRDJk1pTc00gm+ZDiBKsjh7oOOtJfYfglVCmFb8C2+Q=
github.com/DataDog/go-libddwaf/v2 v2.3.2 h1:pdi9xjWW57IpOpTeOyPuNveEDFLmmInsHDeuZk3TY34=
github.com/DataDog/go-libddwaf/v2 v2.3.2/go.mod h1:gsCdoijYQfj8ce/T2bEDNPZFIYnmHluAgVDpuQOWMZE=
github.com/DataDog/go-libddwaf/v2 v2.4.1-0.20240315162818-edc4361e741b h1:DfmL0C5s4ayo+86fTt1zWAzTLG7QoRUofUcswk91dMQ=
github.com/DataDog/go-libddwaf/v2 v2.4.1-0.20240315162818-edc4361e741b/go.mod h1:gsCdoijYQfj8ce/T2bEDNPZFIYnmHluAgVDpuQOWMZE=
github.com/DataDog/go-tuf v1.0.2-0.5.2 h1:EeZr937eKAWPxJ26IykAdWA4A0jQXJgkhUjqEI/w7+I=
github.com/DataDog/go-tuf v1.0.2-0.5.2/go.mod h1:zBcq6f654iVqmkk8n2Cx81E1JnNTMOAx1UEO/wZR+P0=
github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4=
Expand Down
50 changes: 50 additions & 0 deletions internal/appsec/emitter/httpsec/roundtripper.go
@@ -0,0 +1,50 @@
// 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 2016 Datadog, Inc.

package httpsec

import (
"context"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/dyngo"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/emitter/httpsec/types"
"gopkg.in/DataDog/dd-trace-go.v1/internal/log"
"net/http"
)

type RoundTripArgs struct {
Ctx context.Context
Req *http.Request
Rt http.RoundTripper
}

func RoundTrip(args RoundTripArgs) (*http.Response, error) {
url := args.Req.URL.String()
opArgs := types.RoundTripOperationArgs{
URL: url,
}

parent := fromContext(args.Ctx)
if parent == nil { // No parent operation => we can't monitor the request
log.Error("appsec: outgoing http request monitoring ignored: could not find the http handler instrumentation metadata in the request context: the request handler is not being monitored by a middleware function or the provided context has not be forwarded correctly")
return args.Rt.RoundTrip(args.Req)
}

op := &types.RoundTripOperation{Operation: dyngo.NewOperation(parent)}

var err error
dyngo.OnData(op, func(e error) {
err = e
})

dyngo.StartOperation(op, opArgs)
dyngo.FinishOperation(op, types.RoundTripOperationRes{})

if err != nil {
log.Error("appsec: outgoing http request blocked by the WAF on URL: %s", url)
return nil, err
}

return args.Rt.RoundTrip(args.Req)
}
13 changes: 13 additions & 0 deletions internal/appsec/emitter/httpsec/types/types.go
Expand Up @@ -27,6 +27,10 @@ type (
SDKBodyOperation struct {
dyngo.Operation
}

RoundTripOperation struct {
dyngo.Operation
}
)

// Finish the HTTP handler operation, along with the given results and emits a
Expand Down Expand Up @@ -72,6 +76,12 @@ type (
// SDKBodyOperationRes is the SDK body operation results.
SDKBodyOperationRes struct{}

RoundTripOperationArgs struct {
URL string
}

RoundTripOperationRes struct{}

// MonitoringError is used to vehicle an HTTP error, usually resurfaced through Appsec SDKs.
MonitoringError struct {
msg string
Expand Down Expand Up @@ -101,3 +111,6 @@ func (SDKBodyOperationRes) IsResultOf(*SDKBodyOperation) {}

func (HandlerOperationArgs) IsArgOf(*Operation) {}
func (HandlerOperationRes) IsResultOf(*Operation) {}

func (RoundTripOperationArgs) IsArgOf(*RoundTripOperation) {}
func (RoundTripOperationRes) IsResultOf(*RoundTripOperation) {}
5 changes: 5 additions & 0 deletions internal/appsec/listener/graphqlsec/graphql.go
Expand Up @@ -30,6 +30,7 @@ const (
// List of GraphQL rule addresses currently supported by the WAF
var supportedAddresses = listener.AddressSet{
graphQLServerResolverAddr: {},
shared.ServerIoNetURLAddr: {},
}

// Install registers the GraphQL WAF Event Listener on the given root operation.
Expand Down Expand Up @@ -78,6 +79,10 @@ func (l *wafEventListener) onEvent(request *types.RequestOperation, _ types.Requ
return
}

if _, ok := l.addresses[shared.ServerIoNetURLAddr]; ok {
shared.RegisterRoundTripper(request, wafCtx, l.limiter, l.config.WAFTimeout)
}

// Add span tags notifying this trace is AppSec-enabled
trace.SetAppSecEnabledTags(request)
l.once.Do(func() {
Expand Down
20 changes: 11 additions & 9 deletions internal/appsec/listener/grpcsec/grpc.go
Expand Up @@ -19,7 +19,6 @@ import (
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/emitter/grpcsec/types"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/emitter/sharedsec"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/listener"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/listener/httpsec"
shared "gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/listener/sharedsec"
"gopkg.in/DataDog/dd-trace-go.v1/internal/log"
"gopkg.in/DataDog/dd-trace-go.v1/internal/samplernames"
Expand All @@ -30,17 +29,16 @@ const (
GRPCServerMethodAddr = "grpc.server.method"
GRPCServerRequestMessageAddr = "grpc.server.request.message"
GRPCServerRequestMetadataAddr = "grpc.server.request.metadata"
HTTPClientIPAddr = httpsec.HTTPClientIPAddr
UserIDAddr = httpsec.UserIDAddr
)

// List of gRPC rule addresses currently supported by the WAF
var supportedAddresses = listener.AddressSet{
GRPCServerMethodAddr: {},
GRPCServerRequestMessageAddr: {},
GRPCServerRequestMetadataAddr: {},
HTTPClientIPAddr: {},
UserIDAddr: {},
shared.HTTPClientIPAddr: {},
shared.UserIDAddr: {},
shared.ServerIoNetURLAddr: {},
}

// Install registers the gRPC WAF Event Listener on the given root operation.
Expand Down Expand Up @@ -103,14 +101,18 @@ func (l *wafEventListener) onEvent(op *types.HandlerOperation, handlerArgs types
return
}

if _, ok := l.addresses[shared.ServerIoNetURLAddr]; ok {
shared.RegisterRoundTripper(op, wafCtx, l.limiter, l.config.WAFTimeout)
}

// Listen to the UserID address if the WAF rules are using it
if l.isSecAddressListened(UserIDAddr) {
if l.isSecAddressListened(shared.UserIDAddr) {
// UserIDOperation happens when appsec.SetUser() is called. We run the WAF and apply actions to
// see if the associated user should be blocked. Since we don't control the execution flow in this case
// (SetUser is SDK), we delegate the responsibility of interrupting the handler to the user.
dyngo.On(op, func(userIDOp *sharedsec.UserIDOperation, args sharedsec.UserIDOperationArgs) {
values := map[string]any{
UserIDAddr: args.UserID,
shared.UserIDAddr: args.UserID,
}
wafResult := shared.RunWAF(wafCtx, waf.RunAddressData{Persistent: values}, l.config.WAFTimeout)
if wafResult.HasActions() || wafResult.HasEvents() {
Expand All @@ -131,8 +133,8 @@ func (l *wafEventListener) onEvent(op *types.HandlerOperation, handlerArgs types
// Note that this address is passed asap for the passlist, which are created per grpc method
values[GRPCServerMethodAddr] = handlerArgs.Method
}
if l.isSecAddressListened(HTTPClientIPAddr) && handlerArgs.ClientIP.IsValid() {
values[HTTPClientIPAddr] = handlerArgs.ClientIP.String()
if l.isSecAddressListened(shared.HTTPClientIPAddr) && handlerArgs.ClientIP.IsValid() {
values[shared.HTTPClientIPAddr] = handlerArgs.ClientIP.String()
}

wafResult := shared.RunWAF(wafCtx, waf.RunAddressData{Persistent: values}, l.config.WAFTimeout)
Expand Down
19 changes: 11 additions & 8 deletions internal/appsec/listener/httpsec/http.go
Expand Up @@ -34,8 +34,6 @@ const (
ServerRequestBodyAddr = "server.request.body"
ServerResponseStatusAddr = "server.response.status"
ServerResponseHeadersNoCookiesAddr = "server.response.headers.no_cookies"
HTTPClientIPAddr = "http.client_ip"
UserIDAddr = "usr.id"
)

// List of HTTP rule addresses currently supported by the WAF
Expand All @@ -49,8 +47,9 @@ var supportedAddresses = listener.AddressSet{
ServerRequestBodyAddr: {},
ServerResponseStatusAddr: {},
ServerResponseHeadersNoCookiesAddr: {},
HTTPClientIPAddr: {},
UserIDAddr: {},
shared.HTTPClientIPAddr: {},
shared.UserIDAddr: {},
shared.ServerIoNetURLAddr: {},
}

// Install registers the HTTP WAF Event Listener on the given root operation.
Expand Down Expand Up @@ -101,12 +100,16 @@ func (l *wafEventListener) onEvent(op *types.Operation, args types.HandlerOperat
return
}

if _, ok := l.addresses[UserIDAddr]; ok {
if _, ok := l.addresses[shared.ServerIoNetURLAddr]; ok {
shared.RegisterRoundTripper(op, wafCtx, l.limiter, l.config.WAFTimeout)
}

if _, ok := l.addresses[shared.UserIDAddr]; ok {
// OnUserIDOperationStart happens when appsec.SetUser() is called. We run the WAF and apply actions to
// see if the associated user should be blocked. Since we don't control the execution flow in this case
// (SetUser is SDK), we delegate the responsibility of interrupting the handler to the user.
dyngo.On(op, func(operation *sharedsec.UserIDOperation, args sharedsec.UserIDOperationArgs) {
wafResult := shared.RunWAF(wafCtx, waf.RunAddressData{Persistent: map[string]any{UserIDAddr: args.UserID}}, l.config.WAFTimeout)
wafResult := shared.RunWAF(wafCtx, waf.RunAddressData{Persistent: map[string]any{shared.UserIDAddr: args.UserID}}, l.config.WAFTimeout)
if wafResult.HasActions() || wafResult.HasEvents() {
processHTTPSDKAction(operation, l.actions, wafResult.Actions)
shared.AddSecurityEvents(op, l.limiter, wafResult.Events)
Expand All @@ -118,9 +121,9 @@ func (l *wafEventListener) onEvent(op *types.Operation, args types.HandlerOperat
values := make(map[string]any, 8)
for addr := range l.addresses {
switch addr {
case HTTPClientIPAddr:
case shared.HTTPClientIPAddr:
if args.ClientIP.IsValid() {
values[HTTPClientIPAddr] = args.ClientIP.String()
values[shared.HTTPClientIPAddr] = args.ClientIP.String()
}
case ServerRequestMethodAddr:
values[ServerRequestMethodAddr] = args.Method
Expand Down
30 changes: 29 additions & 1 deletion internal/appsec/listener/sharedsec/shared.go
Expand Up @@ -7,6 +7,8 @@ package sharedsec

import (
"encoding/json"
"github.com/DataDog/go-libddwaf/v2/errors"
"gopkg.in/DataDog/dd-trace-go.v1/internal/appsec/emitter/httpsec/types"
"time"

"github.com/DataDog/appsec-internal-go/limiter"
Expand All @@ -17,9 +19,15 @@ import (
"gopkg.in/DataDog/dd-trace-go.v1/internal/log"
)

const (
ServerIoNetURLAddr = "server.io.net.url"
HTTPClientIPAddr = "http.client_ip"
UserIDAddr = "usr.id"
)

func RunWAF(wafCtx *waf.Context, values waf.RunAddressData, timeout time.Duration) waf.Result {
result, err := wafCtx.Run(values, timeout)
if err == waf.ErrTimeout {
if err == errors.ErrTimeout {
log.Debug("appsec: waf timeout value of %s reached", timeout)
} else if err != nil {
log.Error("appsec: unexpected waf error: %v", err)
Expand All @@ -31,6 +39,11 @@ type securityEventsAdder interface {
AddSecurityEvents(events []any)
}

type operationWithEvents interface {
dyngo.Operation
securityEventsAdder
}

// Helper function to add sec events to an operation taking into account the rate limiter.
func AddSecurityEvents(op securityEventsAdder, limiter limiter.Limiter, matches []any) {
if len(matches) > 0 && limiter.Allow() {
Expand Down Expand Up @@ -89,3 +102,18 @@ func ProcessActions(op dyngo.Operation, actions sharedsec.Actions, actionIds []s
}
return interrupt
}

// RegisterRoundTripper registers a listener on outgoing requests to run the WAF.
func RegisterRoundTripper(op operationWithEvents, wafCtx *waf.Context, limiter limiter.Limiter, timeout time.Duration) {
dyngo.On(op, func(operation *types.RoundTripOperation, args types.RoundTripOperationArgs) {
wafResult := RunWAF(wafCtx, waf.RunAddressData{Ephemeral: map[string]any{ServerIoNetURLAddr: args.URL}}, timeout)

// TODO: stacktrace
if !wafResult.HasEvents() {
return
}

AddSecurityEvents(op, limiter, wafResult.Events)
log.Debug("appsec: WAF detected a suspicious outgoing request URL: %s", args.URL)
})
}