Merge lp:~canonical-isd-hackers/canonical-identity-provider/bug_530271_csrf into lp:canonical-identity-provider/release

Proposed by Łukasz Czyżykowski
Status: Merged
Merged at revision: 49
Proposed branch: lp:~canonical-isd-hackers/canonical-identity-provider/bug_530271_csrf
Merge into: lp:canonical-identity-provider/release
Diff against target: 233 lines (+122/-19)
4 files modified
identityprovider/middleware/csrf.py (+6/-2)
identityprovider/templates/403-csrf.html (+21/-0)
identityprovider/tests/test_middleware.py (+92/-16)
identityprovider/views/ui.py (+3/-1)
To merge this branch: bzr merge lp:~canonical-isd-hackers/canonical-identity-provider/bug_530271_csrf
Reviewer Review Type Date Requested Status
Łukasz Czyżykowski (community) Approve
Review via email: mp+26801@code.launchpad.net
To post a comment you must log in.
Revision history for this message
David Owen (dsowen) wrote :

(ready for review)

Revision history for this message
Łukasz Czyżykowski (lukasz-czyzykowski) wrote :

Looks OK

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'identityprovider/middleware/csrf.py'
2--- identityprovider/middleware/csrf.py 2010-04-21 15:29:24 +0000
3+++ identityprovider/middleware/csrf.py 2010-06-04 14:56:29 +0000
4@@ -19,6 +19,7 @@
5
6 from django.conf import settings
7 from django.http import HttpResponseForbidden
8+from django.template import loader
9 from django.utils.hashcompat import md5_constructor
10 from django.utils.safestring import mark_safe
11 from django.utils.translation import ugettext as _
12@@ -38,6 +39,9 @@
13 def _make_token(session_id):
14 return md5_constructor(settings.SECRET_KEY + session_id).hexdigest()
15
16+def _render_403():
17+ return HttpResponseForbidden(loader.render_to_string('403-csrf.html'))
18+
19
20 class CsrfViewMiddleware(object):
21 """
22@@ -63,10 +67,10 @@
23 try:
24 request_csrf_token = request.POST['csrfmiddlewaretoken']
25 except KeyError:
26- return HttpResponseForbidden(_ERROR_MSG)
27+ return _render_403()
28
29 if request_csrf_token != csrf_token:
30- return HttpResponseForbidden(_ERROR_MSG)
31+ return _render_403()
32
33 return None
34
35
36=== added file 'identityprovider/templates/403-csrf.html'
37--- identityprovider/templates/403-csrf.html 1970-01-01 00:00:00 +0000
38+++ identityprovider/templates/403-csrf.html 2010-06-04 14:56:29 +0000
39@@ -0,0 +1,21 @@
40+<!-- Copyright 2010 Canonical Ltd. This software is licensed under the
41+GNU Affero General Public License version 3 (see the file LICENSE). -->
42+
43+{% extends "base.html" %}
44+{% load i18n %}
45+
46+{% block "title" %}
47+ {% trans "Stale request" %}
48+{% endblock %}
49+
50+{% block "content" %}
51+ <div id="box">
52+ <h1 class="main">{% trans "Your page was stale." %}</h1>
53+ <div>
54+ <p>
55+ {% blocktrans %}Apologies, the page you came from was a little old. Perhaps you navigated here from a browser window other than the one you used to login. If so, try using the other browser window. Or, try your action again, starting from our home page.{% endblocktrans %}
56+ </p>
57+ <a class="btn alt" href="/"><span><span>{% trans "Go to our home page" %}</span></span></a>
58+ </div>
59+ </div>
60+{% endblock %}
61
62=== modified file 'identityprovider/tests/test_middleware.py'
63--- identityprovider/tests/test_middleware.py 2010-05-28 19:56:57 +0000
64+++ identityprovider/tests/test_middleware.py 2010-06-04 14:56:29 +0000
65@@ -5,9 +5,13 @@
66 import logging
67 import re
68 import sys
69+from urlparse import urlsplit
70
71 from django.conf import settings
72 from django.contrib.auth.models import User, AnonymousUser
73+from django.http import QueryDict
74+import django.test.client
75+
76 from identityprovider.tests.utils import (BasicAccountTestCase, MockRequest,
77 TestCase)
78 from identityprovider.models import EmailAddress, Account
79@@ -16,6 +20,14 @@
80 UserAccountConversionMiddleware)
81
82
83+def _extract_csrf_token(response):
84+ csrf_field = re.search('<input [^>]*name=[\'"]csrfmiddlewaretoken[\'"]'
85+ '[^>]*/>', response.content)
86+ return csrf_field and re.search(' value=[\'"]([^\'"]+)[\'"]',
87+ csrf_field.group()).group(1)
88+
89+
90+
91 class UserAccountConversionMiddlewareTestCase(BasicAccountTestCase):
92
93 def setUp(self):
94@@ -144,38 +156,102 @@
95 # The tests perform a login in order to acquire this session cookie.
96 class CSRFMiddlewareTestCase(BasicAccountTestCase):
97
98- def login(self):
99- r = self.client.post('/+login', {
100- 'email': 'mark@example.com',
101- 'password': 'test'})
102- self.assertTrue('sessionid' in self.client.cookies)
103-
104- def test_allow_authorized(self):
105- self.login()
106+ def _land(self, client=None):
107+ client = client or self.client
108+ r = client.get('/')
109+ return r, _extract_csrf_token(r)
110+
111+ def _login(self, client=None, csrf_token=None):
112+ client = client or self.client
113+ form = {'email': 'mark@example.com', 'password': 'test'}
114+ if csrf_token:
115+ form['csrfmiddlewaretoken'] = csrf_token
116+ r = client.post('/+login', form)
117+ self.assertTrue('sessionid' in client.cookies)
118+ return r
119+
120+ def _logout(self, client=None):
121+ client = client or self.client
122+ return client.get('/+logout')
123+
124+ def test_allow_with_token(self):
125+ self._login()
126 r = self.client.get('/')
127- csrf_field = re.search('<input [^>]*name=[\'"]csrfmiddlewaretoken[\'"][^>]*/>', r.content)
128- self.assertTrue(csrf_field)
129- csrf_token = re.search(' value=[\'"]([^\'"]+)[\'"]', csrf_field.group()).group(1)
130+ csrf_token = _extract_csrf_token(r)
131+ self.assertTrue(csrf_token)
132 r = self.client.post('/+edit', {
133 'displayname': 'Mark Shuttleworthy',
134 'csrfmiddlewaretoken': csrf_token
135 })
136 self.assertNotEquals(r.status_code, 403)
137
138- def test_forbid_unauthorized(self):
139- self.login()
140+ def test_forbid_without_token(self):
141+ self._login()
142 r = self.client.post('/+edit', {'displayname': 'Mark Shuttleworthy'})
143 self.assertEquals(r.status_code, 403)
144
145- def test_forbid_forged(self):
146- self.login()
147+ def test_forbid_with_forged_token(self):
148+ self._login()
149 r = self.client.post('/+edit', {
150 'displayname': 'Mark Shuttleworthy',
151 'csrfmiddlewaretoken': '0'})
152 self.assertEquals(r.status_code, 403)
153
154+ def test_forbid_with_stolen_token(self):
155+ self._login()
156+ token = 'a'
157+ r, token = self._land()
158+ self.assertTrue(token)
159+ # Get a new session, but don't invalidate the old session
160+ self.client.cookies.clear()
161+ self._login()
162+ r = self.client.post('/+edit', {
163+ 'displayname': 'Mark Shuttleworthy',
164+ 'csrfmiddlewaretoken': token})
165+ self.assertEquals(r.status_code, 403)
166+
167 def test_ajax(self):
168- self.login()
169+ self._login()
170 r = self.client.post('/+edit', {'displayname': 'Mark Shuttleworthy'},
171 HTTP_X_REQUESTED_WITH='XMLHttpRequest')
172 self.assertNotEquals(r.status_code, 403)
173+
174+ def _login_multiple_windows(self, start_with_old_cookie,
175+ logout_before_stale_login):
176+
177+ # Multiple windows actually share a single browser state.
178+ window1 = self.client
179+ window2 = self.client
180+
181+ if start_with_old_cookie:
182+ r1, csrf_token1 = self._land(window1)
183+ r1 = self._login(window1, csrf_token1)
184+ r1 = self._logout(window1)
185+
186+ r1, csrf_token1 = self._land(window1)
187+
188+ r2, csrf_token2 = self._land(window2)
189+ r2 = self._login(window2, csrf_token2)
190+
191+ if logout_before_stale_login:
192+ r2 = self._logout(window2)
193+
194+ r1 = self._login(window1, r1)
195+ self.assertTrue(r1.status_code != 403)
196+
197+ def test_multiple_windows_no_cookie(self):
198+ self._login_multiple_windows(start_with_old_cookie=False,
199+ logout_before_stale_login=False)
200+
201+ def test_multiple_windows_old_cookie(self):
202+ self._login_multiple_windows(start_with_old_cookie=True,
203+ logout_before_stale_login=False)
204+
205+
206+ def test_multiple_windows_no_cookie_logged_out(self):
207+ self._login_multiple_windows(start_with_old_cookie=False,
208+ logout_before_stale_login=True)
209+
210+ def test_multiple_windows_old_cookie_logged_out(self):
211+ self._login_multiple_windows(start_with_old_cookie=True,
212+ logout_before_stale_login=True)
213
214=== modified file 'identityprovider/views/ui.py'
215--- identityprovider/views/ui.py 2010-06-01 15:39:30 +0000
216+++ identityprovider/views/ui.py 2010-06-04 14:56:29 +0000
217@@ -32,13 +32,15 @@
218 format_address, get_person_and_account_by_email, polite_form_errors,
219 CannotResetPasswordException, PersonAndAccountNotFoundException)
220 from identityprovider.decorators import check_readonly
221+from identityprovider.middleware.csrf import csrf_exempt
222+from identityprovider.models.captcha import Captcha
223 from identityprovider.views.utils import get_rpconfig
224-from identityprovider.models.captcha import Captcha
225
226
227 logger = logging.getLogger('sso')
228
229
230+@csrf_exempt
231 @guest_required
232 @dont_cache
233 @limitlogin()