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