Merge lp:~benji/launchpad/bug-597324 into lp:launchpad

Proposed by Benji York
Status: Merged
Approved by: Māris Fogels
Approved revision: no longer in the source branch.
Merged at revision: 11378
Proposed branch: lp:~benji/launchpad/bug-597324
Merge into: lp:launchpad
Diff against target: 368 lines (+184/-102)
4 files modified
lib/canonical/launchpad/webapp/login.py (+0/-9)
lib/canonical/launchpad/webapp/publication.py (+85/-72)
lib/canonical/launchpad/webapp/tests/test_login.py (+0/-18)
lib/canonical/launchpad/webapp/tests/test_publication.py (+99/-3)
To merge this branch: bzr merge lp:~benji/launchpad/bug-597324
Reviewer Review Type Date Requested Status
Māris Fogels (community) Approve
Review via email: mp+32886@code.launchpad.net

Description of the change

This merge proposal covers three relatively small changes to this branch
(r11114-r11118):

- removal an attempted (and ill-fated) work-around for bug 608920
- addition of tests for all existing maybe_block_offsite_form_post code
  paths (in preparation to add another exception)
- addition of referer requirement exception for OpenID callback (fixes
  the error described in
  https://bugs.edge.launchpad.net/launchpad-foundations/+bug/597324/comments/10)

To post a comment you must log in.
Revision history for this message
Māris Fogels (mars) wrote :

Hi Benji,

This change looks good to me. The test suite addition is particularly welcome. That code is absolutely critical judging by the number and detail of the comments, so the additional test coverage counts many many times over. Big +1 for that.

On line 352 of the diff you have a comment that says '# this should not raise an exception', and then on the next line you call self.assertRaises(). The comment probably needs to be deleted.

Otherwise, this change looks good. r=mars

Maris

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/canonical/launchpad/webapp/login.py'
--- lib/canonical/launchpad/webapp/login.py 2010-07-24 17:43:17 +0000
+++ lib/canonical/launchpad/webapp/login.py 2010-08-17 16:05:20 +0000
@@ -254,15 +254,6 @@
254 'Did not expect multi-valued fields.')254 'Did not expect multi-valued fields.')
255 params[key] = value[0]255 params[key] = value[0]
256256
257 # XXX benji 2010-07-23 bug=608920
258 # The production OpenID provider has some Django middleware that
259 # generates a token used to prevent XSRF attacks and stuffs it into
260 # every form. Unfortunately that includes forms that have off-site
261 # targets and since our OpenID client verifies that no form values have
262 # been injected as a security precaution, this breaks logging-in in
263 # certain circumstances (see bug 597324). The best we can do at the
264 # moment is to remove the token before invoking the OpenID library.
265 params.pop('csrfmiddlewaretoken', None)
266 return params257 return params
267258
268 def _get_requested_url(self, request):259 def _get_requested_url(self, request):
269260
=== modified file 'lib/canonical/launchpad/webapp/publication.py'
--- lib/canonical/launchpad/webapp/publication.py 2010-07-30 00:06:00 +0000
+++ lib/canonical/launchpad/webapp/publication.py 2010-08-17 16:05:20 +0000
@@ -60,6 +60,90 @@
6060
61METHOD_WRAPPER_TYPE = type({}.__setitem__)61METHOD_WRAPPER_TYPE = type({}.__setitem__)
6262
63OFFSITE_POST_WHITELIST = ('/+storeblob', '/+request-token', '/+access-token',
64 '/+hwdb/+submit', '/+openid')
65
66
67def maybe_block_offsite_form_post(request):
68 """Check if an attempt was made to post a form from a remote site.
69
70 This is a cross-site request forgery (XSRF/CSRF) countermeasure.
71
72 The OffsiteFormPostError exception is raised if the following
73 holds true:
74 1. the request method is POST *AND*
75 2. a. the HTTP referer header is empty *OR*
76 b. the host portion of the referrer is not a registered vhost
77 """
78 if request.method != 'POST':
79 return
80 if (IOAuthSignedRequest.providedBy(request)
81 or not IBrowserRequest.providedBy(request)):
82 # We only want to check for the referrer header if we are
83 # in the middle of a request initiated by a web browser. A
84 # request to the web service (which is necessarily
85 # OAuth-signed) or a request that does not implement
86 # IBrowserRequest (such as an XML-RPC request) can do
87 # without a Referer.
88 return
89 if request['PATH_INFO'] in OFFSITE_POST_WHITELIST:
90 # XXX: jamesh 2007-11-23 bug=124421:
91 # Allow offsite posts to our TestOpenID endpoint. Ideally we'd
92 # have a better way of marking this URL as allowing offsite
93 # form posts.
94 #
95 # XXX gary 2010-03-09 bug=535122,538097
96 # The one-off exceptions are necessary because existing
97 # non-browser applications make requests to these URLs
98 # without providing a Referer. Apport makes POST requests
99 # to +storeblob without providing a Referer (bug 538097),
100 # and launchpadlib used to make POST requests to
101 # +request-token and +access-token without providing a
102 # Referer.
103 #
104 # XXX Abel Deuring 2010-04-09 bug=550973
105 # The HWDB client "checkbox" accesses /+hwdb/+submit without
106 # a referer. This will change in the version in Ubuntu 10.04,
107 # but Launchpad should support HWDB submissions from older
108 # Ubuntu versions during their support period.
109 #
110 # We'll have to keep an application's one-off exception
111 # until the application has been changed to send a
112 # Referer, and until we have no legacy versions of that
113 # application to support. For instance, we can't get rid
114 # of the apport exception until after Lucid's end-of-life
115 # date. We should be able to get rid of the launchpadlib
116 # exception after Karmic's end-of-life date.
117 return
118 if request['PATH_INFO'].startswith('/+openid-callback'):
119 # If this is a callback from an OpenID provider, we don't require an
120 # on-site referer (because the provider may be off-site). This
121 # exception was added as a result of bug 597324 (message #10 in
122 # particular).
123 return
124 referrer = request.getHeader('referer') # match HTTP spec misspelling
125 if not referrer:
126 raise NoReferrerError('No value for REFERER header')
127 # XXX: jamesh 2007-04-26 bug=98437:
128 # The Zope testing infrastructure sets a default (incorrect)
129 # referrer value of "localhost" or "localhost:9000" if no
130 # referrer is included in the request. We let it pass through
131 # here for the benefits of the tests. Web browsers send full
132 # URLs so this does not open us up to extra XSRF attacks.
133 if referrer in ['localhost', 'localhost:9000']:
134 return
135 # Extract the hostname from the referrer URI
136 try:
137 hostname = URI(referrer).host
138 except InvalidURIError:
139 hostname = None
140 if hostname not in allvhosts.hostnames:
141 raise OffsiteFormPostError(referrer)
142
143
144class ProfilingOops(Exception):
145 """Fake exception used to log OOPS information when profiling pages."""
146
63147
64class LoginRoot:148class LoginRoot:
65 """Object that provides IPublishTraverse to return only itself.149 """Object that provides IPublishTraverse to return only itself.
@@ -191,7 +275,7 @@
191 principal = self.getPrincipal(request)275 principal = self.getPrincipal(request)
192 request.setPrincipal(principal)276 request.setPrincipal(principal)
193 self.maybeRestrictToTeam(request)277 self.maybeRestrictToTeam(request)
194 self.maybeBlockOffsiteFormPost(request)278 maybe_block_offsite_form_post(request)
195 self.maybeNotifyReadOnlyMode(request)279 self.maybeNotifyReadOnlyMode(request)
196280
197 def maybeNotifyReadOnlyMode(self, request):281 def maybeNotifyReadOnlyMode(self, request):
@@ -295,77 +379,6 @@
295 uri = uri.replace(query=query_string)379 uri = uri.replace(query=query_string)
296 return str(uri)380 return str(uri)
297381
298 def maybeBlockOffsiteFormPost(self, request):
299 """Check if an attempt was made to post a form from a remote site.
300
301 The OffsiteFormPostError exception is raised if the following
302 holds true:
303 1. the request method is POST *AND*
304 2. a. the HTTP referer header is empty *OR*
305 b. the host portion of the referrer is not a registered vhost
306 """
307 if request.method != 'POST':
308 return
309 # XXX: jamesh 2007-11-23 bug=124421:
310 # Allow offsite posts to our TestOpenID endpoint. Ideally we'd
311 # have a better way of marking this URL as allowing offsite
312 # form posts.
313 if request['PATH_INFO'] == '/+openid':
314 return
315 if (IOAuthSignedRequest.providedBy(request)
316 or not IBrowserRequest.providedBy(request)
317 or request['PATH_INFO'] in (
318 '/+storeblob', '/+request-token', '/+access-token',
319 '/+hwdb/+submit')):
320 # We only want to check for the referrer header if we are
321 # in the middle of a request initiated by a web browser. A
322 # request to the web service (which is necessarily
323 # OAuth-signed) or a request that does not implement
324 # IBrowserRequest (such as an XML-RPC request) can do
325 # without a Referer.
326 #
327 # XXX gary 2010-03-09 bug=535122,538097
328 # The one-off exceptions are necessary because existing
329 # non-browser applications make requests to these URLs
330 # without providing a Referer. Apport makes POST requests
331 # to +storeblob without providing a Referer (bug 538097),
332 # and launchpadlib used to make POST requests to
333 # +request-token and +access-token without providing a
334 # Referer.
335 #
336 # XXX Abel Deuring 2010-04-09 bug=550973
337 # The HWDB client "checkbox" accesses /+hwdb/+submit without
338 # a referer. This will change in the version in Ubuntu 10.04,
339 # but Launchpad should support HWDB submissions from older
340 # Ubuntu versions during their support period.
341 #
342 # We'll have to keep an application's one-off exception
343 # until the application has been changed to send a
344 # Referer, and until we have no legacy versions of that
345 # application to support. For instance, we can't get rid
346 # of the apport exception until after Lucid's end-of-life
347 # date. We should be able to get rid of the launchpadlib
348 # exception after Karmic's end-of-life date.
349 return
350 referrer = request.getHeader('referer') # match HTTP spec misspelling
351 if not referrer:
352 raise NoReferrerError('No value for REFERER header')
353 # XXX: jamesh 2007-04-26 bug=98437:
354 # The Zope testing infrastructure sets a default (incorrect)
355 # referrer value of "localhost" or "localhost:9000" if no
356 # referrer is included in the request. We let it pass through
357 # here for the benefits of the tests. Web browsers send full
358 # URLs so this does not open us up to extra XSRF attacks.
359 if referrer in ['localhost', 'localhost:9000']:
360 return
361 # Extract the hostname from the referrer URI
362 try:
363 hostname = URI(referrer).host
364 except InvalidURIError:
365 hostname = None
366 if hostname not in allvhosts.hostnames:
367 raise OffsiteFormPostError(referrer)
368
369 def constructPageID(self, view, context):382 def constructPageID(self, view, context):
370 """Given a view, figure out what its page ID should be.383 """Given a view, figure out what its page ID should be.
371384
372385
=== modified file 'lib/canonical/launchpad/webapp/tests/test_login.py'
--- lib/canonical/launchpad/webapp/tests/test_login.py 2010-08-12 21:26:53 +0000
+++ lib/canonical/launchpad/webapp/tests/test_login.py 2010-08-17 16:05:20 +0000
@@ -219,24 +219,6 @@
219 view = OpenIDCallbackView(context=None, request=None)219 view = OpenIDCallbackView(context=None, request=None)
220 self.assertRaises(ValueError, view._gather_params, request)220 self.assertRaises(ValueError, view._gather_params, request)
221221
222 def test_csrfmiddlewaretoken_is_ignored(self):
223 # Show that the _gather_params filters out the errant
224 # csrfmiddlewaretoken form field. See comment in _gather_params for
225 # more info.
226 request = LaunchpadTestRequest(
227 SERVER_URL='http://example.com',
228 QUERY_STRING='foo=bar',
229 form={'starting_url': 'http://launchpad.dev/after-login',
230 'csrfmiddlewaretoken': '12345'},
231 environ={'PATH_INFO': '/'})
232 view = OpenIDCallbackView(context=None, request=None)
233 params = view._gather_params(request)
234 expected_params = {
235 'starting_url': 'http://launchpad.dev/after-login',
236 'foo': 'bar',
237 }
238 self.assertEquals(params, expected_params)
239
240 def test_get_requested_url(self):222 def test_get_requested_url(self):
241 # The OpenIDCallbackView needs to pass the currently-being-requested223 # The OpenIDCallbackView needs to pass the currently-being-requested
242 # URL to the OpenID library. OpenIDCallbackView._get_requested_url224 # URL to the OpenID library. OpenIDCallbackView._get_requested_url
243225
=== modified file 'lib/canonical/launchpad/webapp/tests/test_publication.py'
--- lib/canonical/launchpad/webapp/tests/test_publication.py 2010-05-27 07:53:38 +0000
+++ lib/canonical/launchpad/webapp/tests/test_publication.py 2010-08-17 16:05:20 +0000
@@ -10,6 +10,7 @@
10import unittest10import unittest
1111
12from contrib.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT12from contrib.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
13from zope.interface import directlyProvides, noLongerProvides
1314
14from storm.database import STATE_DISCONNECTED, STATE_RECONNECT15from storm.database import STATE_DISCONNECTED, STATE_RECONNECT
15from storm.exceptions import DisconnectionError16from storm.exceptions import DisconnectionError
@@ -18,20 +19,25 @@
18from zope.component import getUtility19from zope.component import getUtility
19from zope.error.interfaces import IErrorReportingUtility20from zope.error.interfaces import IErrorReportingUtility
20from zope.publisher.interfaces import Retry21from zope.publisher.interfaces import Retry
22from zope.publisher.interfaces.browser import IBrowserRequest
2123
22from canonical.config import dbconfig24from canonical.config import dbconfig
23from canonical.launchpad.database.emailaddress import EmailAddress25from canonical.launchpad.database.emailaddress import EmailAddress
24from canonical.launchpad.interfaces.lpstorm import IMasterStore26from canonical.launchpad.interfaces.lpstorm import IMasterStore
25from canonical.launchpad.interfaces.oauth import IOAuthConsumerSet27from canonical.launchpad.interfaces.oauth import (
28 IOAuthConsumerSet, IOAuthSignedRequest)
26from canonical.launchpad.ftests import ANONYMOUS, login29from canonical.launchpad.ftests import ANONYMOUS, login
27from canonical.launchpad.readonly import is_read_only30from canonical.launchpad.readonly import is_read_only
28from canonical.launchpad.tests.readonly import (31from canonical.launchpad.tests.readonly import (
29 remove_read_only_file, touch_read_only_file)32 remove_read_only_file, touch_read_only_file)
30import canonical.launchpad.webapp.adapter as dbadapter33import canonical.launchpad.webapp.adapter as dbadapter
34from canonical.launchpad.webapp.vhosts import allvhosts
31from canonical.launchpad.webapp.interfaces import (35from canonical.launchpad.webapp.interfaces import (
32 IStoreSelector, MAIN_STORE, MASTER_FLAVOR, OAuthPermission, SLAVE_FLAVOR)36 IStoreSelector, MAIN_STORE, MASTER_FLAVOR, OAuthPermission, SLAVE_FLAVOR,
37 OffsiteFormPostError, NoReferrerError)
33from canonical.launchpad.webapp.publication import (38from canonical.launchpad.webapp.publication import (
34 is_browser, LaunchpadBrowserPublication)39 is_browser, LaunchpadBrowserPublication, maybe_block_offsite_form_post,
40 OFFSITE_POST_WHITELIST)
35from canonical.launchpad.webapp.servers import (41from canonical.launchpad.webapp.servers import (
36 LaunchpadTestRequest, WebServicePublication)42 LaunchpadTestRequest, WebServicePublication)
37from canonical.testing import DatabaseFunctionalLayer, FunctionalLayer43from canonical.testing import DatabaseFunctionalLayer, FunctionalLayer
@@ -312,6 +318,96 @@
312 self.assertFalse(is_browser(request))318 self.assertFalse(is_browser(request))
313319
314320
321class TestBlockingOffsitePosts(TestCase):
322 """We are very particular about what form POSTs we will accept."""
323
324 def test_NoReferrerError(self):
325 # If this request is a POST and there is no referrer, an exception is
326 # raised.
327 request = LaunchpadTestRequest(
328 method='POST', environ=dict(PATH_INFO='/'))
329 self.assertRaises(
330 NoReferrerError, maybe_block_offsite_form_post, request)
331
332 def test_nonPOST_requests(self):
333 # If the request isn't a POST it is always allowed.
334 request = LaunchpadTestRequest(method='SOMETHING')
335 maybe_block_offsite_form_post(request)
336
337 def test_localhost_is_ok(self):
338 # we accept "localhost" and "localhost:9000" as valid referrers. See
339 # comments in the code as to why and for a related bug report.
340 request = LaunchpadTestRequest(
341 method='POST', environ=dict(PATH_INFO='/', REFERER='localhost'))
342 # this doesn't raise an exception
343 maybe_block_offsite_form_post(request)
344
345 def test_whitelisted_paths(self):
346 # There are a few whitelisted POST targets that don't require the
347 # referrer be LP. See comments in the code as to why and for related
348 # bug reports.
349 for path in OFFSITE_POST_WHITELIST:
350 request = LaunchpadTestRequest(
351 method='POST', environ=dict(PATH_INFO=path))
352 # this call shouldn't raise an exception
353 maybe_block_offsite_form_post(request)
354
355 def test_OAuth_signed_requests(self):
356 # Requests that are OAuth signed are allowed.
357 request = LaunchpadTestRequest(
358 method='POST', environ=dict(PATH_INFO='/'))
359 directlyProvides(request, IOAuthSignedRequest)
360 # this call shouldn't raise an exception
361 maybe_block_offsite_form_post(request)
362
363 def test_nonbrowser_requests(self):
364 # Requests that are from non-browsers are allowed.
365 class FakeNonBrowserRequest:
366 method = 'SOMETHING'
367
368 # this call shouldn't raise an exception
369 maybe_block_offsite_form_post(FakeNonBrowserRequest)
370
371 def test_onsite_posts(self):
372 # Other than the explicit execptions, all POSTs have to come from a
373 # known LP virtual host.
374 for hostname in allvhosts.hostnames:
375 referer = 'http://' + hostname + '/foo'
376 request = LaunchpadTestRequest(
377 method='POST', environ=dict(PATH_INFO='/', REFERER=referer))
378 # this call shouldn't raise an exception
379 maybe_block_offsite_form_post(request)
380
381 def test_offsite_posts(self):
382 # If a post comes from an unknown host an execption is raised.
383 disallowed_hosts = ['example.com', 'not-subdomain.launchpad.net']
384 for hostname in disallowed_hosts:
385 referer = 'http://' + hostname + '/foo'
386 request = LaunchpadTestRequest(
387 method='POST', environ=dict(PATH_INFO='/', REFERER=referer))
388 self.assertRaises(
389 OffsiteFormPostError, maybe_block_offsite_form_post, request)
390
391 def test_unparsable_referer(self):
392 # If a post has a referer that is unparsable as a URI an exception is
393 # raised.
394 referer = 'this is not a URI'
395 request = LaunchpadTestRequest(
396 method='POST', environ=dict(PATH_INFO='/', REFERER=referer))
397 self.assertRaises(
398 OffsiteFormPostError, maybe_block_offsite_form_post, request)
399
400
401 def test_openid_callback_with_query_string(self):
402 # An OpenId provider (OP) may post to the +openid-callback URL with a
403 # query string and without a referer. These posts need to be allowed.
404 path_info = u'/+openid-callback?starting_url=...'
405 request = LaunchpadTestRequest(
406 method='POST', environ=dict(PATH_INFO=path_info))
407 # this call shouldn't raise an exception
408 maybe_block_offsite_form_post(request)
409
410
315def test_suite():411def test_suite():
316 suite = unittest.TestLoader().loadTestsFromName(__name__)412 suite = unittest.TestLoader().loadTestsFromName(__name__)
317 return suite413 return suite