Skip to content

Commit

Permalink
Add blocklist functionality
Browse files Browse the repository at this point in the history
This adds blocklist functionality, while preserving the allowlist.
To make this more useful, we also introduce a default policy:  if an IP
matches neither allow- or block-list, it will be allowed or not based on
the default policy.

For CIDR-based lists, higher specificity CIDRs should always take
priority to lower-specificity ones.  In the event of a tie, the block
list should always take priority over the allow list.
  • Loading branch information
Ulexus committed Oct 2, 2023
1 parent e235493 commit 299aec6
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 31 deletions.
7 changes: 5 additions & 2 deletions .traefik.yml
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
displayName: geoblock
type: middleware
import: github.com/nscuro/traefik-plugin-geoblock
summary: traefik plugin to whitelist requests based on geolocation
summary: traefik plugin to block or allow requests based on geolocation
testData:
# It doesn't appear to be possible to get the pilot plugin analyzer
# to load local files. To prevent errors, the plugin is disabled here.
# This will cause the plugin to not attempt to load the database file.
enabled: false
# databaseFilePath: IP2LOCATION-LITE-DB1.IPV6.BIN
# allowedCountries: [ "CH", "DE" ]
# blockedCountries: [ "RU" ]
# defaultAllow: false
# allowPrivate: true
# disallowedStatusCode: 403
# allowedIPBlocks: ["66.249.64.0/19"]
# allowedIPBlocks: ["66.249.64.0/19"]
# blockedIPBlocks: ["66.249.64.0/24"]
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
[![Latest GitHub release](https://img.shields.io/github/v/release/nscuro/traefik-plugin-geoblock?sort=semver)](https://github.com/nscuro/traefik-plugin-geoblock/releases/latest)
[![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](LICENSE)

*traefik-plugin-geoblock is a traefik plugin to whitelist requests based on geolocation*
*traefik-plugin-geoblock is a traefik plugin to allow or block requests based on geolocation*

> This projects includes IP2Location LITE data available from [`lite.ip2location.com`](https://lite.ip2location.com/database/ip-country).
Expand Down Expand Up @@ -49,10 +49,16 @@ http:
databaseFilePath: /plugins-local/src/github.com/nscuro/traefik-plugin-geoblock/IP2LOCATION-LITE-DB1.IPV6.BIN
# Whitelist of countries to allow (ISO 3166-1 alpha-2)
allowedCountries: [ "AT", "CH", "DE" ]
# Blocklist of countries to block (ISO 3166-1 alpha-2)
blockedCountries: [ "RU" ]
# Default allow indicates that if an IP is in neither block list nor allow lists, it should be allowed.
defaultAllow: false
# Allow requests from private / internal networks?
allowPrivate: true
# HTTP status code to return for disallowed requests (default: 403)
disallowedStatusCode: 204
# Add CIDR to be whitelisted, even if in a non-allowed country
allowedIPBlocks: ["66.249.64.0/19"]
```
# Add CIDR to be blacklisted, even if in an allowed country or IP block
blockedIPBlocks: ["66.249.64.5/32"]
```
134 changes: 108 additions & 26 deletions plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ type Config struct {
Enabled bool // Enable this plugin?
DatabaseFilePath string // Path to ip2location database file
AllowedCountries []string // Whitelist of countries to allow (ISO 3166-1 alpha-2)
BlockedCountries []string // Blocklist of countries to be blocked (ISO 3166-1 alpha-2)
DefaultAllow bool // If source matches neither blocklist nor whitelist, should it be allowed through?
AllowPrivate bool // Allow requests from private / internal networks?
DisallowedStatusCode int // HTTP status code to return for disallowed requests
AllowedIPBlocks []string // List of whitelist CIDR
BlockedIPBlocks []string // List of blocklisted CIDRs
}

// CreateConfig creates the default plugin configuration.
Expand All @@ -36,16 +39,20 @@ type Plugin struct {
db *ip2location.DB
enabled bool
allowedCountries []string
blockedCountries []string
defaultAllow bool
allowPrivate bool
disallowedStatusCode int
allowedIPBlocks []*net.IPNet
blockedIPBlocks []*net.IPNet
}

// New creates a new plugin instance.
func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.Handler, error) {
if next == nil {
return nil, fmt.Errorf("%s: no next handler provided", name)
}

if cfg == nil {
return nil, fmt.Errorf("%s: no config provided", name)
}
Expand Down Expand Up @@ -73,7 +80,12 @@ func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.H
return nil, fmt.Errorf("%s: failed to open database: %w", name, err)
}

allowedIPBlocks, err := initAllowedIPBlocks(cfg.AllowedIPBlocks)
allowedIPBlocks, err := initIPBlocks(cfg.AllowedIPBlocks)
if err != nil {
return nil, fmt.Errorf("%s: failed loading allowed CIDR blocks: %w", name, err)
}

blockedIPBlocks, err := initIPBlocks(cfg.BlockedIPBlocks)
if err != nil {
return nil, fmt.Errorf("%s: failed loading allowed CIDR blocks: %w", name, err)
}
Expand All @@ -84,9 +96,12 @@ func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.H
db: db,
enabled: cfg.Enabled,
allowedCountries: cfg.AllowedCountries,
blockedCountries: cfg.BlockedCountries,
defaultAllow: cfg.DefaultAllow,
allowPrivate: cfg.AllowPrivate,
disallowedStatusCode: cfg.DisallowedStatusCode,
allowedIPBlocks: allowedIPBlocks,
blockedIPBlocks: blockedIPBlocks,
}, nil
}

Expand Down Expand Up @@ -146,37 +161,92 @@ func (p Plugin) GetRemoteIPs(req *http.Request) []string {
}

// CheckAllowed checks whether a given IP address is allowed according to the configured allowed countries.
func (p Plugin) CheckAllowed(ip string) (bool, string, error) {
country, err := p.Lookup(ip)
func (p Plugin) CheckAllowed(ip string) (allow bool, country string, err error) {
var allowedCountry, allowedIP, blockedCountry, blockedIP bool
var allowedNetworkLength, blockedNetworkLength int

country, err = p.Lookup(ip)
if err != nil {
return false, "", fmt.Errorf("lookup of %s failed: %w", ip, err)
return false, ip, fmt.Errorf("lookup of %s failed: %w", ip, err)
}

if country == "-" { // Private address
if p.allowPrivate {
return true, ip, nil
if country == "-" {
return p.allowPrivate, country, nil
}

if country != "-" {
for _, item := range p.blockedCountries {
if item == country {
blockedCountry = true

break
}
}

return false, ip, nil
for _, item := range p.allowedCountries {
if item == country {
allowedCountry = true
}
}
}

blocked, blockedNetworkLength, err := p.isBlockedIPBlocks(ip)
if err != nil {
return false, ip, fmt.Errorf("failed to check if IP %q is blocked by IP block: %w", ip, err)
}

if blocked {
blockedIP = true
}

var allowed bool
for _, allowedCountry := range p.allowedCountries {
if allowedCountry == country {
return true, country, nil
return true, ip, nil
}
}

allowed, err = p.isAllowedIPBlocks(ip)
allowed, allowedNetBits, err := p.isAllowedIPBlocks(ip)
if err != nil {
return false, "", fmt.Errorf("checking if %s is part of an allowed range failed: %w", ip, err)
return false, ip, fmt.Errorf("failed to check if IP %q is allowed by IP block: %w", ip, err)
}

if !allowed {
if allowed {
allowedIP = true
allowedNetworkLength = allowedNetBits
}

// Handle final values
//
// NB: discrete IPs have higher priority than countries: more specific to less specific.

// NB: whichever matched prefix is longer has higher priority: more specific to less specific.
if allowedNetworkLength < blockedNetworkLength {
if blockedIP {
return false, country, nil
}

if allowedIP {
return true, country, nil
}
} else {
if allowedIP {
return true, country, nil
}

if blockedIP {
return false, country, nil
}
}

if allowedCountry {
return true, country, nil
}

if blockedCountry {
return false, country, nil
}

return true, country, nil
return p.defaultAllow, country, nil
}

// Lookup queries the ip2location database for a given IP address.
Expand All @@ -195,34 +265,46 @@ func (p Plugin) Lookup(ip string) (string, error) {
}

// Create IP Networks using CIDR block array
func initAllowedIPBlocks(allowedIPBlocks []string) ([]*net.IPNet, error) {
func initIPBlocks(ipBlocks []string) ([]*net.IPNet, error) {

var allowedIPBlocksNet []*net.IPNet
var ipBlocksNet []*net.IPNet

for _, cidr := range allowedIPBlocks {
for _, cidr := range ipBlocks {
_, block, err := net.ParseCIDR(cidr)
if err != nil {
return nil, fmt.Errorf("parse error on %q: %v", cidr, err)
}
allowedIPBlocksNet = append(allowedIPBlocksNet, block)
ipBlocksNet = append(ipBlocksNet, block)
}

return allowedIPBlocksNet, nil
return ipBlocksNet, nil
}

// isAllowedIPBlocks check if an IP is allowed base on the allowed CIDR blocks
func (p Plugin) isAllowedIPBlocks(ip string) (bool, error) {
var ipAddress net.IP = net.ParseIP(ip)
// isAllowedIPBlocks checks if an IP is allowed base on the allowed CIDR blocks
func (p Plugin) isAllowedIPBlocks(ip string) (bool, int, error) {
return p.isInIPBlocks(ip, p.allowedIPBlocks)
}

// isBlockedIPBlocks checks if an IP is allowed base on the blocked CIDR blocks
func (p Plugin) isBlockedIPBlocks(ip string) (bool, int, error) {
return p.isInIPBlocks(ip, p.blockedIPBlocks)
}

// isInIPBlocks indicates whether the given IP exists in any of the IP subnets contained within ipBlocks.
func (p Plugin) isInIPBlocks(ip string, ipBlocks []*net.IPNet) (bool, int, error) {
ipAddress := net.ParseIP(ip)

if ipAddress == nil {
return false, fmt.Errorf("unable parse IP address from address [%s]", ip)
return false, 0, fmt.Errorf("unable parse IP address from address [%s]", ip)
}

for _, block := range p.allowedIPBlocks {
for _, block := range ipBlocks {
if block.Contains(ipAddress) {
return true, nil
ones, _ := block.Mask.Size()

return true, ones, nil
}
}

return false, nil
return false, 0, nil
}
50 changes: 49 additions & 1 deletion plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func TestNew(t *testing.T) {
}
})

t.Run("NoConfig", func(t *testing.T) {
t.Run("Nogeoblock.Config", func(t *testing.T) {
plugin, err := New(context.TODO(), &noopHandler{}, nil, pluginName)
if err == nil {
t.Errorf("expected error, but got none")
Expand Down Expand Up @@ -174,6 +174,54 @@ func TestPlugin_ServeHTTP(t *testing.T) {
t.Errorf("expected status code %d, but got: %d", http.StatusForbidden, rr.Code)
}
})

t.Run("Blocklist", func(t *testing.T) {
cfg := &Config{
Enabled: true,
DatabaseFilePath: dbFilePath,
BlockedCountries: []string{"US"},
AllowPrivate: false,
DefaultAllow: true,
DisallowedStatusCode: http.StatusForbidden,
}

testRequest(t, "US IP blocked", cfg, "8.8.8.8", http.StatusForbidden)
testRequest(t, "DE IP allowed", cfg, "185.5.82.105", 0)

cfg.BlockedCountries = nil
cfg.BlockedIPBlocks = []string{"8.8.8.0/24"}

testRequest(t, "Google DNS-A blocked", cfg, "8.8.8.8", http.StatusForbidden)
testRequest(t, "Google DNS-B allowed", cfg, "8.8.4.4", 0)

cfg.AllowedIPBlocks = []string{"8.8.8.7/32"}

testRequest(t, "Higher specificity IP CIDR allow trumps lower specificity IP CIDR block", cfg, "8.8.8.7", 0)
testRequest(t, "Higher specificity IP CIDR allow should not override encompassing CIDR block", cfg, "8.8.8.9", http.StatusForbidden)

cfg.DefaultAllow = false

testRequest(t, "Default allow false", cfg, "8.8.4.4", http.StatusForbidden)
})
}

func testRequest(t *testing.T, testName string, cfg *Config, ip string, expectedStatus int) {
t.Run(testName, func(t *testing.T) {
plugin, err := New(context.TODO(), &noopHandler{}, cfg, pluginName)
if err != nil {
t.Errorf("expected no error, but got: %v", err)
}

req := httptest.NewRequest(http.MethodGet, "/foobar", nil)
req.Header.Set("X-Real-IP", ip)

rr := httptest.NewRecorder()
plugin.ServeHTTP(rr, req)

if expectedStatus > 0 && rr.Code != expectedStatus {
t.Errorf("expected status code %d, but got: %d", expectedStatus, rr.Code)
}
})
}

func TestPlugin_Lookup(t *testing.T) {
Expand Down

0 comments on commit 299aec6

Please sign in to comment.