commit 5a205201aac8891610f10b24d24e7947f5dd1eed Author: Rob Watson Date: Mon Jul 10 08:24:07 2017 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dfd5c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +/.bundle/ +/.yardoc +/Gemfile.lock +/_yardoc/ +/coverage/ +/doc/ +/pkg/ +/spec/reports/ +/tmp/ + +# rspec failure tracking +.rspec_status + +.byebug_history + +*.gem diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..8c18f1a --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--format documentation +--color diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..b64d770 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,5 @@ +sudo: false +language: ruby +rvm: + - 2.4.1 +before_install: gem install bundler -v 1.14.6 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..851fabc --- /dev/null +++ b/Gemfile @@ -0,0 +1,2 @@ +source 'https://rubygems.org' +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..38d79e2 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Rob Watson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1c30678 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# rack-filter-param + +Refactoring something behind an API? Plagued by extraneous HTTP params? `rack-filter-param` might be for you. + +## What is it? + +[Rack](https://github.com/rack/rack) middleware to remove specific params from HTTP requests. + +## What does it do? + +Given a set of params and optional constraints, `rack-filter-param` will remove those params, then pass the request downstream. + +Removes params from: + +* GET querystring +* POST params (x-www-form-urlencoded) +* JSON or other params hitherto processed by [`ActionDispatch::ParamsParser`](http://api.rubyonrails.org/classes/ActionDispatch/ParamsParser.html) + +## Installation + +```ruby + +gem 'rack-filter-param', require: 'rack/filter_param' +``` + +## Usage + +In rackup file or `application.rb`, initialize `rack-filter-param` with a list of HTTP params you want filtered from requests. + +Strip a parameter named `client_id`: + +```ruby +use Rack::FilterParam, :client_id +``` + +Strip a parameter named `client_id` from a specific path only: + +```ruby +use Rack::FilterParam, { param: :client_id, path: '/oauth/tokens' } +``` + +Strip a parameter named `client_id` from a fuzzy path: + +```ruby +use Rack::FilterParam, { param: :client_id, path: /\A\/oauth/ } +``` + +To filter multiple parameters, an array of parameters or options hashes can also be passed. + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/rfwatson/rack-filter-param + + +## License + +The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). + diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..b7e9ed5 --- /dev/null +++ b/Rakefile @@ -0,0 +1,6 @@ +require "bundler/gem_tasks" +require "rspec/core/rake_task" + +RSpec::Core::RakeTask.new(:spec) + +task :default => :spec diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..69f5240 --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "rack/filter_param" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/lib/rack/filter_param.rb b/lib/rack/filter_param.rb new file mode 100644 index 0000000..6a02f93 --- /dev/null +++ b/lib/rack/filter_param.rb @@ -0,0 +1,66 @@ +require "rack/filter_param/version" + +module Rack + class FilterParam + ACTION_DISPATCH_KEY = 'action_dispatch.request.request_parameters'.freeze + FILTERED_PARAMS_KEY = 'rack.filtered_params'.freeze + + def initialize(app, *params) + @app = app + @params = params.flatten + end + + def call(env) + @request = Rack::Request.new(env) + @params.each { |param| process_param(param) } + + @app.call(env) + end + + private + + attr_reader :request + + def process_param(param) + return unless path_matches?(param) + + param = param[:param] if param.is_a?(Hash) + + if delete_from_action_dispatch(param) || delete_from_request(param) + filtered_params << [ param.to_s, nil ] + end + end + + def path_matches?(param) + return true unless param.is_a?(Hash) + + path = param[:path] + return true unless path = param[:path] + + return request.env['PATH_INFO'] == path if path.is_a?(String) + return request.env['PATH_INFO'] =~ path if path.is_a?(Regexp) + + false + end + + def delete_from_action_dispatch(param) + action_dispatch_parsed? && !!action_dispatch_params.delete(param.to_s) + end + + def delete_from_request(param) + !!request.delete_param(param.to_s) + end + + def action_dispatch_params + request.env[ACTION_DISPATCH_KEY] + end + + def action_dispatch_parsed? + !action_dispatch_params.nil? + end + + def filtered_params + request.env[FILTERED_PARAMS_KEY] ||= [] + end + end +end diff --git a/lib/rack/filter_param/version.rb b/lib/rack/filter_param/version.rb new file mode 100644 index 0000000..6eb6d78 --- /dev/null +++ b/lib/rack/filter_param/version.rb @@ -0,0 +1,5 @@ +module Rack + class FilterParam + VERSION = "0.1.0" + end +end diff --git a/rack-filter-param.gemspec b/rack-filter-param.gemspec new file mode 100644 index 0000000..78b5d9a --- /dev/null +++ b/rack-filter-param.gemspec @@ -0,0 +1,29 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'rack/filter_param/version' + +Gem::Specification.new do |spec| + spec.name = "rack-filter-param" + spec.version = Rack::FilterParam::VERSION + spec.authors = ["Rob Watson"] + spec.email = ["rob@mixlr.com"] + + spec.summary = "Rack middleware to filter params from HTTP requests" + spec.homepage = "https://github.com/rfwatson" + spec.license = "MIT" + + spec.files = `git ls-files -z`.split("\x0").reject do |f| + f.match(%r{^(test|spec|features)/}) + end + spec.bindir = "exe" + spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] + + spec.add_development_dependency 'bundler', '~> 1.14' + spec.add_development_dependency 'rake', '~> 10.0' + spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'rack-test', '~> 0.6' + spec.add_development_dependency 'json', '~> 2' + spec.add_development_dependency 'byebug', '~> 9' +end diff --git a/spec/rack/filter_param_spec.rb b/spec/rack/filter_param_spec.rb new file mode 100644 index 0000000..9939a96 --- /dev/null +++ b/spec/rack/filter_param_spec.rb @@ -0,0 +1,190 @@ +require "spec_helper" +require 'json' + +RSpec.describe Rack::FilterParam do + let(:path) { '/' } + let(:params) { {} } + let(:headers) { {} } + let(:rack_env) { {} } + let(:params_to_test) { + last_request.env[described_class::ACTION_DISPATCH_KEY] || last_request.params + } + + before { + headers.each { |k, v| header(k.to_s, v) } + public_send(method, path, params, rack_env) + } + + shared_context 'middleware with basic filters' do + let(:app) { + Rack::Builder.new do + use Rack::FilterParam, :x, :y + run -> (env) { [200, {}, ['OK']] } + end.to_app + } + end + + shared_context 'middleware with filtered paths' do + let(:app) { + Rack::Builder.new do + use Rack::FilterParam, [ + { param: :x, path: '/' }, + { param: :y, path: /\A\/something/ } + ] + run -> (env) { [200, {}, ['OK']] } + end.to_app + } + end + + shared_examples 'core functionality' do + context 'sending a param that is not expected to be filtered' do + let(:params) { { 'a' => '1' } } + + it 'does not filter the param' do + expect(params_to_test).to eq('a' => '1') + end + + it 'does not include the param in `rack.filtered_params`' do + expect(last_request.env['rack.filtered_params']) + .to be nil + end + end + + context 'sending a param that is expected to be filtered' do + let(:params) { { 'x' => '1' } } + + it 'filters the param' do + expect(params_to_test.keys).to eq [] + end + + it 'includes the param in `rack.filtered_params`' do + expect(last_request.env['rack.filtered_params']) + .to eq [['x', nil]] + end + end + + context 'sending two params, filtering one' do + let(:params) { { 'x' => '1', 'a' => '1' } } + + it 'filters the param' do + expect(params_to_test.keys).to eq ['a'] + end + + it 'includes one param in `rack.filtered_params`' do + expect(last_request.env['rack.filtered_params']) + .to eq [['x', nil]] + end + end + + context 'sending three params, filtering two' do + let(:params) { { 'x' => '1', 'y' => '1', 'a' => '1' } } + + it 'filters the params' do + expect(params_to_test.keys).to eq ['a'] + end + + it 'includes two params in `rack.filtered_params`' do + expect(last_request.env['rack.filtered_params']) + .to eq [['x', nil], ['y', nil]] + end + end + end + + shared_examples 'path filtering' do + let(:params) { { 'x' => '1', 'y' => '1' } } + + context 'when the path is equal to a string' do + let(:path) { '/' } + + it 'filters the param' do + expect(params_to_test.keys).to eq ['y'] + end + + it 'includes the param in `rack.filtered_params`' do + expect(last_request.env['rack.filtered_params']) + .to eq [['x', nil]] + end + end + + context 'when the path matches a regexp' do + let(:path) { '/something/good' } + + it 'filters the param' do + expect(params_to_test.keys).to eq ['x'] + end + + it 'includes the param in `rack.filtered_params`' do + expect(last_request.env['rack.filtered_params']) + .to eq [['y', nil]] + end + end + + context 'when the path does not match' do + let(:path) { '/wrong' } + + it 'does not filter the param' do + expect(params_to_test) + .to eq('x' => '1', 'y' => '1') + end + + it 'does not include the param in `rack.filtered_params`' do + expect(last_request.env['rack.filtered_params']) + .to be nil + end + end + end + + context 'GET request' do + let(:method) { :get } + + describe 'basic functionality' do + include_context 'middleware with basic filters' + include_examples 'core functionality' + end + + describe 'path filtering' do + include_context 'middleware with filtered paths' + include_examples 'path filtering' + end + end + + context 'POST request' do + let(:method) { :post } + + let(:headers) { + super().merge( + 'Content-Type' => 'application/x-www-form-urlencoded' + ) + } + + describe 'basic functionality' do + include_context 'middleware with basic filters' + include_examples 'core functionality' + end + + describe 'path filtering' do + include_context 'middleware with filtered paths' + include_examples 'path filtering' + end + end + + context 'Request previously parsed by ActionDispatch::ParamsParser' do + let(:method) { :post } + let(:headers) { super().merge('Content-Type' => 'application/json') } + let(:params) { super().to_json } + + let(:rack_env) { + { described_class::ACTION_DISPATCH_KEY => params } + } + + describe 'basic functionality' do + include_context 'middleware with basic filters' + include_examples 'core functionality' + end + + describe 'path filtering' do + include_context 'middleware with filtered paths' + include_examples 'path filtering' + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..2418bad --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,17 @@ +require 'bundler/setup' +require 'rack/filter_param' +require 'rack/test' +require 'byebug' +require 'ap' + +RSpec.configure do |config| + # Enable flags like --only-failures and --next-failure + config.example_status_persistence_file_path = '.rspec_status' + + # Include rack-test helpers + config.include Rack::Test::Methods + + config.expect_with :rspec do |c| + c.syntax = :expect + end +end