Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Ballot return to url via url params rather than session #7788

Merged
merged 9 commits into from
Aug 9, 2024
30 changes: 30 additions & 0 deletions ietf/doc/tests_ballot.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
BallotPositionDocEventFactory, BallotDocEventFactory, IRSGBallotDocEventFactory)
from ietf.doc.templatetags.ietf_filters import can_defer
from ietf.doc.utils import create_ballot_if_not_open
from ietf.doc.views_ballot import parse_ballot_edit_return_point
from ietf.doc.views_doc import document_ballot_content
from ietf.group.models import Group, Role
from ietf.group.factories import GroupFactory, RoleFactory, ReviewTeamFactory
Expand Down Expand Up @@ -1451,3 +1452,32 @@ def test_document_ballot_content_without_send_email_values(self):
self._assertBallotMessage(q, balloters[0], 'No discuss send log available')
self._assertBallotMessage(q, balloters[1], 'No comment send log available')
self._assertBallotMessage(q, old_balloter, 'No ballot position send log available')

class ReturnToUrlTests(TestCase):
def test_invalid_return_to_url(self):
self.assertRaises(
Exception,
lambda: parse_ballot_edit_return_point('/doc/', 'draft-ietf-opsawg-ipfix-tcpo-v6eh', '998718'),
)
self.assertRaises(
Exception,
lambda: parse_ballot_edit_return_point('/a-route-that-does-not-exist/', 'draft-ietf-opsawg-ipfix-tcpo-v6eh', '998718'),
)
self.assertRaises(
Exception,
lambda: parse_ballot_edit_return_point('https://example.com/phishing', 'draft-ietf-opsawg-ipfix-tcpo-v6eh', '998718'),
)

def test_valid_default_return_to_url(self):
self.assertEqual(parse_ballot_edit_return_point(
None,
'draft-ietf-opsawg-ipfix-tcpo-v6eh',
'998718'
), '/doc/draft-ietf-opsawg-ipfix-tcpo-v6eh/ballot/998718/')

def test_valid_return_to_url(self):
self.assertEqual(parse_ballot_edit_return_point(
'/doc/draft-ietf-opsawg-ipfix-tcpo-v6eh/ballot/998718/',
'draft-ietf-opsawg-ipfix-tcpo-v6eh',
'998718'
), '/doc/draft-ietf-opsawg-ipfix-tcpo-v6eh/ballot/998718/')
45 changes: 32 additions & 13 deletions ietf/doc/views_ballot.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@

from django import forms
from django.conf import settings
from django.http import HttpResponse, HttpResponseRedirect, Http404
from django.http import HttpResponse, HttpResponseRedirect, Http404, HttpResponseBadRequest
from django.shortcuts import render, get_object_or_404, redirect
from django.template.defaultfilters import striptags
from django.template.loader import render_to_string
from django.urls import reverse as urlreverse
from django.views.decorators.csrf import csrf_exempt
from django.utils.html import escape
from urllib.parse import urlencode as urllib_urlencode

import debug # pyflakes:ignore

Expand All @@ -39,6 +40,7 @@
from ietf.name.models import BallotPositionName, DocTypeName
from ietf.person.models import Person
from ietf.utils.fields import ModelMultipleChoiceField
from ietf.utils.http import validate_return_to_path
from ietf.utils.mail import send_mail_text, send_mail_preformatted
from ietf.utils.decorators import require_api_key
from ietf.utils.response import permission_denied
Expand Down Expand Up @@ -185,11 +187,11 @@ def edit_position(request, name, ballot_id):

balloter = login = request.user.person

if 'ballot_edit_return_point' in request.session:
return_to_url = request.session['ballot_edit_return_point']
else:
return_to_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id))

try:
return_to_url = parse_ballot_edit_return_point(request.GET.get('ballot_edit_return_point'), doc.name, ballot_id)
except ValueError:
return HttpResponseBadRequest('ballot_edit_return_point is invalid')
# if we're in the Secretariat, we can select a balloter to act as stand-in for
if has_role(request.user, "Secretariat"):
balloter_id = request.GET.get('balloter')
Expand All @@ -209,9 +211,14 @@ def edit_position(request, name, ballot_id):
save_position(form, doc, ballot, balloter, login, send_mail)

if send_mail:
qstr=""
query = {}
if request.GET.get('balloter'):
qstr += "?balloter=%s" % request.GET.get('balloter')
query['balloter'] = request.GET.get('balloter')
if request.GET.get('ballot_edit_return_point'):
query['ballot_edit_return_point'] = request.GET.get('ballot_edit_return_point')
qstr = ""
if len(query) > 0:
qstr = "?" + urllib_urlencode(query, safe='/')
return HttpResponseRedirect(urlreverse('ietf.doc.views_ballot.send_ballot_comment', kwargs=dict(name=doc.name, ballot_id=ballot_id)) + qstr)
elif request.POST.get("Defer") and doc.stream.slug != "irtf":
return redirect('ietf.doc.views_ballot.defer_ballot', name=doc)
Expand Down Expand Up @@ -337,11 +344,11 @@ def send_ballot_comment(request, name, ballot_id):

balloter = request.user.person

if 'ballot_edit_return_point' in request.session:
return_to_url = request.session['ballot_edit_return_point']
else:
return_to_url = urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc.name, ballot_id=ballot_id))

try:
return_to_url = parse_ballot_edit_return_point(request.GET.get('ballot_edit_return_point'), doc.name, ballot_id)
except ValueError:
return HttpResponseBadRequest('ballot_edit_return_point is invalid')
if 'HTTP_REFERER' in request.META:
back_url = request.META['HTTP_REFERER']
else:
Expand Down Expand Up @@ -1302,3 +1309,15 @@ def rsab_ballot_status(request):
# Possible TODO: add a menu item to show this? Maybe only if you're in rsab or an rswg chair?
# There will be so few of these that the general community would follow them from the rswg docs page.
# Maybe the view isn't actually needed at all...


def parse_ballot_edit_return_point(path, doc_name, ballot_id):
get_default_path = lambda: urlreverse("ietf.doc.views_doc.document_ballot", kwargs=dict(name=doc_name, ballot_id=ballot_id))
allowed_path_handlers = {
"ietf.doc.views_doc.document_ballot",
"ietf.doc.views_doc.document_irsg_ballot",
"ietf.doc.views_doc.document_rsab_ballot",
"ietf.iesg.views.agenda",
"ietf.iesg.views.agenda_documents",
}
return validate_return_to_path(path, get_default_path, allowed_path_handlers)
5 changes: 0 additions & 5 deletions ietf/doc/views_doc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1538,7 +1538,6 @@ def document_ballot(request, name, ballot_id=None):
top = render_document_top(request, doc, ballot_tab, name)

c = document_ballot_content(request, doc, ballot.id, editable=True)
request.session['ballot_edit_return_point'] = request.path_info

return render(request, "doc/document_ballot.html",
dict(doc=doc,
Expand All @@ -1556,8 +1555,6 @@ def document_irsg_ballot(request, name, ballot_id=None):

c = document_ballot_content(request, doc, ballot_id, editable=True)

request.session['ballot_edit_return_point'] = request.path_info

return render(request, "doc/document_ballot.html",
dict(doc=doc,
top=top,
Expand All @@ -1575,8 +1572,6 @@ def document_rsab_ballot(request, name, ballot_id=None):

c = document_ballot_content(request, doc, ballot_id, editable=True)

request.session['ballot_edit_return_point'] = request.path_info

return render(
request,
"doc/document_ballot.html",
Expand Down
3 changes: 1 addition & 2 deletions ietf/iesg/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,6 @@ def agenda(request, date=None):
urlreverse("ietf.iesg.views.telechat_agenda_content_view", kwargs={"section": "minutes"})
))

request.session['ballot_edit_return_point'] = request.path_info
return render(request, "iesg/agenda.html", {
"date": data["date"],
"sections": sorted(data["sections"].items(), key=lambda x:[int(p) for p in x[0].split('.')]),
Expand Down Expand Up @@ -398,7 +397,7 @@ def agenda_documents(request):
"sections": sorted((num, section) for num, section in sections.items()
if "2" <= num < "5")
})
request.session['ballot_edit_return_point'] = request.path_info

return render(request, 'iesg/agenda_documents.html', { 'telechats': telechats })

def past_documents(request):
Expand Down
2 changes: 1 addition & 1 deletion ietf/templates/doc/ballot_popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
{% if editable and user|has_role:"Area Director,Secretariat,IRSG Member,RSAB Member" %}
{% if user|can_ballot:doc %}
<a class="btn btn-primary"
href="{% url "ietf.doc.views_ballot.edit_position" name=doc.name ballot_id=ballot_id %}">
href="{% url "ietf.doc.views_ballot.edit_position" name=doc.name ballot_id=ballot_id %}?ballot_edit_return_point={{ request.path|urlencode }}">
Edit position
</a>
{% endif %}
Expand Down
2 changes: 1 addition & 1 deletion ietf/templates/doc/document_ballot_content.html
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@
</a>
{% if user|can_ballot:doc %}
<a class="btn btn-primary"
href="{% url "ietf.doc.views_ballot.edit_position" name=doc.name ballot_id=ballot.pk %}">
href="{% url "ietf.doc.views_ballot.edit_position" name=doc.name ballot_id=ballot.pk %}?ballot_edit_return_point={{ request.path|urlencode }}">
Edit position
</a>
{% endif %}
Expand Down
26 changes: 25 additions & 1 deletion ietf/utils/http.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,34 @@
# Copyright The IETF Trust 2023, All Rights Reserved
# Copyright The IETF Trust 2023-2024, All Rights Reserved
# -*- coding: utf-8 -*-

from django.urls import resolve as urlresolve, Resolver404

def is_ajax(request):
"""Checks whether a request was an AJAX call

See https://docs.djangoproject.com/en/3.1/releases/3.1/#id2 - this implements the
exact reproduction of the deprecated method suggested there.
"""
return request.headers.get("x-requested-with") == "XMLHttpRequest"

def validate_return_to_path(path, get_default_path, allowed_path_handlers):
if path is None:
path = get_default_path()

# we need to ensure the path isn't used for attacks (eg phishing).
# `path` can be used in HttpResponseRedirect() which could redirect to Datatracker or offsite.
# Eg http://datatracker.ietf.org/...?ballot_edit_return_point=https://example.com/phish
# offsite links could be phishing attempts so let's reject them all, and require valid Datatracker
# routes
try:
# urlresolve will throw if the url doesn't match a route known to Django
match = urlresolve(path)
# further restrict by whether it's in the list of valid routes to prevent
# (eg) redirecting to logout
if match.url_name not in allowed_path_handlers:
raise ValueError("Invalid return to path not among valid matches")
pass
except Resolver404:
raise ValueError("Invalid return to path doesn't match a route")

return path
1 change: 1 addition & 0 deletions playwright/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"install-deps": "playwright install --with-deps",
"test": "playwright test",
"test:legacy": "playwright test -c playwright-legacy.config.js",
"test:legacy:visual": "playwright test -c playwright-legacy.config.js --headed --workers=1",
"test:visual": "playwright test --headed --workers=1",
"test:debug": "playwright test --debug"
},
Expand Down
Loading