Skip to content

Commit

Permalink
Merge branch 'main' into main
Browse files Browse the repository at this point in the history
  • Loading branch information
mlissner authored Sep 25, 2024
2 parents 95bf60c + 8fb2933 commit 572a9a5
Show file tree
Hide file tree
Showing 8 changed files with 308 additions and 37 deletions.
10 changes: 8 additions & 2 deletions bc/assets/templates/includes/header.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{% load static %}
{% load static web_extras %}

<div class="relative bg-gray-200 border-b-2 border-gray-400">
<div class="max-w-7xl mx-auto px-4 sm:px-6 md:px-10">
Expand Down Expand Up @@ -204,7 +204,13 @@
{% else %}
<div class="w-20 text-sm font-medium sm:text-md">
{% url 'sign-in' as sign_in %}
{% include './action-button.html' with link=sign_in text="Sign In" size='sm' %}
{% if request.path != "/sign-out/" %}
{% with sign_in|addstr:"?next="|addstr:request.path as sign_in_w_redirect %}
{% include './action-button.html' with link=sign_in_w_redirect text="Sign In" size='sm' %}
{% endwith %}
{% else %}
{% include './action-button.html' with link=sign_in text="Sign In" size='sm' %}
{% endif %}
</div>
{% endif %}
</div>
Expand Down
63 changes: 53 additions & 10 deletions bc/core/utils/urls.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,66 @@
from ada_url import URL
from django.conf import settings
from django.http import HttpRequest
from django.urls import reverse
from django.utils.http import url_has_allowed_host_and_scheme

BASE_URL = (
"https://www.courtlistener.com"
if not settings.DEVELOPMENT
else "http://localhost:8000"
)

def get_redirect_or_login_url(request: HttpRequest, field_name: str) -> str:
"""Get the redirect if it's safe, or send the user to the login page

def parse_url_with_ada(url: str) -> str:
"""Parses a URL using the `URL` class from the `Ada`.
Handles relative paths by adding the `BASE_URL` to the class constructor.
If the URL is already absolute, this step has no effect. Extracts the
parsed URL from the `href` attribute and attempts to remove the `BASE_URL`
if it was added previously.
Returns an empty string If the input URL is invalid or cannot be parsed.
:param url: The URL to parse.
:return: The parsed URL or an empty string if the input URL is invalid or
cannot be parsed.
"""
if not url:
return ""

try:
ada_url = URL(url, base=BASE_URL)
return ada_url.href.replace(BASE_URL, "")
except ValueError:
return ""


def get_redirect_or_login_url(
request: HttpRequest, redirect_field_name: str
) -> str:
"""
Retrieves a safe redirect URL from the request or returns the login URL.
This function checks for a redirect URL in both the POST and GET data of
the provided request object. It then parses the retrieved URL using the
`parse_url_with_ada` helper and performs safety checks using the
`is_safe_url` function.
:param request: The HTTP request
:param field_name: The field where the redirect is located
:param redirect_field_name: The name of the field containing the redirect
URL.
:return: Either the value requested or the default LOGIN_REDIRECT_URL, if
a sanity or security check failed.
"""
url = request.GET.get(field_name, "")
is_safe = is_safe_url(url, request)
if not is_safe:
redirect_url = request.POST.get(
redirect_field_name,
request.GET.get(redirect_field_name, ""),
)
cleaned_url = parse_url_with_ada(redirect_url)
safe = is_safe_url(cleaned_url, request)
if not safe:
return settings.LOGIN_REDIRECT_URL
return url
return cleaned_url


def is_safe_url(url: str, request: HttpRequest) -> bool:
Expand Down Expand Up @@ -45,11 +89,10 @@ def is_safe_url(url: str, request: HttpRequest) -> bool:
"""
sign_in_url = reverse("sign-in") in url
register_in_url = reverse("register") in url
no_url = not url
not_safe_url = not url_has_allowed_host_and_scheme(
url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
)
if any([sign_in_url, register_in_url, not_safe_url]):
return False
return True
return not any([sign_in_url, register_in_url, no_url, not_safe_url])
43 changes: 20 additions & 23 deletions bc/subscription/utils/courtlistener.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,11 @@
r"(?P<url_for_redirect>(https:\/{2}storage\.courtlistener\.com\/recap\/gov.uscourts.(?P<court>[a-z]+).(?P<pacer_case_id>\d+)))(?:\/.*)"
)

CL_API = {
"docket": "https://www.courtlistener.com/api/rest/v3/dockets/",
"docket-alerts": "https://www.courtlistener.com/api/rest/v3/docket-alerts/",
"docket-entries": "https://www.courtlistener.com/api/rest/v3/docket-entries/",
"recap-documents": "https://www.courtlistener.com/api/rest/v3/recap-documents/",
"recap-fetch": "https://www.courtlistener.com/api/rest/v3/recap-fetch/",
"media-storage": "https://storage.courtlistener.com/",
}
CL_API_URL = (
lambda suffix: f"https://www.courtlistener.com/api/rest/v4/{suffix}/"
)

CL_MEDIA_STORAGE = "https://storage.courtlistener.com/"

pacer_to_cl_ids = {
# Maps PACER ids to their CL equivalents
Expand Down Expand Up @@ -131,10 +128,10 @@ class DocketDict(TypedDict):

def lookup_docket_by_cl_id(cl_id: int) -> DocketDict:
"""
Performs a GET query on /api/rest/v3/dockets/
Performs a GET query on /api/rest/v4/dockets/
to get a Docket using the CourtListener ID
"""
url = f"{CL_API['docket']}{cl_id}/"
url = f"{CL_API_URL('dockets')}{cl_id}/"
response = requests.get(url, headers=auth_header(), timeout=5)
response.raise_for_status()
return response.json()
Expand All @@ -150,11 +147,11 @@ class DocumentDict(TypedDict):

def lookup_document_by_doc_id(doc_id: int | None) -> DocumentDict:
"""
Performs a GET query on /api/rest/v3/recap-documents/
Performs a GET query on /api/rest/v4/recap-documents/
using the document_id to get a recap document
"""
response = requests.get(
f"{CL_API['recap-documents']}{doc_id}/",
f"{CL_API_URL('recap-documents')}{doc_id}/",
params={
"fields": "id,absolute_url,filepath_local,page_count,pacer_doc_id"
},
Expand All @@ -168,7 +165,7 @@ def lookup_document_by_doc_id(doc_id: int | None) -> DocumentDict:

def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None:
"""
Performs a GET query on /api/rest/v3/recap/
Performs a GET query on /api/rest/v4/recap/
using the docket_id to get the first entry of the case.
Args:
Expand All @@ -189,15 +186,15 @@ def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None:
}

response = requests.get(
f"{CL_API['recap-documents']}",
f"{CL_API_URL('recap-documents')}",
params=params,
headers=auth_header(),
timeout=5,
)
response.raise_for_status()

data = response.json()
if not data["count"]:
if not data["results"]:
return None

document = data["results"][0]
Expand All @@ -211,20 +208,20 @@ def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None:


def download_pdf_from_cl(filepath: str) -> bytes:
document_url = f"{CL_API['media-storage']}{filepath}"
document_url = f"{CL_MEDIA_STORAGE}{filepath}"
document_request = requests.get(document_url, timeout=3)
document_request.raise_for_status()
return document_request.content


def purchase_pdf_by_doc_id(doc_id: int | None, docket_id: int | None) -> int:
"""
Performs a POST query on /api/rest/v3/recap-fetch/
Performs a POST query on /api/rest/v4/recap-fetch/
using the document_id from CL and the PACER's login
credentials.
"""
response = requests.post(
f"{CL_API['recap-fetch']}",
f"{CL_API_URL('recap-fetch')}",
json={
"request_type": 2,
"pacer_username": settings.PACER_USERNAME,
Expand All @@ -242,19 +239,19 @@ def purchase_pdf_by_doc_id(doc_id: int | None, docket_id: int | None) -> int:

def lookup_docket_by_case_number(court: str, docket_number: str):
"""
Performs a GET query on /api/rest/v3/dockets/
Performs a GET query on /api/rest/v4/dockets/
using the court_id and docket_number to get a
Docket.
"""

response = requests.get(
CL_API["docket"],
CL_API_URL("dockets"),
params={"court_id": court, "docket_number": docket_number},
headers=auth_header(),
timeout=5,
)
data = response.json()
num_results = data["count"]
num_results = len(data["results"])
if num_results == 1:
return data["results"][0]
elif num_results == 0:
Expand Down Expand Up @@ -296,11 +293,11 @@ def lookup_docket_by_case_number(court: str, docket_number: str):

def subscribe_to_docket_alert(cl_id: int) -> bool:
"""
Performs a POST query on /api/rest/v3/docket-alerts/
Performs a POST query on /api/rest/v4/docket-alerts/
to subscribe to docket alerts for a given CourtListener docket ID.
"""
response = requests.post(
CL_API["docket-alerts"],
CL_API_URL("docket-alerts"),
headers=auth_header(),
data={
"docket": cl_id,
Expand Down
114 changes: 114 additions & 0 deletions bc/users/tests/test_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
from http import HTTPStatus

from django.test import LiveServerTestCase
from django.urls import reverse


class UserTest(LiveServerTestCase):
async def test_simple_auth_urls_GET(self) -> None:
"""Can we at least GET all the basic auth URLs?"""
reverse_names = [
"sign-in",
"password_reset",
"password_reset_done",
"password_reset_complete",
"email_confirmation_request",
"email_confirmation_request_success",
]
for reverse_name in reverse_names:
path = reverse(reverse_name)
r = await self.async_client.get(path)
self.assertEqual(
r.status_code,
HTTPStatus.OK,
msg="Got wrong status code for page at: {path}. "
"Status Code: {code}".format(path=path, code=r.status_code),
)

async def test_login_redirects(self) -> None:
"""Do we allow good redirects in login while banning bad ones?"""
next_params = [
# A safe redirect
(reverse("little_cases"), False),
# Redirection to the register page
(reverse("register"), True),
# No open redirects (to a domain outside CL)
("https://evil.com&email=e%40e.net", True),
# No javascript (!)
("javascript:confirm(document.domain)", True),
# No spaces
("/test test", True),
# CRLF injection attack
(
"/%0d/evil.com/&email=Your+Account+still+in+maintenance,please+click+Return+below",
True,
),
# XSS vulnerabilities
(
"register/success/?next=java%0d%0ascript%0d%0a:alert(document.cookie)&email=Reflected+XSS+here",
True,
),
]
for next_param, is_not_safe in next_params:
bad_url = "{host}{path}?next={next}".format(
host=self.live_server_url,
path=reverse("sign-in"),
next=next_param,
)
response = await self.async_client.get(bad_url)
with self.subTest("Checking redirect in login", url=bad_url):
if is_not_safe:
self.assertNotIn(
f'value="{next_param}"',
response.content.decode(),
msg="'%s' found in HTML of response. This suggests it was "
"not cleaned by the sanitize_redirection function."
% next_param,
)
else:
self.assertIn(
f'value="{next_param}"',
response.content.decode(),
msg="'%s' not found in HTML of response. This suggests it "
"was sanitized when it should not have been."
% next_param,
)

async def test_prevent_text_injection_in_success_registration(self):
"""Can we handle text injection attacks?"""
evil_text = "visit https://evil.com/malware.exe to win $100 giftcard"
url_params = [
# A safe redirect and email
(reverse("little_cases"), "[email protected]", False),
# Text injection attack
(reverse("little_cases"), evil_text, True),
# open redirect and text injection attack
("https://evil.com&email=e%40e.net", evil_text, True),
]

for next_param, email, is_evil in url_params:
url = "{host}{path}?next={next}&email={email}".format(
host=self.live_server_url,
path=reverse("register_success"),
next=next_param,
email=email,
)
response = await self.async_client.get(url)
with self.subTest("Checking url", url=url):
if is_evil:
self.assertNotIn(
email,
response.content.decode(),
msg="'%s' found in HTML of response. This indicates a "
"potential security vulnerability. The view likely "
"failed to properly validate it." % email,
)
else:
self.assertIn(
email,
response.content.decode(),
msg="'%s' not found in HTML of response. This suggests a "
"a potential issue with the validation logic. The email "
"address may have been incorrectly identified as invalid"
% email,
)
3 changes: 2 additions & 1 deletion bc/users/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .forms import ConfirmedEmailAuthenticationForm
from .views import (
RateLimitedPasswordResetView,
SafeRedirectLoginView,
confirm_email,
delete_account,
delete_profile_done,
Expand All @@ -24,7 +25,7 @@
path(
"sign-in/",
ratelimiter_unsafe_10_per_m(
auth_views.LoginView.as_view(
SafeRedirectLoginView.as_view(
template_name="register/login.html",
authentication_form=ConfirmedEmailAuthenticationForm,
)
Expand Down
Loading

0 comments on commit 572a9a5

Please sign in to comment.