rack-wwwhisper 1.0.5 → 1.0.6

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.
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