Skip to content

Commit 069f3ec

Browse files
authored
Merge pull request #70 from Ulexus/add-blacklist
Add blocklist functionality
2 parents 772d3ef + 299aec6 commit 069f3ec

File tree

4 files changed

+170
-31
lines changed

4 files changed

+170
-31
lines changed

.traefik.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
displayName: geoblock
22
type: middleware
33
import: github.com/nscuro/traefik-plugin-geoblock
4-
summary: traefik plugin to whitelist requests based on geolocation
4+
summary: traefik plugin to block or allow requests based on geolocation
55
testData:
66
# It doesn't appear to be possible to get the pilot plugin analyzer
77
# to load local files. To prevent errors, the plugin is disabled here.
88
# This will cause the plugin to not attempt to load the database file.
99
enabled: false
1010
# databaseFilePath: IP2LOCATION-LITE-DB1.IPV6.BIN
1111
# allowedCountries: [ "CH", "DE" ]
12+
# blockedCountries: [ "RU" ]
13+
# defaultAllow: false
1214
# allowPrivate: true
1315
# disallowedStatusCode: 403
14-
# allowedIPBlocks: ["66.249.64.0/19"]
16+
# allowedIPBlocks: ["66.249.64.0/19"]
17+
# blockedIPBlocks: ["66.249.64.0/24"]

README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![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)
66
[![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](LICENSE)
77

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

1010
> This projects includes IP2Location LITE data available from [`lite.ip2location.com`](https://lite.ip2location.com/database/ip-country).
1111
@@ -49,10 +49,16 @@ http:
4949
databaseFilePath: /plugins-local/src/github.com/nscuro/traefik-plugin-geoblock/IP2LOCATION-LITE-DB1.IPV6.BIN
5050
# Whitelist of countries to allow (ISO 3166-1 alpha-2)
5151
allowedCountries: [ "AT", "CH", "DE" ]
52+
# Blocklist of countries to block (ISO 3166-1 alpha-2)
53+
blockedCountries: [ "RU" ]
54+
# Default allow indicates that if an IP is in neither block list nor allow lists, it should be allowed.
55+
defaultAllow: false
5256
# Allow requests from private / internal networks?
5357
allowPrivate: true
5458
# HTTP status code to return for disallowed requests (default: 403)
5559
disallowedStatusCode: 204
5660
# Add CIDR to be whitelisted, even if in a non-allowed country
5761
allowedIPBlocks: ["66.249.64.0/19"]
58-
```
62+
# Add CIDR to be blacklisted, even if in an allowed country or IP block
63+
blockedIPBlocks: ["66.249.64.5/32"]
64+
```

plugin.go

Lines changed: 108 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ type Config struct {
2020
Enabled bool // Enable this plugin?
2121
DatabaseFilePath string // Path to ip2location database file
2222
AllowedCountries []string // Whitelist of countries to allow (ISO 3166-1 alpha-2)
23+
BlockedCountries []string // Blocklist of countries to be blocked (ISO 3166-1 alpha-2)
24+
DefaultAllow bool // If source matches neither blocklist nor whitelist, should it be allowed through?
2325
AllowPrivate bool // Allow requests from private / internal networks?
2426
DisallowedStatusCode int // HTTP status code to return for disallowed requests
2527
AllowedIPBlocks []string // List of whitelist CIDR
28+
BlockedIPBlocks []string // List of blocklisted CIDRs
2629
}
2730

2831
// CreateConfig creates the default plugin configuration.
@@ -36,16 +39,20 @@ type Plugin struct {
3639
db *ip2location.DB
3740
enabled bool
3841
allowedCountries []string
42+
blockedCountries []string
43+
defaultAllow bool
3944
allowPrivate bool
4045
disallowedStatusCode int
4146
allowedIPBlocks []*net.IPNet
47+
blockedIPBlocks []*net.IPNet
4248
}
4349

4450
// New creates a new plugin instance.
4551
func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.Handler, error) {
4652
if next == nil {
4753
return nil, fmt.Errorf("%s: no next handler provided", name)
4854
}
55+
4956
if cfg == nil {
5057
return nil, fmt.Errorf("%s: no config provided", name)
5158
}
@@ -73,7 +80,12 @@ func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.H
7380
return nil, fmt.Errorf("%s: failed to open database: %w", name, err)
7481
}
7582

76-
allowedIPBlocks, err := initAllowedIPBlocks(cfg.AllowedIPBlocks)
83+
allowedIPBlocks, err := initIPBlocks(cfg.AllowedIPBlocks)
84+
if err != nil {
85+
return nil, fmt.Errorf("%s: failed loading allowed CIDR blocks: %w", name, err)
86+
}
87+
88+
blockedIPBlocks, err := initIPBlocks(cfg.BlockedIPBlocks)
7789
if err != nil {
7890
return nil, fmt.Errorf("%s: failed loading allowed CIDR blocks: %w", name, err)
7991
}
@@ -84,9 +96,12 @@ func New(_ context.Context, next http.Handler, cfg *Config, name string) (http.H
8496
db: db,
8597
enabled: cfg.Enabled,
8698
allowedCountries: cfg.AllowedCountries,
99+
blockedCountries: cfg.BlockedCountries,
100+
defaultAllow: cfg.DefaultAllow,
87101
allowPrivate: cfg.AllowPrivate,
88102
disallowedStatusCode: cfg.DisallowedStatusCode,
89103
allowedIPBlocks: allowedIPBlocks,
104+
blockedIPBlocks: blockedIPBlocks,
90105
}, nil
91106
}
92107

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

148163
// CheckAllowed checks whether a given IP address is allowed according to the configured allowed countries.
149-
func (p Plugin) CheckAllowed(ip string) (bool, string, error) {
150-
country, err := p.Lookup(ip)
164+
func (p Plugin) CheckAllowed(ip string) (allow bool, country string, err error) {
165+
var allowedCountry, allowedIP, blockedCountry, blockedIP bool
166+
var allowedNetworkLength, blockedNetworkLength int
167+
168+
country, err = p.Lookup(ip)
151169
if err != nil {
152-
return false, "", fmt.Errorf("lookup of %s failed: %w", ip, err)
170+
return false, ip, fmt.Errorf("lookup of %s failed: %w", ip, err)
153171
}
154172

155-
if country == "-" { // Private address
156-
if p.allowPrivate {
157-
return true, ip, nil
173+
if country == "-" {
174+
return p.allowPrivate, country, nil
175+
}
176+
177+
if country != "-" {
178+
for _, item := range p.blockedCountries {
179+
if item == country {
180+
blockedCountry = true
181+
182+
break
183+
}
158184
}
159185

160-
return false, ip, nil
186+
for _, item := range p.allowedCountries {
187+
if item == country {
188+
allowedCountry = true
189+
}
190+
}
191+
}
192+
193+
blocked, blockedNetworkLength, err := p.isBlockedIPBlocks(ip)
194+
if err != nil {
195+
return false, ip, fmt.Errorf("failed to check if IP %q is blocked by IP block: %w", ip, err)
196+
}
197+
198+
if blocked {
199+
blockedIP = true
161200
}
162201

163-
var allowed bool
164202
for _, allowedCountry := range p.allowedCountries {
165203
if allowedCountry == country {
166-
return true, country, nil
204+
return true, ip, nil
167205
}
168206
}
169207

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

175-
if !allowed {
213+
if allowed {
214+
allowedIP = true
215+
allowedNetworkLength = allowedNetBits
216+
}
217+
218+
// Handle final values
219+
//
220+
// NB: discrete IPs have higher priority than countries: more specific to less specific.
221+
222+
// NB: whichever matched prefix is longer has higher priority: more specific to less specific.
223+
if allowedNetworkLength < blockedNetworkLength {
224+
if blockedIP {
225+
return false, country, nil
226+
}
227+
228+
if allowedIP {
229+
return true, country, nil
230+
}
231+
} else {
232+
if allowedIP {
233+
return true, country, nil
234+
}
235+
236+
if blockedIP {
237+
return false, country, nil
238+
}
239+
}
240+
241+
if allowedCountry {
242+
return true, country, nil
243+
}
244+
245+
if blockedCountry {
176246
return false, country, nil
177247
}
178248

179-
return true, country, nil
249+
return p.defaultAllow, country, nil
180250
}
181251

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

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

200-
var allowedIPBlocksNet []*net.IPNet
270+
var ipBlocksNet []*net.IPNet
201271

202-
for _, cidr := range allowedIPBlocks {
272+
for _, cidr := range ipBlocks {
203273
_, block, err := net.ParseCIDR(cidr)
204274
if err != nil {
205275
return nil, fmt.Errorf("parse error on %q: %v", cidr, err)
206276
}
207-
allowedIPBlocksNet = append(allowedIPBlocksNet, block)
277+
ipBlocksNet = append(ipBlocksNet, block)
208278
}
209279

210-
return allowedIPBlocksNet, nil
280+
return ipBlocksNet, nil
211281
}
212282

213-
// isAllowedIPBlocks check if an IP is allowed base on the allowed CIDR blocks
214-
func (p Plugin) isAllowedIPBlocks(ip string) (bool, error) {
215-
var ipAddress net.IP = net.ParseIP(ip)
283+
// isAllowedIPBlocks checks if an IP is allowed base on the allowed CIDR blocks
284+
func (p Plugin) isAllowedIPBlocks(ip string) (bool, int, error) {
285+
return p.isInIPBlocks(ip, p.allowedIPBlocks)
286+
}
287+
288+
// isBlockedIPBlocks checks if an IP is allowed base on the blocked CIDR blocks
289+
func (p Plugin) isBlockedIPBlocks(ip string) (bool, int, error) {
290+
return p.isInIPBlocks(ip, p.blockedIPBlocks)
291+
}
292+
293+
// isInIPBlocks indicates whether the given IP exists in any of the IP subnets contained within ipBlocks.
294+
func (p Plugin) isInIPBlocks(ip string, ipBlocks []*net.IPNet) (bool, int, error) {
295+
ipAddress := net.ParseIP(ip)
216296

217297
if ipAddress == nil {
218-
return false, fmt.Errorf("unable parse IP address from address [%s]", ip)
298+
return false, 0, fmt.Errorf("unable parse IP address from address [%s]", ip)
219299
}
220300

221-
for _, block := range p.allowedIPBlocks {
301+
for _, block := range ipBlocks {
222302
if block.Contains(ipAddress) {
223-
return true, nil
303+
ones, _ := block.Mask.Size()
304+
305+
return true, ones, nil
224306
}
225307
}
226308

227-
return false, nil
309+
return false, 0, nil
228310
}

plugin_test.go

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ func TestNew(t *testing.T) {
4545
}
4646
})
4747

48-
t.Run("NoConfig", func(t *testing.T) {
48+
t.Run("Nogeoblock.Config", func(t *testing.T) {
4949
plugin, err := New(context.TODO(), &noopHandler{}, nil, pluginName)
5050
if err == nil {
5151
t.Errorf("expected error, but got none")
@@ -174,6 +174,54 @@ func TestPlugin_ServeHTTP(t *testing.T) {
174174
t.Errorf("expected status code %d, but got: %d", http.StatusForbidden, rr.Code)
175175
}
176176
})
177+
178+
t.Run("Blocklist", func(t *testing.T) {
179+
cfg := &Config{
180+
Enabled: true,
181+
DatabaseFilePath: dbFilePath,
182+
BlockedCountries: []string{"US"},
183+
AllowPrivate: false,
184+
DefaultAllow: true,
185+
DisallowedStatusCode: http.StatusForbidden,
186+
}
187+
188+
testRequest(t, "US IP blocked", cfg, "8.8.8.8", http.StatusForbidden)
189+
testRequest(t, "DE IP allowed", cfg, "185.5.82.105", 0)
190+
191+
cfg.BlockedCountries = nil
192+
cfg.BlockedIPBlocks = []string{"8.8.8.0/24"}
193+
194+
testRequest(t, "Google DNS-A blocked", cfg, "8.8.8.8", http.StatusForbidden)
195+
testRequest(t, "Google DNS-B allowed", cfg, "8.8.4.4", 0)
196+
197+
cfg.AllowedIPBlocks = []string{"8.8.8.7/32"}
198+
199+
testRequest(t, "Higher specificity IP CIDR allow trumps lower specificity IP CIDR block", cfg, "8.8.8.7", 0)
200+
testRequest(t, "Higher specificity IP CIDR allow should not override encompassing CIDR block", cfg, "8.8.8.9", http.StatusForbidden)
201+
202+
cfg.DefaultAllow = false
203+
204+
testRequest(t, "Default allow false", cfg, "8.8.4.4", http.StatusForbidden)
205+
})
206+
}
207+
208+
func testRequest(t *testing.T, testName string, cfg *Config, ip string, expectedStatus int) {
209+
t.Run(testName, func(t *testing.T) {
210+
plugin, err := New(context.TODO(), &noopHandler{}, cfg, pluginName)
211+
if err != nil {
212+
t.Errorf("expected no error, but got: %v", err)
213+
}
214+
215+
req := httptest.NewRequest(http.MethodGet, "/foobar", nil)
216+
req.Header.Set("X-Real-IP", ip)
217+
218+
rr := httptest.NewRecorder()
219+
plugin.ServeHTTP(rr, req)
220+
221+
if expectedStatus > 0 && rr.Code != expectedStatus {
222+
t.Errorf("expected status code %d, but got: %d", expectedStatus, rr.Code)
223+
}
224+
})
177225
}
178226

179227
func TestPlugin_Lookup(t *testing.T) {

0 commit comments

Comments
 (0)