Skip to content

Commit

Permalink
feat: rename ClientIPStrategy to ClientIPResolver
Browse files Browse the repository at this point in the history
  • Loading branch information
tigerwill90 committed Nov 28, 2024
1 parent 2836f84 commit d178222
Show file tree
Hide file tree
Showing 12 changed files with 190 additions and 203 deletions.
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -525,22 +525,22 @@ f := fox.New(
````

## Client IP Derivation
The `WithClientIPStrategy` option allows you to set up strategies to resolve the client IP address based on your
The `WithClientIPResolver` option allows you to set up strategies to resolve the client IP address based on your
use case and network topology. Accurately determining the client IP is hard, particularly in environments with proxies or
load balancers. For example, the leftmost IP in the `X-Forwarded-For` header is commonly used and is often regarded as the
"closest to the client" and "most real," but it can be easily spoofed. Therefore, you should absolutely avoid using it
for any security-related purposes, such as request throttling.

The strategy used must be chosen and tuned for your network configuration. This should result in the strategy never returning
The resolver used must be chosen and tuned for your network configuration. This should result in a resolver never returning
an error and if it does, it should be treated as an application issue or a misconfiguration, rather than defaulting to an
untrustworthy IP.

The sub-package `github.com/tigerwill90/fox/clientip` provides a set of best practices strategies that should cover most use cases.
The sub-package `github.com/tigerwill90/fox/clientip` provides a set of best practices resolvers that should cover most use cases.

````go
f := fox.New(
fox.DefaultOptions(),
fox.WithClientIPStrategy(
fox.WithClientIPResolver(
// We are behind one or many trusted proxies that have all private-space IP addresses.
clientip.NewRightmostNonPrivate(clientip.XForwardedForKey),
),
Expand All @@ -549,7 +549,7 @@ f := fox.New(
f.MustHandle(http.MethodGet, "/foo/bar", func(c fox.Context) {
ipAddr, err := c.ClientIP()
if err != nil {
// If the current strategy is not able to derive the client IP, an error
// If the current resolver is not able to derive the client IP, an error
// will be returned rather than falling back on an untrustworthy IP. It
// should be treated as an application issue or a misconfiguration.
panic(err)
Expand All @@ -558,12 +558,12 @@ f.MustHandle(http.MethodGet, "/foo/bar", func(c fox.Context) {
})
````

It is also possible to create a chain with multiple strategies that attempt to derive the client IP, stopping when the first one succeeds.
It is also possible to create a chain with multiple resolvers that attempt to derive the client IP, stopping when the first one succeeds.

````go
f = fox.New(
fox.DefaultOptions(),
fox.WithClientIPStrategy(
fox.WithClientIPResolver(
// A common use for this is if a server is both directly connected to the
// internet and expecting a header to check.
clientip.NewChain(
Expand All @@ -574,7 +574,8 @@ f = fox.New(
)
````

Note that there is no "sane" default strategy, so calling `Context.ClientIP` without a strategy configured will return an `ErrNoClientIPStrategy`.
Note that there is no "sane" default strategy, so calling `Context.ClientIP` without a resolver configured will return
an `ErrNoClientIPResolver`.

See this [blog post](https://adam-p.ca/blog/2022/03/x-forwarded-for/) for general guidance on choosing a strategy that fit your needs.
## Benchmark
Expand Down
143 changes: 74 additions & 69 deletions clientip/clientip.go

Large diffs are not rendered by default.

112 changes: 47 additions & 65 deletions clientip/clientip_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
"testing"
)

func TestRemoteAddrStrategy_ClientIP(t *testing.T) {
func TestRemoteAddr_ClientIP(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
req.Header.Add("X-Forwarded-For", "1.1.1.1, 2001:db8:cafe::99%eth0, 3.3.3.3, 192.168.1.1")
w := httptest.NewRecorder()
Expand Down Expand Up @@ -76,15 +76,15 @@ func TestRemoteAddrStrategy_ClientIP(t *testing.T) {

}

func TestSingleIPHeaderStrategy_ClientIP(t *testing.T) {
func TestSingleIPHeader_ClientIP(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
req.Header.Add("X-Real-IP", "4.4.4.4")
req.Header.Add("X-Real-IP", "5.5.5.5")
w := httptest.NewRecorder()

c := fox.NewTestContextOnly(w, req)

s := NewSingleIPHeader("X-Real-IP")
s := Must(NewSingleIPHeader("X-Real-IP"))
ipAddr, err := s.ClientIP(c)
require.NoError(t, err)
assert.Equal(t, "5.5.5.5", ipAddr.String())
Expand All @@ -94,14 +94,14 @@ func TestSingleIPHeaderStrategy_ClientIP(t *testing.T) {
assert.ErrorIs(t, err, ErrSingleIPHeader)
}

func TestLeftmostNonPrivateStrategy_ClientIP(t *testing.T) {
func TestLeftmostNonPrivate_ClientIP(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
req.Header.Add("Forwarded", `For=fe80::abcd;By=fe80::1234, Proto=https;For=::ffff:188.0.2.128, For="[2001:db8:cafe::17]:4848", For=fc00::1`)
w := httptest.NewRecorder()

c := fox.NewTestContextOnly(w, req)

s := NewLeftmostNonPrivate(ForwardedKey, 100, ExcludeLoopback(true), ExcludeLinkLocal(true), ExcludePrivateNet(true))
s := Must(NewLeftmostNonPrivate(ForwardedKey, 100, ExcludeLoopback(true), ExcludeLinkLocal(true), ExcludePrivateNet(true)))
assert.ElementsMatch(t, privateAndLocalRanges, s.blacklistedRanges)
ipAddr, err := s.ClientIP(c)
require.NoError(t, err)
Expand All @@ -113,13 +113,13 @@ func TestLeftmostNonPrivateStrategy_ClientIP(t *testing.T) {
assert.ErrorIs(t, err, ErrLeftmostNonPrivate)
}

func TestRightmostNonPrivateStrategy_ClientIP(t *testing.T) {
func TestRightmostNonPrivate_ClientIP(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
req.Header.Add("X-Forwarded-For", "1.1.1.1, 2001:db8:cafe::99%eth0, 3.3.3.3, 192.168.1.1")
w := httptest.NewRecorder()

c := fox.NewTestContextOnly(w, req)
s := NewRightmostNonPrivate(XForwardedForKey, TrustLoopback(true), TrustLinkLocal(true), TrustPrivateNet(true))
s := Must(NewRightmostNonPrivate(XForwardedForKey, TrustLoopback(true), TrustLinkLocal(true), TrustPrivateNet(true)))
assert.ElementsMatch(t, privateAndLocalRanges, s.trustedRanges)
ipAddr, err := s.ClientIP(c)
require.NoError(t, err)
Expand All @@ -137,13 +137,13 @@ func TestRightmostNonPrivateStrategy_ClientIP(t *testing.T) {
assert.ErrorIs(t, err, ErrRightmostNonPrivate)
}

func TestRightmostTrustedCountStrategy_ClientIP(t *testing.T) {
func TestRightmostTrustedCount_ClientIP(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
req.Header.Add("Forwarded", `For=fe80::abcd;By=fe80::1234, Proto=https;For=::ffff:188.0.2.128, For="[2001:db8:cafe::17]:4848", For=fc00::1`)
w := httptest.NewRecorder()

c := fox.NewTestContextOnly(w, req)
s := NewRightmostTrustedCount(ForwardedKey, 2)
s := Must(NewRightmostTrustedCount(ForwardedKey, 2))
ipAddr, err := s.ClientIP(c)
require.NoError(t, err)
assert.Equal(t, "2001:db8:cafe::17", ipAddr.String())
Expand All @@ -159,16 +159,16 @@ func TestRightmostTrustedCountStrategy_ClientIP(t *testing.T) {
assert.ErrorContains(t, err, "invalid IP address from the first trusted proxy")
}

func TestRightmostTrustedRangeStrategy_ClientIP(t *testing.T) {
func TestRightmostTrustedRange_ClientIP(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
req.Header.Add("X-Forwarded-For", "1.1.1.1, 2001:db8:cafe::99%eth0, 3.3.3.3, 192.168.1.1")
w := httptest.NewRecorder()

c := fox.NewTestContextOnly(w, req)
trustedRanges, _ := AddressesAndRangesToIPNets([]string{"192.168.0.0/16", "3.3.3.3"}...)
s := NewRightmostTrustedRange(XForwardedForKey, IPRangeResolverFunc(func() ([]net.IPNet, error) {
s := Must(NewRightmostTrustedRange(XForwardedForKey, IPRangeResolverFunc(func() ([]net.IPNet, error) {
return trustedRanges, nil
}))
})))
ipAddr, err := s.ClientIP(c)
require.NoError(t, err)
assert.Equal(t, "2001:db8:cafe::99%eth0", ipAddr.String())
Expand All @@ -193,19 +193,19 @@ func TestRightmostTrustedRangeStrategy_ClientIP(t *testing.T) {
assert.ErrorIs(t, err, resolverErr)

assert.Panics(t, func() {
s = NewRightmostTrustedRange(XForwardedForKey, nil)
s = Must(NewRightmostTrustedRange(XForwardedForKey, nil))
})
}

func TestChainStrategy_ClientIP(t *testing.T) {
func TestChain_ClientIP(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
req.Header.Add("X-Real-IP", "4.4.4.4")
req.RemoteAddr = "192.0.2.1:8080"
w := httptest.NewRecorder()

c := fox.NewTestContextOnly(w, req)
s := NewChain(
NewSingleIPHeader("Cf-Connecting-IP"),
Must(NewSingleIPHeader("Cf-Connecting-IP")),
NewRemoteAddr(),
)
ipAddr, err := s.ClientIP(c)
Expand Down Expand Up @@ -727,9 +727,8 @@ func Test_forwardedHeaderRFCDeviations(t *testing.T) {
}
}

func TestRemoteAddrStrategy(t *testing.T) {
// Ensure the strategy interface is implemented
var _ fox.ClientIPStrategy = RemoteAddr{}
func TestRemoteAddr(t *testing.T) {
var _ fox.ClientIPResolver = RemoteAddr{}

type args struct {
headers http.Header
Expand Down Expand Up @@ -895,9 +894,8 @@ func TestRemoteAddrStrategy(t *testing.T) {
}
}

func TestSingleIPHeaderStrategy(t *testing.T) {
// Ensure the strategy interface is implemented
var _ fox.ClientIPStrategy = SingleIPHeader{}
func TestSingleIPHeader(t *testing.T) {
var _ fox.ClientIPResolver = SingleIPHeader{}

type args struct {
headerName string
Expand Down Expand Up @@ -1092,15 +1090,15 @@ func TestSingleIPHeaderStrategy(t *testing.T) {
c := fox.NewTestContextOnly(w, req)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s fox.ClientIPStrategy
var s fox.ClientIPResolver
if tt.wantErr {
require.Panics(t, func() {
s = NewSingleIPHeader(tt.args.headerName)
s = Must(NewSingleIPHeader(tt.args.headerName))
})
return
}

s = NewSingleIPHeader(tt.args.headerName)
s = Must(NewSingleIPHeader(tt.args.headerName))

c.Request().Header = tt.args.headers
c.Request().RemoteAddr = tt.args.remoteAddr
Expand All @@ -1114,9 +1112,8 @@ func TestSingleIPHeaderStrategy(t *testing.T) {
}
}

func TestLeftmostNonPrivateStrategy(t *testing.T) {
// Ensure the strategy interface is implemented
var _ fox.ClientIPStrategy = LeftmostNonPrivate{}
func TestLeftmostNonPrivate(t *testing.T) {
var _ fox.ClientIPResolver = LeftmostNonPrivate{}

type args struct {
headerType HeaderKey
Expand Down Expand Up @@ -1335,15 +1332,15 @@ func TestLeftmostNonPrivateStrategy(t *testing.T) {
c := fox.NewTestContextOnly(w, req)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s fox.ClientIPStrategy
var s fox.ClientIPResolver
if tt.wantErr {
require.Panics(t, func() {
s = NewLeftmostNonPrivate(tt.args.headerType, 100)
s = Must(NewLeftmostNonPrivate(tt.args.headerType, 100))
})
return
}

s = NewLeftmostNonPrivate(tt.args.headerType, 100)
s = Must(NewLeftmostNonPrivate(tt.args.headerType, 100))

c.Request().Header = tt.args.headers
c.Request().RemoteAddr = tt.args.remoteAddr
Expand All @@ -1359,7 +1356,7 @@ func TestLeftmostNonPrivateStrategy(t *testing.T) {

func TestLeftmostNonPrivateLimit(t *testing.T) {
t.Run("limit exactly match the target ip", func(t *testing.T) {
s := NewLeftmostNonPrivate(XForwardedForKey, 3)
s := Must(NewLeftmostNonPrivate(XForwardedForKey, 3))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set(fox.HeaderXForwardedFor, "192.168.1.15, 10.8.1.2, 115.45.98.3")
w := httptest.NewRecorder()
Expand All @@ -1369,7 +1366,7 @@ func TestLeftmostNonPrivateLimit(t *testing.T) {
assert.Equal(t, "115.45.98.3", ip.String())
})
t.Run("limit under the target ip", func(t *testing.T) {
s := NewLeftmostNonPrivate(XForwardedForKey, 2)
s := Must(NewLeftmostNonPrivate(XForwardedForKey, 2))
req := httptest.NewRequest(http.MethodGet, "/foo", nil)
req.Header.Set(fox.HeaderXForwardedFor, "192.168.1.15, 10.8.1.2, 115.45.98.3")
w := httptest.NewRecorder()
Expand All @@ -1379,9 +1376,8 @@ func TestLeftmostNonPrivateLimit(t *testing.T) {
})
}

func TestRightmostNonPrivateStrategy(t *testing.T) {
// Ensure the strategy interface is implemented
var _ fox.ClientIPStrategy = RightmostNonPrivate{}
func TestRightmostNonPrivate(t *testing.T) {
var _ fox.ClientIPResolver = RightmostNonPrivate{}

type args struct {
headerType HeaderKey
Expand Down Expand Up @@ -1601,15 +1597,15 @@ func TestRightmostNonPrivateStrategy(t *testing.T) {
c := fox.NewTestContextOnly(w, req)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s fox.ClientIPStrategy
var s fox.ClientIPResolver
if tt.wantErr {
require.Panics(t, func() {
s = NewRightmostNonPrivate(tt.args.headerType)
s = Must(NewRightmostNonPrivate(tt.args.headerType))
})
return
}

s = NewRightmostNonPrivate(tt.args.headerType)
s = Must(NewRightmostNonPrivate(tt.args.headerType))

c.Request().Header = tt.args.headers
c.Request().RemoteAddr = tt.args.remoteAddr
Expand All @@ -1623,13 +1619,12 @@ func TestRightmostNonPrivateStrategy(t *testing.T) {
}
}

func TestRightmostTrustedCountStrategy(t *testing.T) {
// Ensure the strategy interface is implemented
var _ fox.ClientIPStrategy = RightmostTrustedCount{}
func TestRightmostTrustedCount(t *testing.T) {
var _ fox.ClientIPResolver = RightmostTrustedCount{}

type args struct {
headerType HeaderKey
trustedCount int
trustedCount uint
headers http.Header
remoteAddr string
}
Expand Down Expand Up @@ -1741,34 +1736,22 @@ func TestRightmostTrustedCountStrategy(t *testing.T) {
},
wantErr: true,
},
{
name: "Error: negative trustedCount",
args: args{
headerType: XForwardedForKey,
trustedCount: -999,
headers: http.Header{
"X-Real-Ip": []string{`1.1.1.1`},
"X-Forwarded-For": []string{`2.2.2.2:3384, 3.3.3.3`, `4.4.4.4:39333`},
},
},
wantErr: true,
},
}

req := httptest.NewRequest(http.MethodGet, "https://example.com", nil)
w := httptest.NewRecorder()
c := fox.NewTestContextOnly(w, req)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s fox.ClientIPStrategy
var s fox.ClientIPResolver
if tt.wantErr {
require.Panics(t, func() {
s = NewRightmostTrustedCount(tt.args.headerType, tt.args.trustedCount)
s = Must(NewRightmostTrustedCount(tt.args.headerType, tt.args.trustedCount))
})
return
}

s = NewRightmostTrustedCount(tt.args.headerType, tt.args.trustedCount)
s = Must(NewRightmostTrustedCount(tt.args.headerType, tt.args.trustedCount))

c.Request().Header = tt.args.headers
c.Request().RemoteAddr = tt.args.remoteAddr
Expand All @@ -1782,9 +1765,8 @@ func TestRightmostTrustedCountStrategy(t *testing.T) {
}
}

func TestRightmostTrustedRangeStrategy(t *testing.T) {
// Ensure the strategy interface is implemented
var _ fox.ClientIPStrategy = RightmostTrustedRange{}
func TestRightmostTrustedRange(t *testing.T) {
var _ fox.ClientIPResolver = RightmostTrustedRange{}

type args struct {
headerType HeaderKey
Expand Down Expand Up @@ -1962,19 +1944,19 @@ func TestRightmostTrustedRangeStrategy(t *testing.T) {
t.Fatalf("AddressesAndRangesToIPNets failed")
}

var s fox.ClientIPStrategy
var s fox.ClientIPResolver
if tt.wantErr {
require.Panics(t, func() {
s = NewRightmostTrustedRange(tt.args.headerType, IPRangeResolverFunc(func() ([]net.IPNet, error) {
s = Must(NewRightmostTrustedRange(tt.args.headerType, IPRangeResolverFunc(func() ([]net.IPNet, error) {
return ranges, nil
}))
})))
})
return
}

s = NewRightmostTrustedRange(tt.args.headerType, IPRangeResolverFunc(func() ([]net.IPNet, error) {
s = Must(NewRightmostTrustedRange(tt.args.headerType, IPRangeResolverFunc(func() ([]net.IPNet, error) {
return ranges, nil
}))
})))

c.Request().Header = tt.args.headers
c.Request().RemoteAddr = tt.args.remoteAddr
Expand Down
Loading

0 comments on commit d178222

Please sign in to comment.