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

contrib/internal/httptrace: store client_ip in tags for backend WAF #1328

Merged
merged 14 commits into from Jun 15, 2022
Merged
Show file tree
Hide file tree
Changes from 8 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
102 changes: 102 additions & 0 deletions contrib/internal/httptrace/httptrace.go
Expand Up @@ -12,6 +12,9 @@ import (
"fmt"
"net/http"
"strconv"
"strings"

"inet.af/netaddr"

"gopkg.in/DataDog/dd-trace-go.v1/ddtrace"
"gopkg.in/DataDog/dd-trace-go.v1/ddtrace/ext"
Expand All @@ -34,6 +37,9 @@ func StartRequestSpan(r *http.Request, opts ...ddtrace.StartSpanOption) (tracer.
tracer.Tag("http.host", r.Host),
}, opts...)
}
if ip := getClientIP(r.RemoteAddr, r.Header, cfg.ipHeader); ip.IsValid() {
opts = append(opts, tracer.Tag(ext.HTTPClientIP, ip.String()))
}
if spanctx, err := tracer.Extract(tracer.HTTPHeadersCarrier(r.Header)); err == nil {
opts = append(opts, tracer.ChildOf(spanctx))
}
Expand All @@ -55,3 +61,99 @@ func FinishRequestSpan(s tracer.Span, status int, opts ...tracer.FinishOption) {
}
s.Finish(opts...)
}

// Helper function to return the IP network out of a string.
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
func ippref(s string) *netaddr.IPPrefix {
if prefix, err := netaddr.ParseIPPrefix(s); err == nil {
return &prefix
}
return nil
}

var (
ipv6SpecialNetworks = []*netaddr.IPPrefix{
ippref("fec0::/10"), // site local
}
defaultIPHeaders = []string{
"x-forwarded-for",
"x-real-ip",
"x-client-ip",
"x-forwarded",
"x-cluster-client-ip",
"forwarded-for",
"forwarded",
"via",
"true-client-ip",
}
)

// getClientIP uses the request headers to resolve the client IP. If a specific header to check is provided through
// DD_CLIENT_IP_HEADER, then only this header is checked.
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
func getClientIP(remoteAddr string, headers http.Header, clientIPHeader string) netaddr.IP {
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
ipHeaders := defaultIPHeaders
if len(clientIPHeader) > 0 {
ipHeaders = []string{clientIPHeader}
}
check := func(value string) netaddr.IP {
for _, ipstr := range strings.Split(value, ",") {
ip := parseIP(strings.TrimSpace(ipstr))
if !ip.IsValid() {
continue
}
if isGlobal(ip) {
return ip
}
}
return netaddr.IP{}
}
for _, hdr := range ipHeaders {
if value := headers.Get(hdr); value != "" {
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
if ip := check(value); ip.IsValid() {
return ip
}
}
}

Hellzy marked this conversation as resolved.
Show resolved Hide resolved
if remoteIP := parseIP(remoteAddr); remoteIP.IsValid() && isGlobal(remoteIP) {
return remoteIP
}
return netaddr.IP{}
}

func parseIP(s string) netaddr.IP {
ip, err := netaddr.ParseIP(s)
gbbr marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
h, _ := splitHostPort(s)
ip, err = netaddr.ParseIP(h)
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
}
return ip
}

func isGlobal(ip netaddr.IP) bool {
if ip.Is6() {
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
for _, network := range ipv6SpecialNetworks {
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
if network.Contains(ip) {
return false
}
}
}
//IsPrivate also checks for ipv6 ULA
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
return !ip.IsPrivate() && !ip.IsLoopback() && !ip.IsLinkLocalUnicast()
}

// SplitHostPort splits a network address of the form `host:port` or
// `[host]:port` into `host` and `port`.
func splitHostPort(addr string) (host string, port string) {
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
i := strings.LastIndex(addr, "]:")
if i != -1 {
// ipv6
return strings.Trim(addr[:i+1], "[]"), addr[i+2:]
}

i = strings.LastIndex(addr, ":")
if i == -1 {
// not an address with a port number
return addr, ""
}
return addr[:i], addr[i+1:]
}
198 changes: 198 additions & 0 deletions contrib/internal/httptrace/httptrace_test.go
Expand Up @@ -6,10 +6,13 @@
package httptrace

import (
"math/rand"
"net/http"
"net/http/httptest"
"testing"

"inet.af/netaddr"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

Expand All @@ -27,3 +30,198 @@ func TestStartRequestSpan(t *testing.T) {
require.Len(t, spans, 1)
assert.Equal(t, "example.com", spans[0].Tag("http.host"))
}

type IPTestCase struct {
name string
remoteAddr string
headers map[string]string
expectedIP netaddr.IP
userIPHeader string
}

func genIPTestCases() []IPTestCase {
ipv4Global := randGlobalIPv4().String()
ipv6Global := randGlobalIPv6().String()
ipv4Private := randPrivateIPv4().String()
ipv6Private := randPrivateIPv6().String()
tcs := []IPTestCase{}
// Simple ipv4 test cases over all headers
for _, header := range defaultIPHeaders {
tcs = append(tcs, IPTestCase{
name: "ipv4-global." + header,
headers: map[string]string{header: ipv4Global},
expectedIP: netaddr.MustParseIP(ipv4Global),
})
tcs = append(tcs, IPTestCase{
name: "ipv4-private." + header,
headers: map[string]string{header: ipv4Private},
expectedIP: netaddr.IP{},
})
}
// Simple ipv6 test cases over all headers
for _, header := range defaultIPHeaders {
tcs = append(tcs, IPTestCase{
name: "ipv6-global." + header,
headers: map[string]string{header: ipv6Global},
expectedIP: netaddr.MustParseIP(ipv6Global),
})
tcs = append(tcs, IPTestCase{
name: "ipv6-private." + header,
headers: map[string]string{header: ipv6Private},
expectedIP: netaddr.IP{},
})
}
// private and global in same header
tcs = append([]IPTestCase{
{
name: "ipv4-private+global",
headers: map[string]string{"x-forwarded-for": ipv4Private + "," + ipv4Global},
expectedIP: netaddr.MustParseIP(ipv4Global),
},
{
name: "ipv4-global+private",
headers: map[string]string{"x-forwarded-for": ipv4Global + "," + ipv4Private},
expectedIP: netaddr.MustParseIP(ipv4Global),
},
{
name: "ipv6-private+global",
headers: map[string]string{"x-forwarded-for": ipv6Private + "," + ipv6Global},
expectedIP: netaddr.MustParseIP(ipv6Global),
},
{
name: "ipv6-global+private",
headers: map[string]string{"x-forwarded-for": ipv6Global + "," + ipv6Private},
expectedIP: netaddr.MustParseIP(ipv6Global),
},
}, tcs...)
// Invalid IPs (or a mix of valid/invalid over a single or multiple headers)
tcs = append([]IPTestCase{
{
name: "invalid-ipv4",
headers: map[string]string{"x-forwarded-for": "127..0.0.1"},
expectedIP: netaddr.IP{},
},
{
name: "invalid-ipv4-recover",
headers: map[string]string{"x-forwarded-for": "127..0.0.1, " + ipv4Global},
expectedIP: netaddr.MustParseIP(ipv4Global),
},
{
name: "invalid-ipv4-recover-multi-header-1",
headers: map[string]string{"x-forwarded-for": "127..0.0.1", "forwarded-for": ipv4Global},
expectedIP: netaddr.MustParseIP(ipv4Global),
},
{
name: "invalid-ipv4-recover-multi-header-2",
headers: map[string]string{"forwarded-for": ipv4Global, "x-forwarded-for": "127..0.0.1"},
expectedIP: netaddr.MustParseIP(ipv4Global),
},
{
name: "invalid-ipv6",
headers: map[string]string{"x-forwarded-for": "2001:0db8:2001:zzzz::"},
expectedIP: netaddr.IP{},
},
{
name: "invalid-ipv6-recover",
headers: map[string]string{"x-forwarded-for": "2001:0db8:2001:zzzz::, " + ipv6Global},
expectedIP: netaddr.MustParseIP(ipv6Global),
},
{
name: "invalid-ipv6-recover-multi-header-1",
headers: map[string]string{"x-forwarded-for": "2001:0db8:2001:zzzz::", "forwarded-for": ipv6Global},
expectedIP: netaddr.MustParseIP(ipv6Global),
},
{
name: "invalid-ipv6-recover-multi-header-2",
headers: map[string]string{"forwarded-for": ipv6Global, "x-forwarded-for": "2001:0db8:2001:zzzz::"},
expectedIP: netaddr.MustParseIP(ipv6Global),
},
}, tcs...)
tcs = append([]IPTestCase{
{
name: "no-headers",
expectedIP: netaddr.IP{},
},
{
name: "header-case",
expectedIP: netaddr.MustParseIP(ipv4Global),
headers: map[string]string{"X-fOrWaRdEd-FoR": ipv4Global},
},
{
name: "user-header",
expectedIP: netaddr.MustParseIP(ipv4Global),
headers: map[string]string{"x-forwarded-for": ipv6Global, "custom-header": ipv4Global},
userIPHeader: "custom-header",
},
{
name: "user-header-not-found",
expectedIP: netaddr.IP{},
headers: map[string]string{"x-forwarded-for": ipv4Global},
userIPHeader: "custom-header",
},
}, tcs...)

return tcs
}

func TestIPHeaders(t *testing.T) {
for _, tc := range genIPTestCases() {
t.Run(tc.name, func(t *testing.T) {
header := http.Header{}
for k, v := range tc.headers {
header.Add(k, v)
}
require.Equal(t, tc.expectedIP.String(), getClientIP(tc.remoteAddr, header, tc.userIPHeader).String())
})
}

}

func randIPv4() netaddr.IP {
return netaddr.IPv4(uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()))
}

func randIPv6() netaddr.IP {
return netaddr.IPv6Raw([16]byte{
uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()),
uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()),
uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()),
uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()), uint8(rand.Uint32()),
})
}

func randGlobalIPv4() netaddr.IP {
for {
ip := randIPv4()
if isGlobal(ip) {
return ip
}
}
}

func randGlobalIPv6() netaddr.IP {
for {
ip := randIPv6()
if isGlobal(ip) {
return ip
}
}
}

func randPrivateIPv4() netaddr.IP {
for {
ip := randIPv4()
if !isGlobal(ip) && ip.IsPrivate() {
return ip
}
}
}

func randPrivateIPv6() netaddr.IP {
for {
ip := randIPv6()
if !isGlobal(ip) && ip.IsPrivate() {
return ip
}
}
}
23 changes: 23 additions & 0 deletions contrib/internal/httptrace/options.go
@@ -0,0 +1,23 @@
// 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 httptrace

import "os"

type config struct {
ipHeader string
}

var (
clientIPHeaderEnvVar = "DD_CLIENT_IP_HEADER"
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
cfg = newConfig()
)

func newConfig() *config {
Hellzy marked this conversation as resolved.
Show resolved Hide resolved
return &config{
ipHeader: os.Getenv(clientIPHeaderEnvVar),
}
}