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
1=== modified file 'lib/canonical/launchpad/webapp/login.py'
2--- lib/canonical/launchpad/webapp/login.py 2010-07-24 17:43:17 +0000
3+++ lib/canonical/launchpad/webapp/login.py 2010-08-17 16:05:20 +0000
4@@ -254,15 +254,6 @@
5 'Did not expect multi-valued fields.')
6 params[key] = value[0]
7
8- # XXX benji 2010-07-23 bug=608920
9- # The production OpenID provider has some Django middleware that
10- # generates a token used to prevent XSRF attacks and stuffs it into
11- # every form. Unfortunately that includes forms that have off-site
12- # targets and since our OpenID client verifies that no form values have
13- # been injected as a security precaution, this breaks logging-in in
14- # certain circumstances (see bug 597324). The best we can do at the
15- # moment is to remove the token before invoking the OpenID library.
16- params.pop('csrfmiddlewaretoken', None)
17 return params
18
19 def _get_requested_url(self, request):
20
21=== modified file 'lib/canonical/launchpad/webapp/publication.py'
22--- lib/canonical/launchpad/webapp/publication.py 2010-07-30 00:06:00 +0000
23+++ lib/canonical/launchpad/webapp/publication.py 2010-08-17 16:05:20 +0000
24@@ -60,6 +60,90 @@
25
26 METHOD_WRAPPER_TYPE = type({}.__setitem__)
27
28+OFFSITE_POST_WHITELIST = ('/+storeblob', '/+request-token', '/+access-token',
29+ '/+hwdb/+submit', '/+openid')
30+
31+
32+def maybe_block_offsite_form_post(request):
33+ """Check if an attempt was made to post a form from a remote site.
34+
35+ This is a cross-site request forgery (XSRF/CSRF) countermeasure.
36+
37+ The OffsiteFormPostError exception is raised if the following
38+ holds true:
39+ 1. the request method is POST *AND*
40+ 2. a. the HTTP referer header is empty *OR*
41+ b. the host portion of the referrer is not a registered vhost
42+ """
43+ if request.method != 'POST':
44+ return
45+ if (IOAuthSignedRequest.providedBy(request)
46+ or not IBrowserRequest.providedBy(request)):
47+ # We only want to check for the referrer header if we are
48+ # in the middle of a request initiated by a web browser. A
49+ # request to the web service (which is necessarily
50+ # OAuth-signed) or a request that does not implement
51+ # IBrowserRequest (such as an XML-RPC request) can do
52+ # without a Referer.
53+ return
54+ if request['PATH_INFO'] in OFFSITE_POST_WHITELIST:
55+ # XXX: jamesh 2007-11-23 bug=124421:
56+ # Allow offsite posts to our TestOpenID endpoint. Ideally we'd
57+ # have a better way of marking this URL as allowing offsite
58+ # form posts.
59+ #
60+ # XXX gary 2010-03-09 bug=535122,538097
61+ # The one-off exceptions are necessary because existing
62+ # non-browser applications make requests to these URLs
63+ # without providing a Referer. Apport makes POST requests
64+ # to +storeblob without providing a Referer (bug 538097),
65+ # and launchpadlib used to make POST requests to
66+ # +request-token and +access-token without providing a
67+ # Referer.
68+ #
69+ # XXX Abel Deuring 2010-04-09 bug=550973
70+ # The HWDB client "checkbox" accesses /+hwdb/+submit without
71+ # a referer. This will change in the version in Ubuntu 10.04,
72+ # but Launchpad should support HWDB submissions from older
73+ # Ubuntu versions during their support period.
74+ #
75+ # We'll have to keep an application's one-off exception
76+ # until the application has been changed to send a
77+ # Referer, and until we have no legacy versions of that
78+ # application to support. For instance, we can't get rid
79+ # of the apport exception until after Lucid's end-of-life
80+ # date. We should be able to get rid of the launchpadlib
81+ # exception after Karmic's end-of-life date.
82+ return
83+ if request['PATH_INFO'].startswith('/+openid-callback'):
84+ # If this is a callback from an OpenID provider, we don't require an
85+ # on-site referer (because the provider may be off-site). This
86+ # exception was added as a result of bug 597324 (message #10 in
87+ # particular).
88+ return
89+ referrer = request.getHeader('referer') # match HTTP spec misspelling
90+ if not referrer:
91+ raise NoReferrerError('No value for REFERER header')
92+ # XXX: jamesh 2007-04-26 bug=98437:
93+ # The Zope testing infrastructure sets a default (incorrect)
94+ # referrer value of "localhost" or "localhost:9000" if no
95+ # referrer is included in the request. We let it pass through
96+ # here for the benefits of the tests. Web browsers send full
97+ # URLs so this does not open us up to extra XSRF attacks.
98+ if referrer in ['localhost', 'localhost:9000']:
99+ return
100+ # Extract the hostname from the referrer URI
101+ try:
102+ hostname = URI(referrer).host
103+ except InvalidURIError:
104+ hostname = None
105+ if hostname not in allvhosts.hostnames:
106+ raise OffsiteFormPostError(referrer)
107+
108+
109+class ProfilingOops(Exception):
110+ """Fake exception used to log OOPS information when profiling pages."""
111+
112
113 class LoginRoot:
114 """Object that provides IPublishTraverse to return only itself.
115@@ -191,7 +275,7 @@
116 principal = self.getPrincipal(request)
117 request.setPrincipal(principal)
118 self.maybeRestrictToTeam(request)
119- self.maybeBlockOffsiteFormPost(request)
120+ maybe_block_offsite_form_post(request)
121 self.maybeNotifyReadOnlyMode(request)
122
123 def maybeNotifyReadOnlyMode(self, request):
124@@ -295,77 +379,6 @@
125 uri = uri.replace(query=query_string)
126 return str(uri)
127
128- def maybeBlockOffsiteFormPost(self, request):
129- """Check if an attempt was made to post a form from a remote site.
130-
131- The OffsiteFormPostError exception is raised if the following
132- holds true:
133- 1. the request method is POST *AND*
134- 2. a. the HTTP referer header is empty *OR*
135- b. the host portion of the referrer is not a registered vhost
136- """
137- if request.method != 'POST':
138- return
139- # XXX: jamesh 2007-11-23 bug=124421:
140- # Allow offsite posts to our TestOpenID endpoint. Ideally we'd
141- # have a better way of marking this URL as allowing offsite
142- # form posts.
143- if request['PATH_INFO'] == '/+openid':
144- return
145- if (IOAuthSignedRequest.providedBy(request)
146- or not IBrowserRequest.providedBy(request)
147- or request['PATH_INFO'] in (
148- '/+storeblob', '/+request-token', '/+access-token',
149- '/+hwdb/+submit')):
150- # We only want to check for the referrer header if we are
151- # in the middle of a request initiated by a web browser. A
152- # request to the web service (which is necessarily
153- # OAuth-signed) or a request that does not implement
154- # IBrowserRequest (such as an XML-RPC request) can do
155- # without a Referer.
156- #
157- # XXX gary 2010-03-09 bug=535122,538097
158- # The one-off exceptions are necessary because existing
159- # non-browser applications make requests to these URLs
160- # without providing a Referer. Apport makes POST requests
161- # to +storeblob without providing a Referer (bug 538097),
162- # and launchpadlib used to make POST requests to
163- # +request-token and +access-token without providing a
164- # Referer.
165- #
166- # XXX Abel Deuring 2010-04-09 bug=550973
167- # The HWDB client "checkbox" accesses /+hwdb/+submit without
168- # a referer. This will change in the version in Ubuntu 10.04,
169- # but Launchpad should support HWDB submissions from older
170- # Ubuntu versions during their support period.
171- #
172- # We'll have to keep an application's one-off exception
173- # until the application has been changed to send a
174- # Referer, and until we have no legacy versions of that
175- # application to support. For instance, we can't get rid
176- # of the apport exception until after Lucid's end-of-life
177- # date. We should be able to get rid of the launchpadlib
178- # exception after Karmic's end-of-life date.
179- return
180- referrer = request.getHeader('referer') # match HTTP spec misspelling
181- if not referrer:
182- raise NoReferrerError('No value for REFERER header')
183- # XXX: jamesh 2007-04-26 bug=98437:
184- # The Zope testing infrastructure sets a default (incorrect)
185- # referrer value of "localhost" or "localhost:9000" if no
186- # referrer is included in the request. We let it pass through
187- # here for the benefits of the tests. Web browsers send full
188- # URLs so this does not open us up to extra XSRF attacks.
189- if referrer in ['localhost', 'localhost:9000']:
190- return
191- # Extract the hostname from the referrer URI
192- try:
193- hostname = URI(referrer).host
194- except InvalidURIError:
195- hostname = None
196- if hostname not in allvhosts.hostnames:
197- raise OffsiteFormPostError(referrer)
198-
199 def constructPageID(self, view, context):
200 """Given a view, figure out what its page ID should be.
201
202
203=== modified file 'lib/canonical/launchpad/webapp/tests/test_login.py'
204--- lib/canonical/launchpad/webapp/tests/test_login.py 2010-08-12 21:26:53 +0000
205+++ lib/canonical/launchpad/webapp/tests/test_login.py 2010-08-17 16:05:20 +0000
206@@ -219,24 +219,6 @@
207 view = OpenIDCallbackView(context=None, request=None)
208 self.assertRaises(ValueError, view._gather_params, request)
209
210- def test_csrfmiddlewaretoken_is_ignored(self):
211- # Show that the _gather_params filters out the errant
212- # csrfmiddlewaretoken form field. See comment in _gather_params for
213- # more info.
214- request = LaunchpadTestRequest(
215- SERVER_URL='http://example.com',
216- QUERY_STRING='foo=bar',
217- form={'starting_url': 'http://launchpad.dev/after-login',
218- 'csrfmiddlewaretoken': '12345'},
219- environ={'PATH_INFO': '/'})
220- view = OpenIDCallbackView(context=None, request=None)
221- params = view._gather_params(request)
222- expected_params = {
223- 'starting_url': 'http://launchpad.dev/after-login',
224- 'foo': 'bar',
225- }
226- self.assertEquals(params, expected_params)
227-
228 def test_get_requested_url(self):
229 # The OpenIDCallbackView needs to pass the currently-being-requested
230 # URL to the OpenID library. OpenIDCallbackView._get_requested_url
231
232=== modified file 'lib/canonical/launchpad/webapp/tests/test_publication.py'
233--- lib/canonical/launchpad/webapp/tests/test_publication.py 2010-05-27 07:53:38 +0000
234+++ lib/canonical/launchpad/webapp/tests/test_publication.py 2010-08-17 16:05:20 +0000
235@@ -10,6 +10,7 @@
236 import unittest
237
238 from contrib.oauth import OAuthRequest, OAuthSignatureMethod_PLAINTEXT
239+from zope.interface import directlyProvides, noLongerProvides
240
241 from storm.database import STATE_DISCONNECTED, STATE_RECONNECT
242 from storm.exceptions import DisconnectionError
243@@ -18,20 +19,25 @@
244 from zope.component import getUtility
245 from zope.error.interfaces import IErrorReportingUtility
246 from zope.publisher.interfaces import Retry
247+from zope.publisher.interfaces.browser import IBrowserRequest
248
249 from canonical.config import dbconfig
250 from canonical.launchpad.database.emailaddress import EmailAddress
251 from canonical.launchpad.interfaces.lpstorm import IMasterStore
252-from canonical.launchpad.interfaces.oauth import IOAuthConsumerSet
253+from canonical.launchpad.interfaces.oauth import (
254+ IOAuthConsumerSet, IOAuthSignedRequest)
255 from canonical.launchpad.ftests import ANONYMOUS, login
256 from canonical.launchpad.readonly import is_read_only
257 from canonical.launchpad.tests.readonly import (
258 remove_read_only_file, touch_read_only_file)
259 import canonical.launchpad.webapp.adapter as dbadapter
260+from canonical.launchpad.webapp.vhosts import allvhosts
261 from canonical.launchpad.webapp.interfaces import (
262- IStoreSelector, MAIN_STORE, MASTER_FLAVOR, OAuthPermission, SLAVE_FLAVOR)
263+ IStoreSelector, MAIN_STORE, MASTER_FLAVOR, OAuthPermission, SLAVE_FLAVOR,
264+ OffsiteFormPostError, NoReferrerError)
265 from canonical.launchpad.webapp.publication import (
266- is_browser, LaunchpadBrowserPublication)
267+ is_browser, LaunchpadBrowserPublication, maybe_block_offsite_form_post,
268+ OFFSITE_POST_WHITELIST)
269 from canonical.launchpad.webapp.servers import (
270 LaunchpadTestRequest, WebServicePublication)
271 from canonical.testing import DatabaseFunctionalLayer, FunctionalLayer
272@@ -312,6 +318,96 @@
273 self.assertFalse(is_browser(request))
274
275
276+class TestBlockingOffsitePosts(TestCase):
277+ """We are very particular about what form POSTs we will accept."""
278+
279+ def test_NoReferrerError(self):
280+ # If this request is a POST and there is no referrer, an exception is
281+ # raised.
282+ request = LaunchpadTestRequest(
283+ method='POST', environ=dict(PATH_INFO='/'))
284+ self.assertRaises(
285+ NoReferrerError, maybe_block_offsite_form_post, request)
286+
287+ def test_nonPOST_requests(self):
288+ # If the request isn't a POST it is always allowed.
289+ request = LaunchpadTestRequest(method='SOMETHING')
290+ maybe_block_offsite_form_post(request)
291+
292+ def test_localhost_is_ok(self):
293+ # we accept "localhost" and "localhost:9000" as valid referrers. See
294+ # comments in the code as to why and for a related bug report.
295+ request = LaunchpadTestRequest(
296+ method='POST', environ=dict(PATH_INFO='/', REFERER='localhost'))
297+ # this doesn't raise an exception
298+ maybe_block_offsite_form_post(request)
299+
300+ def test_whitelisted_paths(self):
301+ # There are a few whitelisted POST targets that don't require the
302+ # referrer be LP. See comments in the code as to why and for related
303+ # bug reports.
304+ for path in OFFSITE_POST_WHITELIST:
305+ request = LaunchpadTestRequest(
306+ method='POST', environ=dict(PATH_INFO=path))
307+ # this call shouldn't raise an exception
308+ maybe_block_offsite_form_post(request)
309+
310+ def test_OAuth_signed_requests(self):
311+ # Requests that are OAuth signed are allowed.
312+ request = LaunchpadTestRequest(
313+ method='POST', environ=dict(PATH_INFO='/'))
314+ directlyProvides(request, IOAuthSignedRequest)
315+ # this call shouldn't raise an exception
316+ maybe_block_offsite_form_post(request)
317+
318+ def test_nonbrowser_requests(self):
319+ # Requests that are from non-browsers are allowed.
320+ class FakeNonBrowserRequest:
321+ method = 'SOMETHING'
322+
323+ # this call shouldn't raise an exception
324+ maybe_block_offsite_form_post(FakeNonBrowserRequest)
325+
326+ def test_onsite_posts(self):
327+ # Other than the explicit execptions, all POSTs have to come from a
328+ # known LP virtual host.
329+ for hostname in allvhosts.hostnames:
330+ referer = 'http://' + hostname + '/foo'
331+ request = LaunchpadTestRequest(
332+ method='POST', environ=dict(PATH_INFO='/', REFERER=referer))
333+ # this call shouldn't raise an exception
334+ maybe_block_offsite_form_post(request)
335+
336+ def test_offsite_posts(self):
337+ # If a post comes from an unknown host an execption is raised.
338+ disallowed_hosts = ['example.com', 'not-subdomain.launchpad.net']
339+ for hostname in disallowed_hosts:
340+ referer = 'http://' + hostname + '/foo'
341+ request = LaunchpadTestRequest(
342+ method='POST', environ=dict(PATH_INFO='/', REFERER=referer))
343+ self.assertRaises(
344+ OffsiteFormPostError, maybe_block_offsite_form_post, request)
345+
346+ def test_unparsable_referer(self):
347+ # If a post has a referer that is unparsable as a URI an exception is
348+ # raised.
349+ referer = 'this is not a URI'
350+ request = LaunchpadTestRequest(
351+ method='POST', environ=dict(PATH_INFO='/', REFERER=referer))
352+ self.assertRaises(
353+ OffsiteFormPostError, maybe_block_offsite_form_post, request)
354+
355+
356+ def test_openid_callback_with_query_string(self):
357+ # An OpenId provider (OP) may post to the +openid-callback URL with a
358+ # query string and without a referer. These posts need to be allowed.
359+ path_info = u'/+openid-callback?starting_url=...'
360+ request = LaunchpadTestRequest(
361+ method='POST', environ=dict(PATH_INFO=path_info))
362+ # this call shouldn't raise an exception
363+ maybe_block_offsite_form_post(request)
364+
365+
366 def test_suite():
367 suite = unittest.TestLoader().loadTestsFromName(__name__)
368 return suite