File: 21_fix_redirect_poisoning.diff

package info (click to toggle)
python-django 1.2.3-3%2Bsqueeze15
  • links: PTS, VCS
  • area: main
  • in suites: squeeze-lts
  • size: 29,720 kB
  • ctags: 21,538
  • sloc: python: 101,631; xml: 574; makefile: 149; sh: 121; sql: 7
file content (413 lines) | stat: -rw-r--r-- 17,155 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
Origin: backport, commit:1515eb46daa0897ba5ad5f0a2db8969255f1b343
Description: Ensure that redirects can't be poisoned by malicious users
Bug: https://code.djangoproject.com/ticket/18856
Bug-Debian: http://bugs.debian.org/696535

--- a/django/contrib/auth/views.py
+++ b/django/contrib/auth/views.py
@@ -13,7 +13,7 @@ from django.shortcuts import render_to_r
 from django.contrib.sites.models import Site, RequestSite
 from django.http import HttpResponseRedirect, Http404
 from django.template import RequestContext
-from django.utils.http import urlquote, base36_to_int
+from django.utils.http import urlquote, base36_to_int, is_safe_url
 from django.utils.translation import ugettext as _
 from django.contrib.auth.models import User
 from django.views.decorators.cache import never_cache
@@ -30,18 +30,11 @@ def login(request, template_name='regist
     if request.method == "POST":
         form = authentication_form(data=request.POST)
         if form.is_valid():
-            # Light security check -- make sure redirect_to isn't garbage.
-            if not redirect_to or ' ' in redirect_to:
-                redirect_to = settings.LOGIN_REDIRECT_URL
-            
-            # Heavier security check -- redirects to http://example.com should 
-            # not be allowed, but things like /view/?param=http://example.com 
-            # should be allowed. This regex checks if there is a '//' *before* a
-            # question mark.
-            elif '//' in redirect_to and re.match(r'[^\?]*//', redirect_to):
+            # Ensure the user-originating redirection url is safe.
+            if not is_safe_url(url=redirect_to, host=request.get_host()):
                     redirect_to = settings.LOGIN_REDIRECT_URL
             
-            # Okay, security checks complete. Log the user in.
+            # Okay, security check complete. Log the user in.
             auth_login(request, form.get_user())
 
             if request.session.test_cookie_worked():
@@ -70,17 +63,29 @@ def logout(request, next_page=None, temp
     "Logs out the user and displays 'You are logged out' message."
     from django.contrib.auth import logout
     logout(request)
-    if next_page is None:
-        redirect_to = request.REQUEST.get(redirect_field_name, '')
-        if redirect_to:
-            return HttpResponseRedirect(redirect_to)
-        else:
-            return render_to_response(template_name, {
-                'title': _('Logged out')
-            }, context_instance=RequestContext(request))
-    else:
+
+    if redirect_field_name in request.REQUEST:
+        next_page = request.REQUEST[redirect_field_name]
+        # Security check -- don't allow redirection to a different host.
+        if not is_safe_url(url=next_page, host=request.get_host()):
+            next_page = request.path
+
+    if next_page:
         # Redirect to this page until the session has been cleared.
-        return HttpResponseRedirect(next_page or request.path)
+        return HttpResponseRedirect(next_page)
+
+    if Site._meta.installed:
+        current_site = Site.objects.get_current()
+    else:
+        current_site = RequestSite(request)
+
+    context = {
+        'site': current_site,
+        'site_name': current_site.name,
+        'title': _('Logged out')
+    }
+    return render_to_response(template_name, context,
+                              context_instance=RequestContext(request))
 
 def logout_then_login(request, login_url=None):
     "Logs out the user if he is logged in. Then redirects to the log-in page."
--- a/django/contrib/comments/views/comments.py
+++ b/django/contrib/comments/views/comments.py
@@ -40,9 +40,6 @@ def post_comment(request, next=None, usi
         if not data.get('email', ''):
             data["email"] = request.user.email
 
-    # Check to see if the POST data overrides the view's next argument.
-    next = data.get("next", next)
-
     # Look up the object we're trying to comment about
     ctype = data.get("content_type")
     object_pk = data.get("object_pk")
@@ -94,9 +91,9 @@ def post_comment(request, next=None, usi
         ]
         return render_to_response(
             template_list, {
-                "comment" : form.data.get("comment", ""),
-                "form" : form,
-                "next": next,
+                "comment": form.data.get("comment", ""),
+                "form": form,
+                "next": data.get("next", next),
             },
             RequestContext(request, {})
         )
@@ -127,7 +124,7 @@ def post_comment(request, next=None, usi
         request = request
     )
 
-    return next_redirect(data, next, comment_done, c=comment._get_pk_val())
+    return next_redirect(request, next, comment_done, c=comment._get_pk_val())
 
 comment_done = confirmation_view(
     template = "comments/posted.html",
--- a/django/contrib/comments/views/moderation.py
+++ b/django/contrib/comments/views/moderation.py
@@ -7,6 +7,7 @@ from django.contrib import comments
 from django.contrib.comments import signals
 from django.views.decorators.csrf import csrf_protect
 
+
 @csrf_protect
 @login_required
 def flag(request, comment_id, next=None):
@@ -23,7 +24,7 @@ def flag(request, comment_id, next=None)
     # Flag on POST
     if request.method == 'POST':
         perform_flag(request, comment)
-        return next_redirect(request.POST.copy(), next, flag_done, c=comment.pk)
+        return next_redirect(request, next, flag_done, c=comment.pk)
 
     # Render a form on GET
     else:
@@ -50,7 +51,7 @@ def delete(request, comment_id, next=Non
     if request.method == 'POST':
         # Flag the comment as deleted instead of actually deleting it.
         perform_delete(request, comment)
-        return next_redirect(request.POST.copy(), next, delete_done, c=comment.pk)
+        return next_redirect(request, next, delete_done, c=comment.pk)
 
     # Render a form on GET
     else:
@@ -77,7 +78,7 @@ def approve(request, comment_id, next=No
     if request.method == 'POST':
         # Flag the comment as approved.
         perform_approve(request, comment)
-        return next_redirect(request.POST.copy(), next, approve_done, c=comment.pk)
+        return next_redirect(request, next, approve_done, c=comment.pk)
 
     # Render a form on GET
     else:
--- a/django/contrib/comments/views/utils.py
+++ b/django/contrib/comments/views/utils.py
@@ -4,14 +4,15 @@ A few bits of helper functions for comme
 
 import urllib
 import textwrap
-from django.http import HttpResponseRedirect
 from django.core import urlresolvers
+from django.http import HttpResponseRedirect
 from django.shortcuts import render_to_response
 from django.template import RequestContext
 from django.core.exceptions import ObjectDoesNotExist
 from django.contrib import comments
+from django.utils.http import is_safe_url
 
-def next_redirect(data, default, default_view, **get_kwargs):
+def next_redirect(request, default, default_view, **get_kwargs):
     """
     Handle the "where should I go next?" part of comment views.
 
@@ -21,9 +22,10 @@ def next_redirect(data, default, default
 
     Returns an ``HttpResponseRedirect``.
     """
-    next = data.get("next", default)
-    if next is None:
+    next = request.POST.get('next', default)
+    if not is_safe_url(url=next, host=request.get_host()):
         next = urlresolvers.reverse(default_view)
+
     if get_kwargs:
         joiner = ('?' in next) and '&' or '?'
         next += joiner + urllib.urlencode(get_kwargs)
--- a/django/utils/http.py
+++ b/django/utils/http.py
@@ -1,5 +1,6 @@
 import re
 import urllib
+import urlparse
 from email.Utils import formatdate
 
 from django.utils.encoding import smart_str, force_unicode
@@ -122,3 +123,15 @@ def quote_etag(etag):
     """
     return '"%s"' % etag.replace('\\', '\\\\').replace('"', '\\"')
 
+def is_safe_url(url, host=None):
+    """
+    Return ``True`` if the url is a safe redirection (i.e. it doesn't point to
+    a different host).
+
+    Always returns ``False`` on an empty url.
+    """
+    if not url:
+        return False
+    netloc = urlparse.urlparse(url)[1]
+    return not netloc or netloc == host
+
--- a/django/views/i18n.py
+++ b/django/views/i18n.py
@@ -8,6 +8,7 @@ from django.utils.translation import che
 from django.utils.text import javascript_quote
 from django.utils.encoding import smart_unicode
 from django.utils.formats import get_format_modules
+from django.utils.http import is_safe_url
 
 def set_language(request):
     """
@@ -20,11 +21,11 @@ def set_language(request):
     redirect to the page in the request (the 'next' parameter) without changing
     any state.
     """
-    next = request.REQUEST.get('next', None)
-    if not next:
-        next = request.META.get('HTTP_REFERER', None)
-    if not next:
-        next = '/'
+    next = request.REQUEST.get('next')
+    if not is_safe_url(url=next, host=request.get_host()):
+        next = request.META.get('HTTP_REFERER')
+        if not is_safe_url(url=next, host=request.get_host()):
+            next = '/'
     response = http.HttpResponseRedirect(next)
     if request.method == 'POST':
         lang_code = request.POST.get('language', None)
--- a/tests/regressiontests/comment_tests/tests/comment_view_tests.py
+++ b/tests/regressiontests/comment_tests/tests/comment_view_tests.py
@@ -212,6 +212,13 @@ class CommentViewTests(CommentTestCase):
         match = re.search(r"^http://testserver/somewhere/else/\?c=\d+$", location)
         self.failUnless(match != None, "Unexpected redirect location: %s" % location)
 
+        data["next"] = "http://badserver/somewhere/else/"
+        data["comment"] = "This is another comment with an unsafe next url"
+        response = self.client.post("/post/", data)
+        location = response["Location"]
+        match = post_redirect_re.match(location)
+        self.assertTrue(match != None, "Unsafe redirection to: %s" % location)
+
     def testCommentDoneView(self):
         a = Article.objects.get(pk=1)
         data = self.getValidData(a)
--- a/tests/regressiontests/comment_tests/tests/moderation_view_tests.py
+++ b/tests/regressiontests/comment_tests/tests/moderation_view_tests.py
@@ -25,6 +25,30 @@ class FlagViewTests(CommentTestCase):
         self.assertEqual(c.flags.filter(flag=CommentFlag.SUGGEST_REMOVAL).count(), 1)
         return c
 
+    def testFlagPostNext(self):
+        """
+        POST the flag view, explicitly providing a next url.
+        """
+        comments = self.createSomeComments()
+        pk = comments[0].pk
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.post("/flag/%d/" % pk, {'next': "/go/here/"})
+        self.assertEqual(response["Location"],
+            "http://testserver/go/here/?c=1")
+
+    def testFlagPostUnsafeNext(self):
+        """
+        POSTing to the flag view with an unsafe next url will ignore the
+        provided url when redirecting.
+        """
+        comments = self.createSomeComments()
+        pk = comments[0].pk
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.post("/flag/%d/" % pk,
+            {'next': "http://elsewhere/bad"})
+        self.assertEqual(response["Location"],
+            "http://testserver/flagged/?c=%d" % pk)
+
     def testFlagPostTwice(self):
         """Users don't get to flag comments more than once."""
         c = self.testFlagPost()
@@ -44,7 +68,7 @@ class FlagViewTests(CommentTestCase):
     def testFlaggedView(self):
         comments = self.createSomeComments()
         pk = comments[0].pk        
-        response = self.client.get("/flagged/", data={"c":pk})
+        response = self.client.get("/flagged/", data={"c": pk})
         self.assertTemplateUsed(response, "comments/flagged.html")
 
     def testFlagSignals(self):
@@ -96,6 +120,33 @@ class DeleteViewTests(CommentTestCase):
         self.failUnless(c.is_removed)
         self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_DELETION, user__username="normaluser").count(), 1)
 
+    def testDeletePostNext(self):
+        """
+        POSTing the delete view will redirect to an explicitly provided a next
+        url.
+        """
+        comments = self.createSomeComments()
+        pk = comments[0].pk
+        makeModerator("normaluser")
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.post("/delete/%d/" % pk, {'next': "/go/here/"})
+        self.assertEqual(response["Location"],
+            "http://testserver/go/here/?c=1")
+
+    def testDeletePostUnsafeNext(self):
+        """
+        POSTing to the delete view with an unsafe next url will ignore the
+        provided url when redirecting.
+        """
+        comments = self.createSomeComments()
+        pk = comments[0].pk
+        makeModerator("normaluser")
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.post("/delete/%d/" % pk,
+            {'next': "http://elsewhere/bad"})
+        self.assertEqual(response["Location"],
+            "http://testserver/deleted/?c=%d" % pk)
+
     def testDeleteSignals(self):
         def receive(sender, **kwargs):
             received_signals.append(kwargs.get('signal'))
@@ -117,7 +168,7 @@ class DeleteViewTests(CommentTestCase):
 class ApproveViewTests(CommentTestCase):
 
     def testApprovePermissions(self):
-        """The delete view should only be accessible to 'moderators'"""
+        """The approve view should only be accessible to 'moderators'"""
         comments = self.createSomeComments()
         pk = comments[0].pk        
         self.client.login(username="normaluser", password="normaluser")
@@ -129,7 +180,7 @@ class ApproveViewTests(CommentTestCase):
         self.assertEqual(response.status_code, 200)
 
     def testApprovePost(self):
-        """POSTing the delete view should mark the comment as removed"""
+        """POSTing the approve view should mark the comment as removed"""
         c1, c2, c3, c4 = self.createSomeComments()
         c1.is_public = False; c1.save()
 
@@ -141,6 +192,36 @@ class ApproveViewTests(CommentTestCase):
         self.failUnless(c.is_public)
         self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_APPROVAL, user__username="normaluser").count(), 1)
 
+    def testApprovePostNext(self):
+        """
+        POSTing the approve view will redirect to an explicitly provided a next
+        url.
+        """
+        c1, c2, c3, c4 = self.createSomeComments()
+        c1.is_public = False; c1.save()
+
+        makeModerator("normaluser")
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.post("/approve/%d/" % c1.pk,
+            {'next': "/go/here/"})
+        self.assertEqual(response["Location"],
+            "http://testserver/go/here/?c=1")
+
+    def testApprovePostUnsafeNext(self):
+        """
+        POSTing to the approve view with an unsafe next url will ignore the
+        provided url when redirecting.
+        """
+        c1, c2, c3, c4 = self.createSomeComments()
+        c1.is_public = False; c1.save()
+
+        makeModerator("normaluser")
+        self.client.login(username="normaluser", password="normaluser")
+        response = self.client.post("/approve/%d/" % c1.pk,
+            {'next': "http://elsewhere/bad"})
+        self.assertEqual(response["Location"],
+            "http://testserver/approved/?c=%d" % c1.pk)
+
     def testApproveSignals(self):
         def receive(sender, **kwargs):
             received_signals.append(kwargs.get('signal'))
--- a/tests/regressiontests/views/tests/i18n.py
+++ b/tests/regressiontests/views/tests/i18n.py
@@ -12,13 +12,28 @@ class I18NTests(TestCase):
     """ Tests django views in django/views/i18n.py """
 
     def test_setlang(self):
-        """The set_language view can be used to change the session language"""
+        """
+        The set_language view can be used to change the session language
+
+        The user is redirected to the 'next' argument if provided.
+        """
         for lang_code, lang_name in settings.LANGUAGES:
             post_data = dict(language=lang_code, next='/views/')
             response = self.client.post('/views/i18n/setlang/', data=post_data)
             self.assertRedirects(response, 'http://testserver/views/')
             self.assertEquals(self.client.session['django_language'], lang_code)
 
+    def test_setlang_unsafe_next(self):
+        """
+        The set_language view only redirects to the 'next' argument if it is
+        "safe".
+        """
+        lang_code, lang_name = settings.LANGUAGES[0]
+        post_data = dict(language=lang_code, next='//unsafe/redirection/')
+        response = self.client.post('/views/i18n/setlang/', data=post_data)
+        self.assertEqual(response['Location'], 'http://testserver/')
+        self.assertEqual(self.client.session['django_language'], lang_code)
+
     def test_jsi18n(self):
         """The javascript_catalog can be deployed with language settings"""
         for lang_code in ['es', 'fr', 'ru']: