Skip to content

Commit

Permalink
Merge pull request #70 from Ulexus/add-blacklist
Browse files Browse the repository at this point in the history
Add blocklist functionality
  • Loading branch information
nscuro committed Jan 13, 2024
2 parents 772d3ef + 299aec6 commit 069f3ec
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 069f3ec

Please sign in to comment.