Skip to content

Commit 1515eb4

Browse files
committed
[1.3.X] Fixed django#18856 -- Ensured that redirects can't be poisoned by malicious users.
1 parent 6383d23 commit 1515eb4

File tree

9 files changed

+163
-52
lines changed

9 files changed

+163
-52
lines changed

django/contrib/auth/views.py

Lines changed: 22 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from django.http import HttpResponseRedirect, QueryDict
66
from django.shortcuts import render_to_response
77
from django.template import RequestContext
8-
from django.utils.http import base36_to_int
8+
from django.utils.http import base36_to_int, is_safe_url
99
from django.utils.translation import ugettext as _
1010
from django.views.decorators.cache import never_cache
1111
from django.views.decorators.csrf import csrf_protect
@@ -33,18 +33,11 @@ def login(request, template_name='registration/login.html',
3333
if request.method == "POST":
3434
form = authentication_form(data=request.POST)
3535
if form.is_valid():
36-
netloc = urlparse.urlparse(redirect_to)[1]
37-
38-
# Use default setting if redirect_to is empty
39-
if not redirect_to:
40-
redirect_to = settings.LOGIN_REDIRECT_URL
41-
42-
# Security check -- don't allow redirection to a different
43-
# host.
44-
elif netloc and netloc != request.get_host():
36+
# Ensure the user-originating redirection url is safe.
37+
if not is_safe_url(url=redirect_to, host=request.get_host()):
4538
redirect_to = settings.LOGIN_REDIRECT_URL
4639

47-
# Okay, security checks complete. Log the user in.
40+
# Okay, security check complete. Log the user in.
4841
auth_login(request, form.get_user())
4942

5043
if request.session.test_cookie_worked():
@@ -76,26 +69,27 @@ def logout(request, next_page=None,
7669
Logs out the user and displays 'You are logged out' message.
7770
"""
7871
auth_logout(request)
79-
redirect_to = request.REQUEST.get(redirect_field_name, '')
80-
if redirect_to:
81-
netloc = urlparse.urlparse(redirect_to)[1]
72+
73+
if redirect_field_name in request.REQUEST:
74+
next_page = request.REQUEST[redirect_field_name]
8275
# Security check -- don't allow redirection to a different host.
83-
if not (netloc and netloc != request.get_host()):
84-
return HttpResponseRedirect(redirect_to)
76+
if not is_safe_url(url=next_page, host=request.get_host()):
77+
next_page = request.path
8578

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

10094
def logout_then_login(request, login_url=None, current_app=None, extra_context=None):
10195
"""

django/contrib/comments/views/comments.py

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

43-
# Check to see if the POST data overrides the view's next argument.
44-
next = data.get("next", next)
45-
4643
# Look up the object we're trying to comment about
4744
ctype = data.get("content_type")
4845
object_pk = data.get("object_pk")
@@ -94,9 +91,9 @@ def post_comment(request, next=None, using=None):
9491
]
9592
return render_to_response(
9693
template_list, {
97-
"comment" : form.data.get("comment", ""),
98-
"form" : form,
99-
"next": next,
94+
"comment": form.data.get("comment", ""),
95+
"form": form,
96+
"next": data.get("next", next),
10097
},
10198
RequestContext(request, {})
10299
)
@@ -127,7 +124,7 @@ def post_comment(request, next=None, using=None):
127124
request = request
128125
)
129126

130-
return next_redirect(data, next, comment_done, c=comment._get_pk_val())
127+
return next_redirect(request, next, comment_done, c=comment._get_pk_val())
131128

132129
comment_done = confirmation_view(
133130
template = "comments/posted.html",

django/contrib/comments/views/moderation.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.contrib.comments import signals
88
from django.views.decorators.csrf import csrf_protect
99

10+
1011
@csrf_protect
1112
@login_required
1213
def flag(request, comment_id, next=None):
@@ -23,7 +24,7 @@ def flag(request, comment_id, next=None):
2324
# Flag on POST
2425
if request.method == 'POST':
2526
perform_flag(request, comment)
26-
return next_redirect(request.POST.copy(), next, flag_done, c=comment.pk)
27+
return next_redirect(request, next, flag_done, c=comment.pk)
2728

2829
# Render a form on GET
2930
else:
@@ -50,7 +51,7 @@ def delete(request, comment_id, next=None):
5051
if request.method == 'POST':
5152
# Flag the comment as deleted instead of actually deleting it.
5253
perform_delete(request, comment)
53-
return next_redirect(request.POST.copy(), next, delete_done, c=comment.pk)
54+
return next_redirect(request, next, delete_done, c=comment.pk)
5455

5556
# Render a form on GET
5657
else:
@@ -77,7 +78,7 @@ def approve(request, comment_id, next=None):
7778
if request.method == 'POST':
7879
# Flag the comment as approved.
7980
perform_approve(request, comment)
80-
return next_redirect(request.POST.copy(), next, approve_done, c=comment.pk)
81+
return next_redirect(request, next, approve_done, c=comment.pk)
8182

8283
# Render a form on GET
8384
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
@@ -204,3 +204,15 @@ def same_origin(url1, url2):
204204
"""
205205
p1, p2 = urlparse.urlparse(url1), urlparse.urlparse(url2)
206206
return p1[0:2] == p2[0:2]
207+
208+
def is_safe_url(url, host=None):
209+
"""
210+
Return ``True`` if the url is a safe redirection (i.e. it doesn't point to
211+
a different host).
212+
213+
Always returns ``False`` on an empty url.
214+
"""
215+
if not url:
216+
return False
217+
netloc = urlparse.urlparse(url)[1]
218+
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
@@ -217,6 +217,13 @@ def testCommentNext(self):
217217
match = re.search(r"^http://testserver/somewhere/else/\?c=\d+$", location)
218218
self.assertTrue(match != None, "Unexpected redirect location: %s" % location)
219219

220+
data["next"] = "http://badserver/somewhere/else/"
221+
data["comment"] = "This is another comment with an unsafe next url"
222+
response = self.client.post("/post/", data)
223+
location = response["Location"]
224+
match = post_redirect_re.match(location)
225+
self.assertTrue(match != None, "Unsafe redirection to: %s" % location)
226+
220227
def testCommentDoneView(self):
221228
a = Article.objects.get(pk=1)
222229
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
@@ -27,6 +27,30 @@ def testFlagPost(self):
2727
self.assertEqual(c.flags.filter(flag=CommentFlag.SUGGEST_REMOVAL).count(), 1)
2828
return c
2929

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

5276
def testFlagSignals(self):
@@ -98,6 +122,33 @@ def testDeletePost(self):
98122
self.assertTrue(c.is_removed)
99123
self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_DELETION, user__username="normaluser").count(), 1)
100124

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

119170
class ApproveViewTests(CommentTestCase):
120171

121172
def testApprovePermissions(self):
122-
"""The delete view should only be accessible to 'moderators'"""
173+
"""The approve view should only be accessible to 'moderators'"""
123174
comments = self.createSomeComments()
124175
pk = comments[0].pk
125176
self.client.login(username="normaluser", password="normaluser")
@@ -131,7 +182,7 @@ def testApprovePermissions(self):
131182
self.assertEqual(response.status_code, 200)
132183

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

@@ -143,6 +194,36 @@ def testApprovePost(self):
143194
self.assertTrue(c.is_public)
144195
self.assertEqual(c.flags.filter(flag=CommentFlag.MODERATOR_APPROVAL, user__username="normaluser").count(), 1)
145196

197+
def testApprovePostNext(self):
198+
"""
199+
POSTing the approve view will redirect to an explicitly provided a next
200+
url.
201+
"""
202+
c1, c2, c3, c4 = self.createSomeComments()
203+
c1.is_public = False; c1.save()
204+
205+
makeModerator("normaluser")
206+
self.client.login(username="normaluser", password="normaluser")
207+
response = self.client.post("/approve/%d/" % c1.pk,
208+
{'next': "/go/here/"})
209+
self.assertEqual(response["Location"],
210+
"http://testserver/go/here/?c=1")
211+
212+
def testApprovePostUnsafeNext(self):
213+
"""
214+
POSTing to the approve view with an unsafe next url will ignore the
215+
provided url when redirecting.
216+
"""
217+
c1, c2, c3, c4 = self.createSomeComments()
218+
c1.is_public = False; c1.save()
219+
220+
makeModerator("normaluser")
221+
self.client.login(username="normaluser", password="normaluser")
222+
response = self.client.post("/approve/%d/" % c1.pk,
223+
{'next': "http://elsewhere/bad"})
224+
self.assertEqual(response["Location"],
225+
"http://testserver/approved/?c=%d" % c1.pk)
226+
146227
def testApproveSignals(self):
147228
def receive(sender, **kwargs):
148229
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
@@ -13,13 +13,28 @@ class I18NTests(TestCase):
1313
""" Tests django views in django/views/i18n.py """
1414

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

27+
def test_setlang_unsafe_next(self):
28+
"""
29+
The set_language view only redirects to the 'next' argument if it is
30+
"safe".
31+
"""
32+
lang_code, lang_name = settings.LANGUAGES[0]
33+
post_data = dict(language=lang_code, next='//unsafe/redirection/')
34+
response = self.client.post('/views/i18n/setlang/', data=post_data)
35+
self.assertEqual(response['Location'], 'http://testserver/')
36+
self.assertEqual(self.client.session['django_language'], lang_code)
37+
2338
def test_jsi18n(self):
2439
"""The javascript_catalog can be deployed with language settings"""
2540
for lang_code in ['es', 'fr', 'ru']:

0 commit comments

Comments
 (0)