rack-wwwhisper 1.0.5 → 1.0.6

Sign up to get free protection for your applications and to get access to all the features.
Files changed (3) hide show
  1. data/lib/rack/wwwhisper.rb +96 -36
  2. data/test/test_wwwhisper.rb +37 -15
  3. metadata +4 -4
@@ -10,15 +10,21 @@ require 'rack/utils'
10
10
 
11
11
  module Rack
12
12
 
13
+ # An internal middleware used by Rack::WWWhisper to change directives
14
+ # that enable public caching into directives that enable private
15
+ # caching.
16
+ #
17
+ # To be on a safe side, all wwwhisper protected content is treated as
18
+ # sensitive and not publicly cacheable.
13
19
  class NoPublicCache
14
20
  def initialize(app)
15
21
  @app = app
16
22
  end
17
23
 
24
+ # If a response enables caching, makes sure it is private.
18
25
  def call(env)
19
26
  status, headers, body = @app.call(env)
20
27
  if cache_control = headers['Cache-Control']
21
- # If caching is enabled, make sure it is private.
22
28
  cache_control = cache_control.gsub(/public/, 'private')
23
29
  if (not cache_control.include? 'private' and
24
30
  cache_control.index(/max-age\s*=\s*0*[1-9]/))
@@ -30,11 +36,36 @@ class NoPublicCache
30
36
  end
31
37
  [status, headers, body]
32
38
  end
39
+
33
40
  end
34
41
 
42
+ # Communicates with the wwwhisper service to authorize each incomming
43
+ # request. Acts as a proxy for requests to locations handled by
44
+ # wwwhisper (/wwwhisper/auth and /wwwhisper/admin)
45
+ #
46
+ # For each incomming request an authorization query is sent.
47
+ # The query contains a normalized path that a request is
48
+ # trying to access and wwwhisper session cookies. The
49
+ # query result determines the action to be performed:
50
+ # [200] request is allowed and passed down the Rack stack.
51
+ # [401] the user is not authenticated, request is denied, login
52
+ # page is returned.
53
+ # [403] the user is not authorized, request is denied, error is returned.
54
+ # [any other] error while communicating with wwwhisper, request is denied.
55
+ #
56
+ # For Persona assertion verification the middleware depends on a
57
+ # 'Host' header being verified by a frontend server. This is true on
58
+ # Heroku, where the 'Host' header is rewritten if a request sets it to
59
+ # incorrect value. If the frontend server does does not perform such
60
+ # verification, SITE_URL environment variable must be set to enforce a
61
+ # valid url (for example `export SITE_URL="\https://example.com"`).
35
62
  class WWWhisper
63
+ # Requests to locations starting with this prefix are passed to wwwhisper.
36
64
  @@WWWHISPER_PREFIX = '/wwwhisper/'
65
+ # Cookies starting with this prefix are passed to wwwhisper.
37
66
  @@AUTH_COOKIES_PREFIX = 'wwwhisper'
67
+ # Headers that are passed to wwwhisper ('Cookie' is handled
68
+ # in a special way: only wwwhisper related cookies are passed).
38
69
  @@FORWARDED_HEADERS = ['Accept', 'Accept-Language', 'Cookie', 'X-CSRFToken',
39
70
  'X-Requested-With']
40
71
  @@DEFAULT_IFRAME = \
@@ -43,6 +74,21 @@ class WWWhisper
43
74
  right:0px; z-index:11235; background-color:transparent;"> </iframe>
44
75
  ]
45
76
 
77
+ # Following environment variables are recognized:
78
+ # 1. WWWHISPER_DISABLE: useful for a local development environment.
79
+ #
80
+ # 2. WWWHISPER_URL: an address of a wwwhisper service that must be
81
+ # set if WWWHISPER_DISABLE is not set. The url includes
82
+ # credentials that identify a protected site. If the same
83
+ # credentials are used for \www.example.org and \www.example.com,
84
+ # the sites are treated as one: access control rules defined for
85
+ # one site, apply to the other site.
86
+ #
87
+ # 3. WWWHISPER_IFRAME: an HTML snippet that should be injected to
88
+ # returned HTML documents (has a default value).
89
+ #
90
+ # 4. SITE_URL: must be set if the frontend server does not validate
91
+ # the Host header.
46
92
  def initialize(app)
47
93
  @app = app
48
94
  if ENV['WWWHISPER_DISABLE'] == "1"
@@ -51,6 +97,7 @@ class WWWhisper
51
97
  end
52
98
  return
53
99
  end
100
+
54
101
  @app = NoPublicCache.new(app)
55
102
 
56
103
  if not ENV['WWWHISPER_URL']
@@ -65,10 +112,12 @@ class WWWhisper
65
112
  @wwwhisper_iframe_bytesize = Rack::Utils::bytesize(@wwwhisper_iframe)
66
113
  end
67
114
 
115
+ # Exposed for tests.
68
116
  def wwwhisper_path(suffix)
69
117
  "#{@@WWWHISPER_PREFIX}#{suffix}"
70
118
  end
71
119
 
120
+ # Exposed for tests.
72
121
  def auth_query(queried_path)
73
122
  wwwhisper_path "auth/api/is-authorized/?path=#{queried_path}"
74
123
  end
@@ -76,11 +125,9 @@ class WWWhisper
76
125
  def call(env)
77
126
  req = Request.new(env)
78
127
 
79
- if req.path =~ %r{^#{wwwhisper_path('auth')}}
80
- # Requests to /@@WWWHISPER_PREFIX/auth/ should not be authorized,
81
- # every visitor can access login pages.
82
- return dispatch(req)
83
- end
128
+ # Requests to /@@WWWHISPER_PREFIX/auth/ should not be authorized,
129
+ # every visitor can access login pages.
130
+ return dispatch(req) if req.path =~ %r{^#{wwwhisper_path('auth')}}
84
131
 
85
132
  debug req, "sending auth request for #{req.path}"
86
133
  auth_resp = wwwhisper_auth_request(req)
@@ -104,45 +151,49 @@ class WWWhisper
104
151
  end
105
152
 
106
153
  private
107
-
154
+ # Extends Rack::Request with more conservative scheme, host and port
155
+ # setting rules. Rack::Request tries to obtain these values from
156
+ # mutiple sources, whereas for wwwhisper it is crucial that the
157
+ # values are not spoofed by the client.
158
+ #
159
+ # If SITE_URL environemnt variable is set: scheme, host and port are
160
+ # always taken directly from it.
161
+ #
162
+ # If SITE_URL is not set, scheme is taken from the X-Forwarded-Proto
163
+ # header, host is taken from the 'Host' header and port is taken
164
+ # from the X-Forwarded-Port header. The frontend must ensure these
165
+ # values can not be spoofed by client (a request to example.com,
166
+ # that carries a Host header 'example.org' should be dropped or
167
+ # rewritten).
108
168
  class Request < Rack::Request
169
+ attr_reader :scheme, :host, :port, :site_url
170
+
109
171
  def initialize(env)
110
172
  super(env)
111
173
  normalize_path
112
- end
113
-
114
- def scheme
115
- env['HTTP_X_FORWARDED_PROTO'] || 'http'
116
- end
117
-
118
- def host_with_port
119
- env['HTTP_HOST']
120
- end
121
-
122
- def host
123
- host_with_port.to_s.gsub(/:\d+\z/, '')
124
- end
125
-
126
- def port
127
- env['HTTP_X_FORWARDED_PORT'] || host_with_port.split(/:/)[1] ||
128
- default_port(scheme)
129
- end
130
-
131
- def site_url
132
- port_str = port != default_port(scheme) ? ":#{port}" : ""
133
- "#{scheme}://#{host}#{port_str}"
174
+ if (@site_url = ENV['SITE_URL'])
175
+ uri = Addressable::URI.parse(@site_url)
176
+ @scheme = uri.scheme
177
+ @host = uri.host
178
+ @port = uri.port || default_port(@scheme)
179
+ else
180
+ @scheme = env['HTTP_X_FORWARDED_PROTO'] || 'http'
181
+ @host, port_from_host = env['HTTP_HOST'].split(/:/)
182
+ @port = env['HTTP_X_FORWARDED_PORT'] || port_from_host ||
183
+ default_port(@scheme)
184
+ port_str = @port != default_port(@scheme) ? ":#{@port}" : ""
185
+ @site_url = "#{@scheme}://#{@host}#{port_str}"
186
+ end
134
187
  end
135
188
 
136
189
  private
137
- def normalize_path()
190
+ def normalize_path
138
191
  self.script_name =
139
192
  Addressable::URI.normalize_path(script_name).squeeze('/')
140
193
  self.path_info =
141
194
  Addressable::URI.normalize_path(path_info).squeeze('/')
142
195
  # Avoid /foo/ /bar being combined into /foo//bar
143
- if self.path_info[0] == ?/
144
- self.script_name.chomp!('/')
145
- end
196
+ self.script_name.chomp!('/') if self.path_info[0] == ?/
146
197
  end
147
198
 
148
199
  def default_port(proto)
@@ -218,18 +269,27 @@ class WWWhisper
218
269
  end
219
270
  # If sub request returned chunked response, remove the header
220
271
  # (chunks will be combined and returned with 'Content-Length).
221
- rack_headers[header] = value if header != 'Transfer-Encoding'
272
+ rack_headers[header] = value if header !~ /Transfer-Encoding|Set-Cookie/
222
273
  end
274
+ # Multiple Set-Cookie headers need to be set as a single value
275
+ # separated by \n (see Rack SPEC).
276
+ cookies = sub_resp.get_fields('Set-Cookie')
277
+ rack_headers['Set-Cookie'] = cookies.join("\n") if cookies
223
278
  return rack_headers
224
279
  end
225
280
 
226
281
  def sub_response_to_rack(rack_req, sub_resp)
282
+ code = sub_resp.code.to_i
227
283
  headers = sub_response_headers_to_rack(rack_req, sub_resp)
228
284
  body = sub_resp.read_body() || ''
229
- if body.length and not headers['Content-Length']
285
+ if code < 200 or [204, 205, 304].include?(code)
286
+ # See Rack SPEC.
287
+ headers.delete('Content-Length')
288
+ headers.delete('Content-Type')
289
+ elsif (body.length || 0) != 0 and not headers['Content-Length']
230
290
  headers['Content-Length'] = Rack::Utils::bytesize(body).to_s
231
291
  end
232
- [ sub_resp.code.to_i, headers, [body] ]
292
+ [ code, headers, [body] ]
233
293
  end
234
294
 
235
295
  def wwwhisper_auth_request(req)
@@ -37,7 +37,7 @@ end
37
37
 
38
38
  class TestWWWhisper < Test::Unit::TestCase
39
39
  include Rack::Test::Methods
40
- WWWHISPER_URL = 'https://example.com'
40
+ WWWHISPER_URL = 'https://wwwhisper.org'
41
41
  SITE_PROTO = 'https'
42
42
  SITE_HOST = 'bar.io'
43
43
  SITE_PORT = 443
@@ -45,6 +45,7 @@ class TestWWWhisper < Test::Unit::TestCase
45
45
  def setup()
46
46
  @backend = MockBackend.new(TEST_USER)
47
47
  ENV.delete('WWWHISPER_DISABLE')
48
+ ENV.delete('SITE_URL')
48
49
  ENV['WWWHISPER_URL'] = WWWHISPER_URL
49
50
  @wwwhisper = Rack::WWWhisper.new(@backend)
50
51
  end
@@ -57,13 +58,6 @@ class TestWWWhisper < Test::Unit::TestCase
57
58
  "#{WWWHISPER_URL}#{path}"
58
59
  end
59
60
 
60
- def test_wwwhisper_url_required
61
- ENV.delete('WWWHISPER_URL')
62
- assert_raise(StandardError) {
63
- Rack::WWWhisper.new(@backend)
64
- }
65
- end
66
-
67
61
  def get path, params={}, rack_env={}
68
62
  rack_env['HTTP_HOST'] ||= SITE_HOST
69
63
  rack_env['HTTP_X_FORWARDED_PROTO'] ||= SITE_PROTO
@@ -71,15 +65,22 @@ class TestWWWhisper < Test::Unit::TestCase
71
65
  super path, params, rack_env
72
66
  end
73
67
 
68
+ def granted()
69
+ {:status => 200, :body => '', :headers => {'User' => TEST_USER}}
70
+ end
71
+
72
+ def test_wwwhisper_url_required
73
+ ENV.delete('WWWHISPER_URL')
74
+ assert_raise(StandardError) {
75
+ Rack::WWWhisper.new(@backend)
76
+ }
77
+ end
78
+
74
79
  def test_auth_query_path
75
80
  assert_equal('/wwwhisper/auth/api/is-authorized/?path=/foo/bar',
76
81
  @wwwhisper.auth_query('/foo/bar'))
77
82
  end
78
83
 
79
- def granted()
80
- {:status => 200, :body => '', :headers => {'User' => TEST_USER}}
81
- end
82
-
83
84
  def test_request_allowed
84
85
  path = '/foo/bar'
85
86
  stub_request(:get, full_url(@wwwhisper.auth_query(path))).
@@ -166,7 +167,7 @@ class TestWWWhisper < Test::Unit::TestCase
166
167
  assert_equal 'Login', last_response.body
167
168
  end
168
169
 
169
- def test_auth_cookies_passed_to_wwwhisper()
170
+ def test_auth_cookies_passed_to_wwwhisper
170
171
  path = '/foo/bar'
171
172
  stub_request(:get, full_url(@wwwhisper.auth_query(path))).
172
173
  with(:headers => {
@@ -181,7 +182,7 @@ class TestWWWhisper < Test::Unit::TestCase
181
182
  assert_requested :get, full_url(@wwwhisper.auth_query(path))
182
183
  end
183
184
 
184
- def test_non_wwwhisper_cookies_not_passed_to_wwwhisper()
185
+ def test_non_wwwhisper_cookies_not_passed_to_wwwhisper
185
186
  path = '/foo/bar'
186
187
  stub_request(:get, full_url(@wwwhisper.auth_query(path))).
187
188
  with(:headers => {
@@ -280,6 +281,27 @@ class TestWWWhisper < Test::Unit::TestCase
280
281
  assert_requested :get, full_url(path)
281
282
  end
282
283
 
284
+ def test_explicit_site_url_takes_precedense
285
+ path = '/wwwhisper/admin/index.html'
286
+
287
+ site_url = 'https://foo.bar.com'
288
+ ENV['SITE_URL'] = site_url
289
+ stub_request(:get, full_url(@wwwhisper.auth_query(path))).
290
+ with(:headers => {'Site-Url' => site_url}).
291
+ to_return(granted())
292
+ stub_request(:get, full_url(path)).
293
+ with(:headers => {'Site-Url' => site_url}).
294
+ to_return(:status => 200, :body => 'Admin page', :headers => {})
295
+
296
+ get path
297
+ assert last_response.ok?
298
+ assert_equal 200, last_response.status
299
+ assert_equal 'Admin page', last_response.body
300
+ assert_requested :get, full_url(@wwwhisper.auth_query(path))
301
+ assert_requested :get, full_url(path)
302
+ ENV['SITE_URL'] = site_url
303
+ end
304
+
283
305
  def test_host_with_port
284
306
  path = '/foo'
285
307
  host = 'localhost:5000'
@@ -351,7 +373,7 @@ class TestWWWhisper < Test::Unit::TestCase
351
373
  assert_not_nil last_response['Content-Length']
352
374
  end
353
375
 
354
- def test_public_caching_disabled()
376
+ def test_public_caching_disabled
355
377
  path = '/foo/bar'
356
378
  stub_request(:get, full_url(@wwwhisper.auth_query(path))).
357
379
  to_return(granted())
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack-wwwhisper
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.5
4
+ version: 1.0.6
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-01-24 00:00:00.000000000 Z
12
+ date: 2013-01-29 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -131,7 +131,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
131
131
  version: '0'
132
132
  segments:
133
133
  - 0
134
- hash: 411330195400284462
134
+ hash: -1091804725487714711
135
135
  required_rubygems_version: !ruby/object:Gem::Requirement
136
136
  none: false
137
137
  requirements:
@@ -140,7 +140,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
140
140
  version: '0'
141
141
  segments:
142
142
  - 0
143
- hash: 411330195400284462
143
+ hash: -1091804725487714711
144
144
  requirements: []
145
145
  rubyforge_project:
146
146
  rubygems_version: 1.8.24