Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit b2ae0a6

Browse files
committed
[1.4.X] Fixed #18856 -- Ensured that redirects can't be poisoned by malicious users.
1 parent 8c9a8fd commit b2ae0a6

9 files changed

Lines changed: 162 additions & 54 deletions

File tree

django/contrib/auth/views.py

Lines changed: 22 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.core.urlresolvers import reverse
55
from django.http import HttpResponseRedirect, QueryDict
66
from django.template.response import TemplateResponse
7-
from django.utils.http import base36_to_int
7+
from django.utils.http import base36_to_int, is_safe_url
88
from django.utils.translation import ugettext as _
99
from django.views.decorators.debug import sensitive_post_parameters
1010
from django.views.decorators.cache import never_cache
@@ -34,18 +34,11 @@ def login(request, template_name='registration/login.html',
3434
if request.method == "POST":
3535
form = authentication_form(data=request.POST)
3636
if form.is_valid():
37-
netloc = urlparse.urlparse(redirect_to)[1]
38-
39-
# Use default setting if redirect_to is empty
40-
if not redirect_to:
41-
redirect_to = settings.LOGIN_REDIRECT_URL
42-
43-
# Heavier security check -- don't allow redirection to a different
44-
# host.
45-
elif netloc and netloc != request.get_host():
37+
# Ensure the user-originating redirection url is safe.
38+
if not is_safe_url(url=redirect_to, host=request.get_host()):
4639
redirect_to = settings.LOGIN_REDIRECT_URL
4740

48-
# Okay, security checks complete. Log the user in.
41+
# Okay, security check complete. Log the user in.
4942
auth_login(request, form.get_user())
5043

5144
if request.session.test_cookie_worked():
@@ -78,27 +71,27 @@ def logout(request, next_page=None,
7871
Logs out the user and displays 'You are logged out' message.
7972
"""
8073
auth_logout(request)
81-
redirect_to = request.REQUEST.get(redirect_field_name, '')
82-
if redirect_to:
83-
netloc = urlparse.urlparse(redirect_to)[1]
74+
75+
if redirect_field_name in request.REQUEST:
76+
next_page = request.REQUEST[redirect_field_name]
8477
# Security check -- don't allow redirection to a different host.
85-
if not (netloc and netloc != request.get_host()):
86-
return HttpResponseRedirect(redirect_to)
78+
if not is_safe_url(url=next_page, host=request.get_host()):
79+
next_page = request.path
8780

88-
if next_page is None:
89-
current_site = get_current_site(request)
90-
context = {
91-
'site': current_site,
92-
'site_name': current_site.name,
93-
'title': _('Logged out')
94-
}
95-
if extra_context is not None:
96-
context.update(extra_context)
97-
return TemplateResponse(request, template_name, context,
98-
current_app=current_app)
99-
else:
81+
if next_page:
10082
# Redirect to this page until the session has been cleared.
101-
return HttpResponseRedirect(next_page or request.path)
83+
return HttpResponseRedirect(next_page)
84+
85+
current_site = get_current_site(request)
86+
context = {
87+
'site': current_site,
88+
'site_name': current_site.name,
89+
'title': _('Logged out')
90+
}
91+
if extra_context is not None:
92+
context.update(extra_context)
93+
return TemplateResponse(request, template_name, context,
94+
current_app=current_app)
10295

10396
def logout_then_login(request, login_url=None, current_app=None, extra_context=None):
10497
"""

django/contrib/comments/views/comments.py

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,6 @@ def post_comment(request, next=None, using=None):
4444
if not data.get('email', ''):
4545
data["email"] = request.user.email
4646

47-
# Check to see if the POST data overrides the view's next argument.
48-
next = data.get("next", next)
49-
5047
# Look up the object we're trying to comment about
5148
ctype = data.get("content_type")
5249
object_pk = data.get("object_pk")
@@ -98,9 +95,9 @@ def post_comment(request, next=None, using=None):
9895
]
9996
return render_to_response(
10097
template_list, {
101-
"comment" : form.data.get("comment", ""),
102-
"form" : form,
103-
"next": next,
98+
"comment": form.data.get("comment", ""),
99+
"form": form,
100+
"next": data.get("next", next),
104101
},
105102
RequestContext(request, {})
106103
)
@@ -131,7 +128,7 @@ def post_comment(request, next=None, using=None):
131128
request = request
132129
)
133130

134-
return next_redirect(data, next, comment_done, c=comment._get_pk_val())
131+
return next_redirect(request, next, comment_done, c=comment._get_pk_val())
135132

136133
comment_done = confirmation_view(
137134
template = "comments/posted.html",

django/contrib/comments/views/moderation.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from django.views.decorators.csrf import csrf_protect
1111

1212

13-
1413
@csrf_protect
1514
@login_required
1615
def flag(request, comment_id, next=None):
@@ -27,7 +26,7 @@ def flag(request, comment_id, next=None):
2726
# Flag on POST
2827
if request.method == 'POST':
2928
perform_flag(request, comment)
30-
return next_redirect(request.POST.copy(), next, flag_done, c=comment.pk)
29+
return next_redirect(request, next, flag_done, c=comment.pk)
3130

3231
# Render a form on GET
3332
else:
@@ -54,7 +53,7 @@ def delete(request, comment_id, next=None):
5453
if request.method == 'POST':
5554
# Flag the comment as deleted instead of actually deleting it.
5655
perform_delete(request, comment)
57-
return next_redirect(request.POST.copy(), next, delete_done, c=comment.pk)
56+
return next_redirect(request, next, delete_done, c=comment.pk)
5857

5958
# Render a form on GET
6059
else:
@@ -81,7 +80,7 @@ def approve(request, comment_id, next=None):
8180
if request.method == 'POST':
8281
# Flag the comment as approved.
8382
perform_approve(request, comment)
84-
return next_redirect(request.POST.copy(), next, approve_done, c=comment.pk)
83+
return next_redirect(request, next, approve_done, c=comment.pk)
8584

8685
# Render a form on GET
8786
else:

django/contrib/comments/views/utils.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44

55
import urllib
66
import textwrap
7-
from django.http import HttpResponseRedirect
87
from django.core import urlresolvers
8+
from django.http import HttpResponseRedirect
99
from django.shortcuts import render_to_response
1010
from django.template import RequestContext
1111
from django.core.exceptions import ObjectDoesNotExist
1212
from django.contrib import comments
13+
from django.utils.http import is_safe_url
1314

14-
def next_redirect(data, default, default_view, **get_kwargs):
15+
def next_redirect(request, default, default_view, **get_kwargs):
1516
"""
1617
Handle the "where should I go next?" part of comment views.
1718
@@ -21,9 +22,10 @@ def next_redirect(data, default, default_view, **get_kwargs):
2122
2223
Returns an ``HttpResponseRedirect``.
2324
"""
24-
next = data.get("next", default)
25-
if next is None:
25+
next = request.POST.get('next', default)
26+
if not is_safe_url(url=next, host=request.get_host()):
2627
next = urlresolvers.reverse(default_view)
28+
2729
if get_kwargs:
2830
if '#' in next:
2931
tmp = next.rsplit('#', 1)

django/utils/http.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,15 @@ def same_origin(url1, url2):
224224
"""
225225
p1, p2 = urlparse.urlparse(url1), urlparse.urlparse(url2)
226226
return p1[0:2] == p2[0:2]
227+
228+
def is_safe_url(url, host=None):
229+
"""
230+
Return ``True`` if the url is a safe redirection (i.e. it doesn't point to
231+
a different host).
232+
233+
Always returns ``False`` on an empty url.
234+
"""
235+
if not url:
236+
return False
237+
netloc = urlparse.urlparse(url)[1]
238+
return not netloc or netloc == host

django/views/i18n.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from django.utils.text import javascript_quote
99
from django.utils.encoding import smart_unicode
1010
from django.utils.formats import get_format_modules, get_format
11+
from django.utils.http import is_safe_url
12+
1113

1214
def set_language(request):
1315
"""
@@ -20,11 +22,11 @@ def set_language(request):
2022
redirect to the page in the request (the 'next' parameter) without changing
2123
any state.
2224
"""
23-
next = request.REQUEST.get('next', None)
24-
if not next:
25-
next = request.META.get('HTTP_REFERER', None)
26-
if not next:
27-
next = '/'
25+
next = request.REQUEST.get('next')
26+
if not is_safe_url(url=next, host=request.get_host()):
27+
next = request.META.get('HTTP_REFERER')
28+
if not is_safe_url(url=next, host=request.get_host()):
29+
next = '/'
2830
response = http.HttpResponseRedirect(next)
2931
if request.method == 'POST':
3032
lang_code = request.POST.get('language', None)

tests/regressiontests/comment_tests/tests/comment_view_tests.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,13 @@ def testCommentNext(self):
222222
match = re.search(r"^http://testserver/somewhere/else/\?c=\d+$", location)
223223
self.assertTrue(match != None, "Unexpected redirect location: %s" % location)
224224

225+
data["next"] = "http://badserver/somewhere/else/"
226+
data["comment"] = "This is another comment with an unsafe next url"
227+
response = self.client.post("/post/", data)
228+
location = response["Location"]
229+
match = post_redirect_re.match(location)
230+
self.assertTrue(match != None, "Unsafe redirection to: %s" % location)
231+
225232
def testCommentDoneView(self):
226233
a = Article.objects.get(pk=1)
227234
data = self.getValidData(a)

tests/regressiontests/comment_tests/tests/moderation_view_tests.py

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,30 @@ def testFlagPost(self):
2929
self.assertEqual(c.flags.filter(flag=CommentFlag.SUGGEST_REMOVAL).count(), 1)
3030
return c
3131

32+
def testFlagPostNext(self):
33+
"""
34+
POST the flag view, explicitly providing a next url.
35+
"""
36+
comments = self.createSomeComments()
37+
pk = comments[0].pk
38+
self.client.login(username="normaluser", password="normaluser")
39+
response = self.client.post("/flag/%d/" % pk, {'next': "/go/here/"})
40+
self.assertEqual(response["Location"],
41+
"http://testserver/go/here/?c=1")
42+
43+
def testFlagPostUnsafeNext(self):
44+
"""
45+
POSTing to the flag view with an unsafe next url will ignore the
46+
provided url when redirecting.
47+
"""
48+
comments = self.createSomeComments()
49+
pk = comments[0].pk
50+
self.client.login(username="normaluser", password="normaluser")
51+
response = self.client.post("/flag/%d/" % pk,
52+
{'next': "http://elsewhere/bad"})
53+
self.assertEqual(response["Location"],
54+
"http://testserver/flagged/?c=%d" % pk)
55+
3256
def testFlagPostTwice(self):
3357
"""Users don't get to flag comments more than once."""
3458
c = self.testFlagPost()
@@ -48,7 +72,7 @@ def testFlagAnon(self):
4872
def testFlaggedView(self):
4973
comments = self.createSomeComments()
5074
pk = comments[0].pk
51-
response = self.client.get("/flagged/", data={"c":pk})
75+
response = self.client.get("/flagged/", data={"c": pk})
5276
self.assertTemplateUsed(response, "comments/flagged.html")
5377

5478
def testFlagSignals(self):
@@ -100,6 +124,33 @@ def testDeletePost(self):
100124
self.assertTrue(c.is_removed)
101125
self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_DELETION, user__username="normaluser").count(), 1)
102126

127+
def testDeletePostNext(self):
128+
"""
129+
POSTing the delete view will redirect to an explicitly provided a next
130+
url.
131+
"""
132+
comments = self.createSomeComments()
133+
pk = comments[0].pk
134+
makeModerator("normaluser")
135+
self.client.login(username="normaluser", password="normaluser")
136+
response = self.client.post("/delete/%d/" % pk, {'next': "/go/here/"})
137+
self.assertEqual(response["Location"],
138+
"http://testserver/go/here/?c=1")
139+
140+
def testDeletePostUnsafeNext(self):
141+
"""
142+
POSTing to the delete view with an unsafe next url will ignore the
143+
provided url when redirecting.
144+
"""
145+
comments = self.createSomeComments()
146+
pk = comments[0].pk
147+
makeModerator("normaluser")
148+
self.client.login(username="normaluser", password="normaluser")
149+
response = self.client.post("/delete/%d/" % pk,
150+
{'next': "http://elsewhere/bad"})
151+
self.assertEqual(response["Location"],
152+
"http://testserver/deleted/?c=%d" % pk)
153+
103154
def testDeleteSignals(self):
104155
def receive(sender, **kwargs):
105156
received_signals.append(kwargs.get('signal'))
@@ -115,13 +166,13 @@ def receive(sender, **kwargs):
115166
def testDeletedView(self):
116167
comments = self.createSomeComments()
117168
pk = comments[0].pk
118-
response = self.client.get("/deleted/", data={"c":pk})
169+
response = self.client.get("/deleted/", data={"c": pk})
119170
self.assertTemplateUsed(response, "comments/deleted.html")
120171

121172
class ApproveViewTests(CommentTestCase):
122173

123174
def testApprovePermissions(self):
124-
"""The delete view should only be accessible to 'moderators'"""
175+
"""The approve view should only be accessible to 'moderators'"""
125176
comments = self.createSomeComments()
126177
pk = comments[0].pk
127178
self.client.login(username="normaluser", password="normaluser")
@@ -133,7 +184,7 @@ def testApprovePermissions(self):
133184
self.assertEqual(response.status_code, 200)
134185

135186
def testApprovePost(self):
136-
"""POSTing the delete view should mark the comment as removed"""
187+
"""POSTing the approve view should mark the comment as removed"""
137188
c1, c2, c3, c4 = self.createSomeComments()
138189
c1.is_public = False; c1.save()
139190

@@ -145,6 +196,36 @@ def testApprovePost(self):
145196
self.assertTrue(c.is_public)
146197
self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_APPROVAL, user__username="normaluser").count(), 1)
147198

199+
def testApprovePostNext(self):
200+
"""
201+
POSTing the approve view will redirect to an explicitly provided a next
202+
url.
203+
"""
204+
c1, c2, c3, c4 = self.createSomeComments()
205+
c1.is_public = False; c1.save()
206+
207+
makeModerator("normaluser")
208+
self.client.login(username="normaluser", password="normaluser")
209+
response = self.client.post("/approve/%d/" % c1.pk,
210+
{'next': "/go/here/"})
211+
self.assertEqual(response["Location"],
212+
"http://testserver/go/here/?c=1")
213+
214+
def testApprovePostUnsafeNext(self):
215+
"""
216+
POSTing to the approve view with an unsafe next url will ignore the
217+
provided url when redirecting.
218+
"""
219+
c1, c2, c3, c4 = self.createSomeComments()
220+
c1.is_public = False; c1.save()
221+
222+
makeModerator("normaluser")
223+
self.client.login(username="normaluser", password="normaluser")
224+
response = self.client.post("/approve/%d/" % c1.pk,
225+
{'next': "http://elsewhere/bad"})
226+
self.assertEqual(response["Location"],
227+
"http://testserver/approved/?c=%d" % c1.pk)
228+
148229
def testApproveSignals(self):
149230
def receive(sender, **kwargs):
150231
received_signals.append(kwargs.get('signal'))

tests/regressiontests/views/tests/i18n.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,28 @@ class I18NTests(TestCase):
1616
""" Tests django views in django/views/i18n.py """
1717

1818
def test_setlang(self):
19-
"""The set_language view can be used to change the session language"""
19+
"""
20+
The set_language view can be used to change the session language.
21+
22+
The user is redirected to the 'next' argument if provided.
23+
"""
2024
for lang_code, lang_name in settings.LANGUAGES:
2125
post_data = dict(language=lang_code, next='/views/')
2226
response = self.client.post('/views/i18n/setlang/', data=post_data)
2327
self.assertRedirects(response, 'http://testserver/views/')
2428
self.assertEqual(self.client.session['django_language'], lang_code)
2529

30+
def test_setlang_unsafe_next(self):
31+
"""
32+
The set_language view only redirects to the 'next' argument if it is
33+
"safe".
34+
"""
35+
lang_code, lang_name = settings.LANGUAGES[0]
36+
post_data = dict(language=lang_code, next='//unsafe/redirection/')
37+
response = self.client.post('/views/i18n/setlang/', data=post_data)
38+
self.assertEqual(response['Location'], 'http://testserver/')
39+
self.assertEqual(self.client.session['django_language'], lang_code)
40+
2641
def test_jsi18n(self):
2742
"""The javascript_catalog can be deployed with language settings"""
2843
saved_lang = get_language()

0 commit comments

Comments
 (0)