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.
- data/lib/rack/wwwhisper.rb +96 -36
- data/test/test_wwwhisper.rb +37 -15
- metadata +4 -4
data/lib/rack/wwwhisper.rb
CHANGED
@@ -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
|
-
|
80
|
-
|
81
|
-
|
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
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
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
|
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
|
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
|
-
[
|
292
|
+
[ code, headers, [body] ]
|
233
293
|
end
|
234
294
|
|
235
295
|
def wwwhisper_auth_request(req)
|
data/test/test_wwwhisper.rb
CHANGED
@@ -37,7 +37,7 @@ end
|
|
37
37
|
|
38
38
|
class TestWWWhisper < Test::Unit::TestCase
|
39
39
|
include Rack::Test::Methods
|
40
|
-
WWWHISPER_URL = 'https://
|
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.
|
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-
|
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:
|
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:
|
143
|
+
hash: -1091804725487714711
|
144
144
|
requirements: []
|
145
145
|
rubyforge_project:
|
146
146
|
rubygems_version: 1.8.24
|