Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ It prevents server crashes in case of panic in one of the controllers.

OnlyFrom middleware allows access from a limited list of source IPs.
Such IPs can be defined as complete ip (like 192.168.1.12), prefix (129.168.) or CIDR (192.168.0.0/16).
Complete IP rules use semantic address equality (so equivalent IPv6 spellings match), CIDRs use network containment,
and all other rules use literal textual prefix matching.
The middleware will respond with `StatusForbidden` (403) if the request comes from a different IP.
It supports both IPv4 and IPv6 and checks the usual headers like `X-Forwarded-For` and `X-Real-IP` and the remote address.

Expand Down Expand Up @@ -453,4 +455,3 @@ Profiler is a convenient sub-router used for mounting net/http/pprof, i.e.
```

It exposes a bunch of `/pprof/*` endpoints as well as `/vars`. Builtin support for `onlyIps` allows restricting access, which is important if it runs on a publicly exposed port. However, counting on IP check only is not that reliable way to limit request and for production use it would be better to add some sort of auth (for example provided `BasicAuth` middleware) or run with a separate http server, exposed to internal ip/port only.

15 changes: 12 additions & 3 deletions onlyfrom.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import (
)

// OnlyFrom middleware allows access for limited list of source IPs.
// Such IPs can be defined as complete ip (like 192.168.1.12), prefix (129.168.) or CIDR (192.168.0.0/16)
// Rules can be complete IPs (like 192.168.1.12), textual prefixes (129.168.), or CIDRs (192.168.0.0/16).
// Complete IPs use semantic address equality, CIDRs use network containment, and all other rules use prefix matching.
func OnlyFrom(onlyIps ...string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -42,12 +43,20 @@ func matchSourceIP(r *http.Request, ips []string) (result bool, match string, er
if err != nil {
return false, "", fmt.Errorf("can't get realip: %w", err) // we can't get ip, so no match
}
// check for ip prefix or CIDR
parsedIP := net.ParseIP(ip)
// check for cidr, complete ip, or ip prefix
for _, exclIP := range ips {
if _, cidrnet, err := net.ParseCIDR(exclIP); err == nil {
if cidrnet.Contains(net.ParseIP(ip)) {
if cidrnet.Contains(parsedIP) {
return true, ip, nil
}
continue
}
if allowedIP := net.ParseIP(exclIP); allowedIP != nil {
if allowedIP.Equal(parsedIP) {
return true, ip, nil
}
continue
}
if strings.HasPrefix(ip, exclIP) {
return true, ip, nil
Expand Down
36 changes: 36 additions & 0 deletions onlyfrom_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,42 @@ func TestOnlyFromAllowedCIDR(t *testing.T) {
assert.Equal(t, 403, resp.StatusCode)
}

func TestMatchSourceIPRules(t *testing.T) {
tests := []struct {
name string
rules []string
source string
matched bool
}{
{name: "complete ipv4", rules: []string{"1.2.3.4"}, source: "1.2.3.4", matched: true},
{name: "complete ipv4 rejects textual prefix", rules: []string{"1.2.3.4"}, source: "1.2.3.45", matched: false},
{name: "ipv4 prefix", rules: []string{"1.2.3."}, source: "1.2.3.45", matched: true},
{name: "ipv4 cidr", rules: []string{"1.2.3.0/24"}, source: "1.2.3.45", matched: true},
{name: "complete ipv6 normalized", rules: []string{"2001:db8:0:0::1"}, source: "2001:db8::1", matched: true},
{
name: "complete ipv6 rejects textual prefix", rules: []string{"2001:db8::1"},
source: "2001:db8::10", matched: false,
},
{name: "ipv6 prefix", rules: []string{"2001:db8:"}, source: "2001:db8::10", matched: true},
{name: "ipv6 prefix rejects mismatch", rules: []string{"2001:db9:"}, source: "2001:db8::10", matched: false},
{name: "ipv6 cidr", rules: []string{"2001:db8::/32"}, source: "2001:db8::10", matched: true},
{name: "ipv6 cidr rejects mismatch", rules: []string{"2001:db9::/32"}, source: "2001:db8::10", matched: false},
{name: "later rule matches", rules: []string{"1.2.3.4", "5.6."}, source: "5.6.7.8", matched: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", http.NoBody)
req.Header.Set("X-Real-IP", tt.source)

matched, source, err := matchSourceIP(req, tt.rules)
require.NoError(t, err)
assert.Equal(t, tt.matched, matched)
assert.Equal(t, tt.source, source)
})
}
}

func TestOnlyFromRejected(t *testing.T) {
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte("blah blah"))
Expand Down
Loading