alpaca 0.9.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.md +72 -9
- data/lib/alpaca.rb +2 -51
- data/lib/alpaca/controller_additions.rb +40 -0
- data/lib/rack/alpaca.rb +51 -0
- data/lib/rack/alpaca/version.rb +1 -1
- data/spec/alpaca_controller_additions_spec.rb +54 -0
- data/spec/rack_alpaca_spec.rb +14 -0
- data/spec/spec_helper.rb +48 -0
- data/spec/support/action_controller.rb +27 -0
- data/spec/support/bar_controller.rb +6 -0
- data/spec/support/baz_controller.rb +7 -0
- data/spec/support/filters.rb +22 -0
- data/spec/support/foo_controller.rb +6 -0
- metadata +32 -2
data/README.md
CHANGED
@@ -1,16 +1,21 @@
|
|
1
1
|
## Alpaca (outta nowhere)
|
2
2
|
|
3
|
-
](http://badge.fury.io/rb/alpaca)
|
4
|
+
[](https://travis-ci.org/jeffchao/alpaca)
|
5
|
+
[](https://gemnasium.com/jeffchao/alpaca.png)
|
6
|
+
[](https://coveralls.io/r/jeffchao/alpaca)
|
4
7
|
|
5
8
|
Alpaca (outta nowhere) is a rack middleware that allows developers to quickly and easily configure and manage a whitelist and/or blacklist. The motivation for Alpaca is to address use cases around security concerns such as malicious clients, denial of service, or adding an extra layer of security to an API or a subset of API endpoints.
|
6
9
|
|
7
|
-
|
10
|
+

|
11
|
+
|
12
|
+
Features
|
8
13
|
----------
|
9
14
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
15
|
+
- Global-level whitelisting and blacklisting at the rack layer
|
16
|
+
- Controller-level whitelisting and blacklisting via `before_filter`
|
17
|
+
- Per-action whitelisting and blacklisting at the controller level
|
18
|
+
- Configuration management via YAML
|
14
19
|
|
15
20
|
Getting started
|
16
21
|
----------
|
@@ -44,11 +49,69 @@ use Rack::Alpaca
|
|
44
49
|
Usage
|
45
50
|
----------
|
46
51
|
|
47
|
-
Alpaca supports
|
52
|
+
Alpaca supports:
|
53
|
+
|
54
|
+
- whitelisting and blacklisting single IP addresses (e.g., `0.0.0.1`)
|
55
|
+
- hostnames (e.g., `127.0.0.1` also affects `localhost`)
|
56
|
+
- range of IP addresses via subnet masks (e.g., `198.18.0.0/15`, `2001:db8::/32`).
|
57
|
+
|
58
|
+
### Global-level whitelisting and blacklisting
|
59
|
+
|
60
|
+
You may use IPv4 or IPv6. Make changes in `config/alpaca.yml` by adding or removing IPs to and from either list. Your file should resemble the following:
|
61
|
+
|
62
|
+
```yaml
|
63
|
+
whitelist:
|
64
|
+
- 0.0.0.1
|
65
|
+
- 198.18.0.0/15
|
66
|
+
- "::/128"
|
67
|
+
blacklist:
|
68
|
+
- 0.0.0.1
|
69
|
+
- 0.0.0.2
|
70
|
+
- "2001:db8::/32"
|
71
|
+
default: allow
|
72
|
+
```
|
73
|
+
|
74
|
+
Depending on your strategy, you may choose to enforce an allow-by-default or deny-by-default approach. You can use the `default` key in the configuration file with either `allow` or `deny` as its value.
|
75
|
+
|
76
|
+
**A note about precedence**: If an IP exists in both the whitelist and blacklist, then whitelist will take precedence and allow the IP.
|
77
|
+
|
78
|
+
### Controller-level whitelisting and blacklisting
|
79
|
+
|
80
|
+
There exists two methods for handling IPs at the controller level. You must have your global-level default set to `allow` for it to be useful. This is because a global-level `deny` would have already blocked all IPs at the rack layer.
|
81
|
+
|
82
|
+
```ruby
|
83
|
+
before_filter :enable_whitelist_and_deny_by_default
|
84
|
+
|
85
|
+
# or
|
86
|
+
|
87
|
+
before_filter :enable_blacklist_and_allow_by_default
|
88
|
+
```
|
89
|
+
|
90
|
+
You may optionally attach this filter to specific method(s):
|
91
|
+
|
92
|
+
```ruby
|
93
|
+
before_filter :enable_whitlist_and_deny_by_default, only: [:create, :update]
|
94
|
+
```
|
95
|
+
|
96
|
+
Lastly, you may add additional IPs that were not previously defined in your alpaca.yml`:
|
97
|
+
|
98
|
+
```ruby
|
99
|
+
before_filter only: [:create, :update] { |f| f.enable_whitelist_and_deny_by_default(['0.0.0.1']) }
|
100
|
+
```
|
101
|
+
|
102
|
+
### Example setups
|
103
|
+
|
104
|
+
Given that some configuration permuations may be unecessary or illogical, the following is a table of typical use cases. The cells represent the resulting behavior:
|
48
105
|
|
49
|
-
|
106
|
+
| | global allow-by-default | global deny-by-default |
|
107
|
+
| --- | ----------------------- | ---------------------- |
|
108
|
+
| **no controller filter** | all IPs allowed | all IPs denied |
|
109
|
+
| **controller filter whitelist, no added IPs** | IPs in whitelist from `alpaca.yml` allowed for controller. All other IPs denied for controller. All IPs allowed everywhere else | all IPs denied |
|
110
|
+
| **controller filter whitelist, added IPs** | IPs in whitelist from `alpaca.yml` and arguments to filter allowed for controller. All other IPs denied for controller. All IPs allowed everywhere else | all IPs denied |
|
111
|
+
| **controller filter blacklist, no added IPs** | IPs in blacklist from `alpaca.yml` denied for controller. All other IPs allowed for controller. All IPs allowed everywhere else | all IPs denied |
|
112
|
+
| **controller filter blacklist, added IPs** | IPs in blacklist from `alpaca.yml` and arguments denied for controller. All other IPs allowed for controller. All IPs allowed everywhere else | all IPs denied |
|
50
113
|
|
51
|
-
Performance
|
114
|
+
Performance
|
52
115
|
----------
|
53
116
|
|
54
117
|
Through initial testing, Alpaca does not appear to cause noticeable overhead. Future tests under different types of load will be documented here.
|
data/lib/alpaca.rb
CHANGED
@@ -1,54 +1,5 @@
|
|
1
1
|
require 'rack'
|
2
2
|
require 'yaml'
|
3
3
|
require 'ipaddr'
|
4
|
-
|
5
|
-
|
6
|
-
module Alpaca
|
7
|
-
class << self
|
8
|
-
attr_reader :whitelist, :blacklist, :default
|
9
|
-
|
10
|
-
def new (app)
|
11
|
-
@app = app
|
12
|
-
config = YAML.load_file('config/alpaca.yml')
|
13
|
-
@whitelist ||= config['whitelist'].map { |ip| IPAddr.new(ip) }.freeze
|
14
|
-
@blacklist ||= config['blacklist'].map { |ip| IPAddr.new(ip) }.freeze
|
15
|
-
@default = config['default']
|
16
|
-
|
17
|
-
self
|
18
|
-
end
|
19
|
-
|
20
|
-
def call (env)
|
21
|
-
req = Rack::Request.new(env)
|
22
|
-
|
23
|
-
if whitelisted?('whitelist', req)
|
24
|
-
@app.call(env)
|
25
|
-
elsif blacklisted?('blacklist', req)
|
26
|
-
[503, {}, ["Request blocked\n"]]
|
27
|
-
else
|
28
|
-
default_strategy(env)
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
private
|
33
|
-
|
34
|
-
def default_strategy (env)
|
35
|
-
if @default == 'whitelist'
|
36
|
-
@app.call(env)
|
37
|
-
elsif @default == 'blacklist'
|
38
|
-
[503, {}, ["Request blocked\n"]]
|
39
|
-
else
|
40
|
-
raise 'Unknown default strategy'
|
41
|
-
end
|
42
|
-
end
|
43
|
-
|
44
|
-
def check (type, req)
|
45
|
-
instance_variable_get("@#{type}").any? do |ip|
|
46
|
-
ip.include?(req.ip)
|
47
|
-
end
|
48
|
-
end
|
49
|
-
|
50
|
-
alias_method :whitelisted?, :check
|
51
|
-
alias_method :blacklisted?, :check
|
52
|
-
end
|
53
|
-
end
|
54
|
-
end
|
4
|
+
require 'alpaca/controller_additions'
|
5
|
+
require 'rack/alpaca'
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Alpaca
|
2
|
+
# These are additional helper methods that are added to
|
3
|
+
# ApplicationController. In particular, this module contains
|
4
|
+
# filters that should be used as before_filters.
|
5
|
+
module ControllerAdditions
|
6
|
+
def enable_whitelist_and_deny_by_default (additional_ips = [])
|
7
|
+
render_blocked_request unless whitelisted?(additional_ips)
|
8
|
+
end
|
9
|
+
|
10
|
+
def enable_blacklist_and_allow_by_default (additional_ips = [])
|
11
|
+
if blacklisted?(additional_ips)
|
12
|
+
render_blocked_request
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def whitelisted? (additional_ips)
|
19
|
+
additional_ips.any? { |ip| IPAddr.new(ip).include?(request.ip) } ||
|
20
|
+
Rack::Alpaca.whitelist.any? { |ip| ip.include?(request.ip) }
|
21
|
+
end
|
22
|
+
|
23
|
+
def blacklisted? (additional_ips)
|
24
|
+
additional_ips.any? { |ip| IPAddr.new(ip).include?(request.ip) } ||
|
25
|
+
Rack::Alpaca.blacklist.any? { |ip| ip.include?(request.ip) }
|
26
|
+
end
|
27
|
+
|
28
|
+
def render_blocked_request
|
29
|
+
render text: "Request blocked\n",
|
30
|
+
status: :service_unavailable,
|
31
|
+
content_type: 'text/plain'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
if defined?(ActionController::Base)
|
37
|
+
ActionController::Base.instance_eval do
|
38
|
+
include Alpaca::ControllerAdditions
|
39
|
+
end
|
40
|
+
end
|
data/lib/rack/alpaca.rb
ADDED
@@ -0,0 +1,51 @@
|
|
1
|
+
module Rack
|
2
|
+
module Alpaca
|
3
|
+
class << self
|
4
|
+
attr_reader :whitelist, :blacklist
|
5
|
+
attr_accessor :default
|
6
|
+
|
7
|
+
def new (app)
|
8
|
+
@app = app
|
9
|
+
config = YAML.load_file('config/alpaca.yml')
|
10
|
+
@whitelist ||= config['whitelist'].map { |ip| IPAddr.new(ip) }.freeze
|
11
|
+
@blacklist ||= config['blacklist'].map { |ip| IPAddr.new(ip) }.freeze
|
12
|
+
@default = config['default']
|
13
|
+
|
14
|
+
self
|
15
|
+
end
|
16
|
+
|
17
|
+
def call (env)
|
18
|
+
req = Rack::Request.new(env)
|
19
|
+
|
20
|
+
if whitelisted?('whitelist', req)
|
21
|
+
@app.call(env)
|
22
|
+
elsif blacklisted?('blacklist', req)
|
23
|
+
[503, {}, ["Request blocked\n"]]
|
24
|
+
else
|
25
|
+
default_strategy(env)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def default_strategy (env)
|
32
|
+
if @default == 'allow'
|
33
|
+
@app.call(env)
|
34
|
+
elsif @default == 'deny'
|
35
|
+
[503, {}, ["Request blocked\n"]]
|
36
|
+
else
|
37
|
+
raise 'Unknown default strategy'
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def check (type, req)
|
42
|
+
instance_variable_get("@#{type}").any? do |ip|
|
43
|
+
ip.include?(req.ip)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
alias_method :whitelisted?, :check
|
48
|
+
alias_method :blacklisted?, :check
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
data/lib/rack/alpaca/version.rb
CHANGED
@@ -0,0 +1,54 @@
|
|
1
|
+
require_relative 'spec_helper'
|
2
|
+
|
3
|
+
describe 'Alpaca::ControllerAdditions' do
|
4
|
+
before do
|
5
|
+
@ips = ["0.0.0.1", "198.18.0.0/15", "0.0.0.3"].map { |ip| IPAddr.new(ip) }
|
6
|
+
end
|
7
|
+
|
8
|
+
it 'should initialize with controller additions' do
|
9
|
+
controller = FooController.new
|
10
|
+
controller.methods.must_include(:enable_whitelist_and_deny_by_default)
|
11
|
+
controller.methods.must_include(:enable_blacklist_and_allow_by_default)
|
12
|
+
end
|
13
|
+
|
14
|
+
describe 'with global allow-by-default' do
|
15
|
+
describe '#enable_whitelist_and_deny_by_default' do
|
16
|
+
it 'should only permit IPs on the whitelist' do
|
17
|
+
@ips[0...-1].each do |ip|
|
18
|
+
get '/', {}, 'REMOTE_ADDR' => ip.to_s
|
19
|
+
last_response.status.must_equal 200
|
20
|
+
last_response.body.must_equal 'foo'
|
21
|
+
end
|
22
|
+
|
23
|
+
get '/foo', {}, 'REMOTE_ADDR' => @ips.last.to_s
|
24
|
+
last_response.status.must_equal 503
|
25
|
+
last_response.body.must_equal "Request blocked\n"
|
26
|
+
end
|
27
|
+
|
28
|
+
describe 'with additional IPs' do
|
29
|
+
it 'should permit IPs on the whitelist and arguments' do
|
30
|
+
@ips = @ips[0...-1].push('0.0.0.4')
|
31
|
+
@ips[0..-1].each do |ip|
|
32
|
+
get '/baz', {}, 'REMOTE_ADDR' => ip.to_s
|
33
|
+
last_response.status.must_equal 200
|
34
|
+
last_response.body.must_equal 'baz'
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
describe '#enable_blacklist_and_allow_by_default' do
|
41
|
+
it 'should allow all IPs except ones on the blacklist' do
|
42
|
+
@ips[1..-1].each do |ip|
|
43
|
+
get '/', {}, 'REMOTE_ADDR' => ip.to_s
|
44
|
+
last_response.status.must_equal 200
|
45
|
+
last_response.body.must_equal 'foo'
|
46
|
+
end
|
47
|
+
|
48
|
+
get '/bar', {}, 'REMOTE_ADDR' => @ips.first.to_s
|
49
|
+
last_response.status.must_equal 503
|
50
|
+
last_response.body.must_equal "Request blocked\n"
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
data/spec/rack_alpaca_spec.rb
CHANGED
@@ -64,4 +64,18 @@ describe 'Rack::Alpaca' do
|
|
64
64
|
end
|
65
65
|
end
|
66
66
|
end
|
67
|
+
|
68
|
+
describe 'allow-by-default' do
|
69
|
+
before do
|
70
|
+
@ips = ["0.0.0.1", "198.18.0.0/15", "::/128"].map { |ip| IPAddr.new(ip) }
|
71
|
+
end
|
72
|
+
|
73
|
+
it 'should permit any IP address' do
|
74
|
+
@ips.each do |ip|
|
75
|
+
get '/', {}, 'REMOTE_ADDR' => ip.to_s
|
76
|
+
last_response.status.must_equal 200
|
77
|
+
last_response.body.must_equal 'foo'
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
67
81
|
end
|
data/spec/spec_helper.rb
CHANGED
@@ -1,3 +1,12 @@
|
|
1
|
+
require 'simplecov'
|
2
|
+
require 'coveralls'
|
3
|
+
|
4
|
+
SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter[
|
5
|
+
SimpleCov::Formatter::HTMLFormatter,
|
6
|
+
Coveralls::SimpleCov::Formatter
|
7
|
+
]
|
8
|
+
SimpleCov.start
|
9
|
+
|
1
10
|
require "rubygems"
|
2
11
|
require "bundler/setup"
|
3
12
|
|
@@ -8,12 +17,51 @@ require "rack/test"
|
|
8
17
|
require 'active_support'
|
9
18
|
require "alpaca"
|
10
19
|
|
20
|
+
require_relative 'support/action_controller'
|
21
|
+
require_relative 'support/foo_controller'
|
22
|
+
require_relative 'support/bar_controller'
|
23
|
+
require_relative 'support/baz_controller'
|
24
|
+
|
11
25
|
class Minitest::Spec
|
12
26
|
include Rack::Test::Methods
|
13
27
|
|
14
28
|
def app
|
15
29
|
Rack::Builder.new {
|
16
30
|
use Rack::Alpaca
|
31
|
+
|
32
|
+
map '/foo' do
|
33
|
+
run lambda { |env|
|
34
|
+
controller = ::FooController.new
|
35
|
+
controller.request = Rack::Request.new(env)
|
36
|
+
controller.response = Rack::Response.new
|
37
|
+
controller.process('foo')
|
38
|
+
|
39
|
+
controller.response.finish
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
map '/bar' do
|
44
|
+
run lambda { |env|
|
45
|
+
controller = ::BarController.new
|
46
|
+
controller.request = Rack::Request.new(env)
|
47
|
+
controller.response = Rack::Response.new
|
48
|
+
controller.process('bar')
|
49
|
+
|
50
|
+
controller.response.finish
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
map '/baz' do
|
55
|
+
run lambda { |env|
|
56
|
+
controller = ::BazController.new
|
57
|
+
controller.request = Rack::Request.new(env)
|
58
|
+
controller.response = Rack::Response.new
|
59
|
+
controller.process('baz')
|
60
|
+
|
61
|
+
controller.response.finish
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
17
65
|
run lambda { |env| [200, {}, ['foo']] }
|
18
66
|
}.to_app
|
19
67
|
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
require_relative 'filters'
|
2
|
+
|
3
|
+
module ActionController
|
4
|
+
class Metal
|
5
|
+
attr_accessor :request, :response
|
6
|
+
|
7
|
+
def process (action)
|
8
|
+
send(action)
|
9
|
+
end
|
10
|
+
|
11
|
+
def render (*args)
|
12
|
+
response.status = Rack::Utils.status_code(args.first[:status])
|
13
|
+
response.body = [args.first[:text]]
|
14
|
+
response
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
class Base < Metal
|
19
|
+
include Filters
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
if defined?(ActionController::Base)
|
24
|
+
ActionController::Base.instance_eval do
|
25
|
+
include Alpaca::ControllerAdditions
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
module Filters
|
2
|
+
def self.included (base)
|
3
|
+
base.extend ClassMethods
|
4
|
+
end
|
5
|
+
|
6
|
+
module ClassMethods
|
7
|
+
def before_filters
|
8
|
+
@before_filters ||= []
|
9
|
+
end
|
10
|
+
|
11
|
+
def before_filter (*args)
|
12
|
+
method = args.shift
|
13
|
+
ips = args.shift
|
14
|
+
before_filters << (ips.nil? ? [method] : [method, ips])
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def process (action)
|
19
|
+
self.class.before_filters.each { |method, ips| ips.nil? ? send(method) : send(method, ips) }
|
20
|
+
super
|
21
|
+
end
|
22
|
+
end
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: alpaca
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 1.0.0
|
5
5
|
prerelease:
|
6
6
|
platform: ruby
|
7
7
|
authors:
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: bin
|
11
11
|
cert_chain: []
|
12
|
-
date: 2013-
|
12
|
+
date: 2013-06-04 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: rack
|
@@ -75,6 +75,22 @@ dependencies:
|
|
75
75
|
- - ! '>='
|
76
76
|
- !ruby/object:Gem::Version
|
77
77
|
version: '0'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: simplecov
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
type: :development
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
78
94
|
- !ruby/object:Gem::Dependency
|
79
95
|
name: rack-test
|
80
96
|
requirement: !ruby/object:Gem::Requirement
|
@@ -97,13 +113,21 @@ executables: []
|
|
97
113
|
extensions: []
|
98
114
|
extra_rdoc_files: []
|
99
115
|
files:
|
116
|
+
- lib/alpaca/controller_additions.rb
|
100
117
|
- lib/alpaca.rb
|
101
118
|
- lib/rack/alpaca/version.rb
|
119
|
+
- lib/rack/alpaca.rb
|
102
120
|
- lib/rails/generators/alpaca/install/install_generator.rb
|
103
121
|
- Rakefile
|
104
122
|
- README.md
|
123
|
+
- spec/alpaca_controller_additions_spec.rb
|
105
124
|
- spec/rack_alpaca_spec.rb
|
106
125
|
- spec/spec_helper.rb
|
126
|
+
- spec/support/action_controller.rb
|
127
|
+
- spec/support/bar_controller.rb
|
128
|
+
- spec/support/baz_controller.rb
|
129
|
+
- spec/support/filters.rb
|
130
|
+
- spec/support/foo_controller.rb
|
107
131
|
homepage: http://github.com/jeffchao/alpaca
|
108
132
|
licenses: []
|
109
133
|
post_install_message:
|
@@ -130,5 +154,11 @@ signing_key:
|
|
130
154
|
specification_version: 3
|
131
155
|
summary: Whitelist and blacklist IPs
|
132
156
|
test_files:
|
157
|
+
- spec/alpaca_controller_additions_spec.rb
|
133
158
|
- spec/rack_alpaca_spec.rb
|
134
159
|
- spec/spec_helper.rb
|
160
|
+
- spec/support/action_controller.rb
|
161
|
+
- spec/support/bar_controller.rb
|
162
|
+
- spec/support/baz_controller.rb
|
163
|
+
- spec/support/filters.rb
|
164
|
+
- spec/support/foo_controller.rb
|