From mboxrd@z Thu Jan 1 00:00:00 1970 Delivered-To: chneukirchen@gmail.com Received: by 10.140.141.15 with SMTP id o15cs225920rvd; Thu, 14 Jan 2010 21:21:59 -0800 (PST) Received: from mr.google.com ([10.100.238.2]) by 10.100.238.2 with SMTP id l2mr2591645anh.1.1263532918823 (num_hops = 1); Thu, 14 Jan 2010 21:21:58 -0800 (PST) Received: by 10.100.238.2 with SMTP id l2mr145499anh.1.1263532917145; Thu, 14 Jan 2010 21:21:57 -0800 (PST) X-BeenThere: rack-devel@googlegroups.com Received: by 10.87.42.37 with SMTP id u37ls361934fgj.1.p; Thu, 14 Jan 2010 21:21:54 -0800 (PST) Received: by 10.87.50.37 with SMTP id c37mr13710fgk.27.1263532913805; Thu, 14 Jan 2010 21:21:53 -0800 (PST) Received: by 10.213.100.7 with SMTP id w7mr234854ebn.2.1263510337666; Thu, 14 Jan 2010 15:05:37 -0800 (PST) Received: by 10.213.100.7 with SMTP id w7mr234852ebn.2.1263510337572; Thu, 14 Jan 2010 15:05:37 -0800 (PST) Return-Path: Received: from mail-ew0-f223.google.com (mail-ew0-f223.google.com [209.85.219.223]) by gmr-mx.google.com with ESMTP id 17si138079ewy.7.2010.01.14.15.05.36; Thu, 14 Jan 2010 15:05:36 -0800 (PST) Received-SPF: pass (google.com: domain of pirmin.kalberer@gmail.com designates 209.85.219.223 as permitted sender) client-ip=209.85.219.223; Received: by ewy23 with SMTP id 23so141660ewy.24 for ; Thu, 14 Jan 2010 15:05:36 -0800 (PST) Received: by 10.216.90.203 with SMTP id e53mr515871wef.28.1263510335125; Thu, 14 Jan 2010 15:05:35 -0800 (PST) Return-Path: Received: from polarwind.localnet ([72.14.240.161]) by mx.google.com with ESMTPS id m5sm2467319gve.27.2010.01.14.15.05.32 (version=SSLv3 cipher=RC4-MD5); Thu, 14 Jan 2010 15:05:33 -0800 (PST) From: Pirmin Kalberer To: rack-devel@googlegroups.com Subject: [Patch] Rack::Access - Limit access based on IP address Date: Fri, 15 Jan 2010 00:05:30 +0100 User-Agent: KMail/1.12.2 (Linux/2.6.31-17-386; KDE/4.3.2; i686; ; ) MIME-Version: 1.0 Message-Id: <201001150005.30796.pirmin.kalberer@gmail.com> Reply-To: rack-devel@googlegroups.com Precedence: list Mailing-list: list rack-devel@googlegroups.com; contact rack-devel+owners@googlegroups.com List-ID: List-Post: , List-Help: , List-Archive: X-Thread-Url: http://groups.google.com/group/rack-devel/t/936968f4d92881ba X-Message-Url: http://groups.google.com/group/rack-devel/msg/95f973e4d2d60e4a Sender: rack-devel@googlegroups.com List-Unsubscribe: , List-Subscribe: , Content-Type: Multipart/Mixed; boundary="Boundary-00=_6M6TLjL+d2zBFhp" --Boundary-00=_6M6TLjL+d2zBFhp Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Hi all, For implementing a monitoring rack middleware I had to limit access to certain URL's based on IP adresses. The attached patch for rack-contrib implements this. It borrows some ideas from rack-bug and code from Rack::URLMap... -- Pull URL: git://github.com/pka/rack-contrib.git Pirmin Kalberer Sourcepole - Linux & Open Source Solutions http://www.sourcepole.com --Boundary-00=_6M6TLjL+d2zBFhp Content-Type: text/x-patch; charset="ISO-8859-1"; name="rack_access.patch" Content-Transfer-Encoding: 7bit Content-Disposition: attachment; filename="rack_access.patch" diff --git a/README.rdoc b/README.rdoc index 3583555..ca015ae 100644 --- a/README.rdoc +++ b/README.rdoc @@ -45,6 +45,7 @@ interface: * Rack::AcceptFormat - Adds a format extension at the end of the URI when there is none, corresponding to the mime-type given in the Accept HTTP header. * Rack::HostMeta - Configures /host-meta using a block * Rack::Cookies - Adds simple cookie jar hash to env +* Rack::Access - Limit access based on IP address === Use diff --git a/lib/rack/contrib.rb b/lib/rack/contrib.rb index 6252b26..8905437 100644 --- a/lib/rack/contrib.rb +++ b/lib/rack/contrib.rb @@ -8,6 +8,7 @@ module Rack end autoload :AcceptFormat, "rack/contrib/accept_format" + autoload :Access, "rack/contrib/access" autoload :BounceFavicon, "rack/contrib/bounce_favicon" autoload :Cookies, "rack/contrib/cookies" autoload :CSSHTTPRequest, "rack/contrib/csshttprequest" diff --git a/lib/rack/contrib/access.rb b/lib/rack/contrib/access.rb new file mode 100644 index 0000000..2f12065 --- /dev/null +++ b/lib/rack/contrib/access.rb @@ -0,0 +1,85 @@ +require "ipaddr" + +module Rack + + ## + # Rack middleware for limiting access based on IP address + # + # + # === Options: + # + # path => ipmasks ipmasks: Array of remote addresses which are allowed to access + # + # === Examples: + # + # use Rack::Access, '/backend' => [ '127.0.0.1', '192.168.1.0/24' ] + # + # + + class Access + + attr_reader :options + + def initialize(app, options = {}) + @app = app + mapping = options.empty? ? {"/" => ["127.0.0.1"]} : options + @mapping = remap(mapping) + end + + def remap(mapping) + mapping.map { |location, ipmasks| + if location =~ %r{\Ahttps?://(.*?)(/.*)} + host, location = $1, $2 + else + host = nil + end + + unless location[0] == ?/ + raise ArgumentError, "paths need to start with /" + end + location = location.chomp('/') + match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", nil, 'n') + + ipmasks.collect! do |ipmask| + ipmask.is_a?(IPAddr) ? ipmask : IPAddr.new(ipmask) + end + [host, location, match, ipmasks] + }.sort_by { |(h, l, m, a)| [h ? -h.size : (-1.0 / 0.0), -l.size] } # Longest path first + end + + def call(env) + @original_request = Request.new(env) + ipmasks = ipmasks_for_path(env) + return forbidden! unless ip_authorized?(ipmasks) + status, headers, body = @app.call(env) + [status, headers, body] + end + + def ipmasks_for_path(env) + path = env["PATH_INFO"].to_s + hHost, sName, sPort = env.values_at('HTTP_HOST','SERVER_NAME','SERVER_PORT') + @mapping.each do |host, location, match, ipmasks| + next unless (hHost == host || sName == host \ + || (host.nil? && (hHost == sName || hHost == sName+':'+sPort))) + next unless path =~ match && rest = $1 + next unless rest.empty? || rest[0] == ?/ + + return ipmasks + end + nil + end + + def forbidden! + [403, { 'Content-Type' => 'text/html', 'Content-Length' => '0' }, ''] + end + + def ip_authorized?(ipmasks) + return true if ipmasks.nil? + + ipmasks.any? do |ip_mask| + ip_mask.include?(IPAddr.new(@original_request.ip)) + end + end + + end +end diff --git a/rack-contrib.gemspec b/rack-contrib.gemspec index c21fa67..498b8d4 100644 --- a/rack-contrib.gemspec +++ b/rack-contrib.gemspec @@ -3,8 +3,8 @@ Gem::Specification.new do |s| s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= s.name = 'rack-contrib' - s.version = '0.9.2' - s.date = '2009-03-07' + s.version = '0.9.3' + s.date = '2010-01-10' s.description = "Contributed Rack Middleware and Utilities" s.summary = "Contributed Rack Middleware and Utilities" @@ -19,6 +19,7 @@ Gem::Specification.new do |s| Rakefile lib/rack/contrib.rb lib/rack/contrib/accept_format.rb + lib/rack/contrib/access.rb lib/rack/contrib/backstage.rb lib/rack/contrib/bounce_favicon.rb lib/rack/contrib/callbacks.rb @@ -49,6 +50,7 @@ Gem::Specification.new do |s| test/Maintenance.html test/mail_settings.rb test/spec_rack_accept_format.rb + test/spec_rack_access.rb test/spec_rack_backstage.rb test/spec_rack_callbacks.rb test/spec_rack_config.rb diff --git a/test/spec_rack_access.rb b/test/spec_rack_access.rb new file mode 100644 index 0000000..53a80a8 --- /dev/null +++ b/test/spec_rack_access.rb @@ -0,0 +1,154 @@ +require 'test/spec' +require 'rack/mock' +require 'rack/contrib/access' + +context "Rack::Access" do + + setup do + @app = lambda { |env| [200, { 'Content-Type' => 'text/plain' }, 'hello'] } + @mock_addr_1 = '111.111.111.111' + @mock_addr_2 = '192.168.1.222' + @mock_addr_localhost = '127.0.0.1' + @mock_addr_range = '192.168.1.0/24' + end + + def mock_env(remote_addr, path = '/') + Rack::MockRequest.env_for(path, { 'REMOTE_ADDR' => remote_addr }) + end + + def middleware(options = {}) + Rack::Access.new(@app, options) + end + + specify "default configuration should deny non-local requests" do + app = middleware + status, headers, body = app.call(mock_env(@mock_addr_1)) + status.should.equal 403 + body.should.equal '' + end + + specify "default configuration should allow requests from 127.0.0.1" do + app = middleware + status, headers, body = app.call(mock_env(@mock_addr_localhost)) + status.should.equal 200 + body.should.equal 'hello' + end + + specify "should allow remote addresses in allow_ipmasking" do + app = middleware('/' => [@mock_addr_1]) + status, headers, body = app.call(mock_env(@mock_addr_1)) + status.should.equal 200 + body.should.equal 'hello' + end + + specify "should deny remote addresses not in allow_ipmasks" do + app = middleware('/' => [@mock_addr_1]) + status, headers, body = app.call(mock_env(@mock_addr_2)) + status.should.equal 403 + body.should.equal '' + end + + specify "should allow remote addresses in allow_ipmasks range" do + app = middleware('/' => [@mock_addr_range]) + status, headers, body = app.call(mock_env(@mock_addr_2)) + status.should.equal 200 + body.should.equal 'hello' + end + + specify "should deny remote addresses not in allow_ipmasks range" do + app = middleware('/' => [@mock_addr_range]) + status, headers, body = app.call(mock_env(@mock_addr_1)) + status.should.equal 403 + body.should.equal '' + end + + specify "should allow remote addresses in one of allow_ipmasking" do + app = middleware('/' => [@mock_addr_range, @mock_addr_localhost]) + + status, headers, body = app.call(mock_env(@mock_addr_2)) + status.should.equal 200 + body.should.equal 'hello' + + status, headers, body = app.call(mock_env(@mock_addr_localhost)) + status.should.equal 200 + body.should.equal 'hello' + end + + specify "should deny remote addresses not in one of allow_ipmasks" do + app = middleware('/' => [@mock_addr_range, @mock_addr_localhost]) + status, headers, body = app.call(mock_env(@mock_addr_1)) + status.should.equal 403 + body.should.equal '' + end + + specify "handles paths correctly" do + app = middleware({ + 'http://foo.org/bar' => [@mock_addr_localhost], + '/foo' => [@mock_addr_localhost], + '/foo/bar' => [@mock_addr_range, @mock_addr_localhost] + }) + + status, headers, body = app.call(mock_env(@mock_addr_1, "/")) + status.should.equal 200 + body.should.equal 'hello' + + status, headers, body = app.call(mock_env(@mock_addr_1, "/qux")) + status.should.equal 200 + body.should.equal 'hello' + + status, headers, body = app.call(mock_env(@mock_addr_1, "/foo")) + status.should.equal 403 + body.should.equal '' + status, headers, body = app.call(mock_env(@mock_addr_localhost, "/foo")) + status.should.equal 200 + body.should.equal 'hello' + + status, headers, body = app.call(mock_env(@mock_addr_1, "/foo/")) + status.should.equal 403 + body.should.equal '' + status, headers, body = app.call(mock_env(@mock_addr_localhost, "/foo/")) + status.should.equal 200 + body.should.equal 'hello' + + status, headers, body = app.call(mock_env(@mock_addr_1, "/foo/bar")) + status.should.equal 403 + body.should.equal '' + status, headers, body = app.call(mock_env(@mock_addr_localhost, "/foo/bar")) + status.should.equal 200 + body.should.equal 'hello' + status, headers, body = app.call(mock_env(@mock_addr_2, "/foo/bar")) + status.should.equal 200 + body.should.equal 'hello' + + status, headers, body = app.call(mock_env(@mock_addr_1, "/foo/bar/")) + status.should.equal 403 + body.should.equal '' + status, headers, body = app.call(mock_env(@mock_addr_localhost, "/foo/bar/")) + status.should.equal 200 + body.should.equal 'hello' + + status, headers, body = app.call(mock_env(@mock_addr_1, "/foo///bar//quux")) + status.should.equal 403 + body.should.equal '' + status, headers, body = app.call(mock_env(@mock_addr_localhost, "/foo///bar//quux")) + status.should.equal 200 + body.should.equal 'hello' + + status, headers, body = app.call(mock_env(@mock_addr_1, "/foo/quux")) + status.should.equal 403 + body.should.equal '' + status, headers, body = app.call(mock_env(@mock_addr_localhost, "/foo/quux")) + status.should.equal 200 + body.should.equal 'hello' + + status, headers, body = app.call(mock_env(@mock_addr_1, "/bar")) + status.should.equal 200 + body.should.equal 'hello' + status, headers, body = app.call(mock_env(@mock_addr_1, "/bar").merge('HTTP_HOST' => 'foo.org')) + status.should.equal 403 + body.should.equal '' + status, headers, body = app.call(mock_env(@mock_addr_localhost, "/bar").merge('HTTP_HOST' => 'foo.org')) + status.should.equal 200 + body.should.equal 'hello' + end +end --Boundary-00=_6M6TLjL+d2zBFhp--