diff --git a/bc/assets/templates/includes/header.html b/bc/assets/templates/includes/header.html
index f14c6173..399341b6 100644
--- a/bc/assets/templates/includes/header.html
+++ b/bc/assets/templates/includes/header.html
@@ -1,4 +1,4 @@
-{% load static %}
+{% load static web_extras %}
@@ -204,7 +204,13 @@
{% else %}
{% 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 %}
{% endif %}
diff --git a/bc/core/utils/urls.py b/bc/core/utils/urls.py
index ca0057ac..287ed342 100644
--- a/bc/core/utils/urls.py
+++ b/bc/core/utils/urls.py
@@ -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:
@@ -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])
diff --git a/bc/subscription/utils/courtlistener.py b/bc/subscription/utils/courtlistener.py
index b0c407ec..98c2c927 100644
--- a/bc/subscription/utils/courtlistener.py
+++ b/bc/subscription/utils/courtlistener.py
@@ -26,14 +26,11 @@
r"(?P
(https:\/{2}storage\.courtlistener\.com\/recap\/gov.uscourts.(?P[a-z]+).(?P\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
@@ -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()
@@ -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"
},
@@ -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:
@@ -189,7 +186,7 @@ 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,
@@ -197,7 +194,7 @@ def lookup_initial_complaint(docket_id: int | None) -> DocumentDict | None:
response.raise_for_status()
data = response.json()
- if not data["count"]:
+ if not data["results"]:
return None
document = data["results"][0]
@@ -211,7 +208,7 @@ 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
@@ -219,12 +216,12 @@ def download_pdf_from_cl(filepath: str) -> bytes:
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,
@@ -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:
@@ -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,
diff --git a/bc/users/tests/test_views.py b/bc/users/tests/test_views.py
new file mode 100644
index 00000000..868c72a8
--- /dev/null
+++ b/bc/users/tests/test_views.py
@@ -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"), "test@free.law", 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,
+ )
diff --git a/bc/users/urls.py b/bc/users/urls.py
index b0794702..111909ef 100644
--- a/bc/users/urls.py
+++ b/bc/users/urls.py
@@ -7,6 +7,7 @@
from .forms import ConfirmedEmailAuthenticationForm
from .views import (
RateLimitedPasswordResetView,
+ SafeRedirectLoginView,
confirm_email,
delete_account,
delete_profile_done,
@@ -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,
)
diff --git a/bc/users/views.py b/bc/users/views.py
index 68045ade..80cbc244 100644
--- a/bc/users/views.py
+++ b/bc/users/views.py
@@ -4,17 +4,21 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import logout, update_session_auth_hash
+from django.contrib.auth import views as auth_views
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import PasswordChangeForm
from django.contrib.auth.views import PasswordResetView
+from django.core.exceptions import SuspiciousOperation, ValidationError
from django.core.mail import send_mail
from django.core.signing import BadSignature, SignatureExpired
+from django.core.validators import validate_email
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import urlencode
from django.utils.timezone import now
+from django.views.decorators.cache import never_cache
from django.views.decorators.debug import (
sensitive_post_parameters,
sensitive_variables,
@@ -44,6 +48,7 @@
@sensitive_variables("cd", "signed_pk", "email")
@ratelimiter_unsafe_10_per_m
@ratelimiter_unsafe_2000_per_h
+@never_cache
def register(request: HttpRequest) -> HttpResponse:
"""allow only an anonymous user to register"""
if not request.user.is_anonymous:
@@ -108,13 +113,23 @@ def register(request: HttpRequest) -> HttpResponse:
)
+@never_cache
def register_success(request: HttpRequest) -> HttpResponse:
"""
Let the user know they have been registered and allow them
to continue where they left off.
+
+ Changes here should be reflected in the CourtListener's
+ register_success function.
"""
redirect_to = get_redirect_or_login_url(request, "next")
email = request.GET.get("email", "")
+ if email:
+ try:
+ validate_email(email)
+ except ValidationError:
+ raise SuspiciousOperation("Invalid Email address")
+
default_from = parseaddr(settings.DEFAULT_FROM_EMAIL)[1]
return render(
request,
@@ -129,6 +144,7 @@ def register_success(request: HttpRequest) -> HttpResponse:
@sensitive_variables("signed_pk")
+@never_cache
def confirm_email(request, signed_pk):
"""Confirms email addresses for a user and sends an email to the admins.
@@ -239,6 +255,7 @@ class RateLimitedPasswordResetView(PasswordResetView):
"email",
)
@login_required
+@never_cache
def profile_settings(request: AuthenticatedHttpRequest) -> HttpResponse:
old_email = request.user.email
user = request.user
@@ -287,6 +304,7 @@ def profile_settings(request: AuthenticatedHttpRequest) -> HttpResponse:
@sensitive_post_parameters("old_password", "new_password1", "new_password2")
@login_required
+@never_cache
def password_change(request: AuthenticatedHttpRequest) -> HttpResponse:
if request.method == "POST":
form = PasswordChangeForm(user=request.user, data=request.POST)
@@ -308,6 +326,7 @@ def password_change(request: AuthenticatedHttpRequest) -> HttpResponse:
@login_required
@ratelimiter_unsafe_10_per_m
@ratelimiter_unsafe_2000_per_h
+@never_cache
def take_out(request: AuthenticatedHttpRequest) -> HttpResponse:
if request.method == "POST":
email: EmailType = emails["take_out_requested"]
@@ -337,6 +356,7 @@ def take_out_done(request: HttpRequest) -> HttpResponse:
@login_required
@ratelimiter_unsafe_10_per_m
@ratelimiter_unsafe_2000_per_h
+@never_cache
def delete_account(request: AuthenticatedHttpRequest) -> HttpResponse:
if request.method == "POST":
delete_form = AccountDeleteForm(request, request.POST)
@@ -365,3 +385,24 @@ def delete_account(request: AuthenticatedHttpRequest) -> HttpResponse:
def delete_profile_done(request: HttpRequest) -> HttpResponse:
return render(request, "profile/deleted.html")
+
+
+class SafeRedirectLoginView(auth_views.LoginView):
+ """
+ Custom LoginView that validates and sanitizes the redirect URL after a
+ successful login.
+ This view inherits from Django's built-in LoginView but adds an extra layer
+ of security by ensuring the redirect URL submitted by the login form is safe
+ It prevents potential open redirect vulnerabilities.
+ """
+
+ def get_redirect_url(self):
+ """
+ Return the user-originating redirect URL if it's safe. otherwise falls
+ back to the default.
+ This method ensures users cannot be redirected to malicious URLs after
+ logging in, even if they attempt to provide one.
+ """
+ return get_redirect_or_login_url(
+ self.request, self.redirect_field_name
+ )
diff --git a/poetry.lock b/poetry.lock
index 66f8364b..40ef58d3 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,5 +1,73 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+[[package]]
+name = "ada-url"
+version = "1.15.3"
+description = "URL parser and manipulator based on the WHAT WG URL standard"
+optional = false
+python-versions = ">=3.8"
+files = [
+ {file = "ada_url-1.15.3-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:486ed6775faaf915efb82e4dea9224d388ca743aa572996240ffda20e19dd769"},
+ {file = "ada_url-1.15.3-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:cf0facdc4e66cadafdfb7ccb914e03aae2571dd8f70a28531a60019d8888641b"},
+ {file = "ada_url-1.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b2af6a7453bfd11d7ff3fb9710447887888206e5c3a81ceb7f0f23390d48876e"},
+ {file = "ada_url-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97e0a17dc90d2c42293a44a69308468d2b892564736cc01ef47326824cc9a708"},
+ {file = "ada_url-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3835ebabff21bcb870bba21908e34a642bc8a4ae8a40e4a83b7343f04316b0fb"},
+ {file = "ada_url-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1cf1e261b94dafa9f9d7eb946a1d76a4d5e760422988236ef9d583b0580e67d"},
+ {file = "ada_url-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79144b5a24dfff82cec6b72995668b3d461f21a1e6ef1a57657f5470dfd2c23a"},
+ {file = "ada_url-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:2a38f62abb9dfe7c1bf8c13c3c9d1da94412242199b767f4d7b9b1705739cad4"},
+ {file = "ada_url-1.15.3-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:31d927fcb72ad77e7cb077e706259c20449460f6cc3fc5705dc4a13090ba49e7"},
+ {file = "ada_url-1.15.3-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:2137025c87c419e5360394c8150a8d1a5d3e8f0dc9e63a3a0c42bba0aa1b872f"},
+ {file = "ada_url-1.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89655b4174c4f730f9f43c5df871f1a74904845caec540d0419ac8520cba8073"},
+ {file = "ada_url-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:017cf3a213ff274b3ab74a44834b052632f18a2e137e2d550c9b77ff0877fae6"},
+ {file = "ada_url-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84d6dda6442c43cda776031874c28c4b83658688665ed1b13deee9a433271bb7"},
+ {file = "ada_url-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb25a889c47938dbdbd28377e61a0c785292e1bb8808e82bb1591b7c9a327451"},
+ {file = "ada_url-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a48193a753e24d130e6a731aae95f3fca32535bfd3db7c295f7040f60d777e2"},
+ {file = "ada_url-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:e97d15973e1a5d952a3177669e320756b3b8eb6bd4d9eff48f5077710e575156"},
+ {file = "ada_url-1.15.3-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:82797ae677e3ed5399c209932d05db32615118d550cb77c184c6a98f80a4a42d"},
+ {file = "ada_url-1.15.3-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:f70bc89bfbfbcc51cc1ee8cb9cab12663945acac28a724b1eb64eb2830f9f0f2"},
+ {file = "ada_url-1.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:abd9ba0783eef17a94d119a64d8efb1ee73b0bd328f521caa9919924c61da409"},
+ {file = "ada_url-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:580e64e0e29cbcd55f95896037472028765f28cf7d1f43a27d5f670d98a3e299"},
+ {file = "ada_url-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c253ac0c82680e2aaef793acba35d10170474dbd02bd3db8a46375801e4a8ab2"},
+ {file = "ada_url-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f87a642809852cf686caaf9c2ae0ee1e2d2e89449683a92a433a8e81ab3e13a"},
+ {file = "ada_url-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddc159a70eacb54afb1ed3590ae79c29e19c0b4031c5bdc915d47b6c40222c45"},
+ {file = "ada_url-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:0d2efe08608f2f1be01ac33922edea808083dad01b4512a661895028a25fb0ed"},
+ {file = "ada_url-1.15.3-cp38-cp38-macosx_10_15_universal2.whl", hash = "sha256:dc6c9108111a980f9de2878bb380efacab286cc77e1d832ddc4969554bc8231a"},
+ {file = "ada_url-1.15.3-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:cc3fda231f10618b6fbc54b4f2c1485e68ec54726bddcf51c826917ca637fb34"},
+ {file = "ada_url-1.15.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:41023e4d62d6df874b333491290b094efa7ecdbe352004ff2ecad45a8b12c743"},
+ {file = "ada_url-1.15.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5f9808e1f051e33f1a1adc27cf708de630cb0495178249cb3b132dfe535c569"},
+ {file = "ada_url-1.15.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d68ec1ed7e3572807b489ee6a6f3da27ad9b6c292f1a32b4ef1cc8ec7775d623"},
+ {file = "ada_url-1.15.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:39a51d4a881adfea6430a74e0042eb21582cfd4974883ab51980ff54c64447f7"},
+ {file = "ada_url-1.15.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:84f4ea55c937ed9d7f37986a757fba87cef80f805cd54fdc4f616dd45bd38e33"},
+ {file = "ada_url-1.15.3-cp38-cp38-win_amd64.whl", hash = "sha256:bebfc6d909aeb7fa8f1098924219a414c3d4d339a45b468b5b6b3b3f05f81ad8"},
+ {file = "ada_url-1.15.3-cp39-cp39-macosx_10_15_universal2.whl", hash = "sha256:8030483ee0f08852eeb9c7870841a0e1d0d773071fd05ff1b21d45efabba8954"},
+ {file = "ada_url-1.15.3-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:923f1f13040134a7147c5530eca6c832b6958704bf7dd0c593f7b6786bcdc535"},
+ {file = "ada_url-1.15.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6731a9c7ec646b70e7c4c6b9f68a3b7d3f0f0e0e4eed600bae3f5bb976df0b4"},
+ {file = "ada_url-1.15.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75166e872b1e5618d9b197baf0ebb26f3b2432cdbd186c1765126cdc92946ac4"},
+ {file = "ada_url-1.15.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b5c45c9e0f04caa5b98decb64c9da9d32e26f8f3a262616ee3b78748f103fdb"},
+ {file = "ada_url-1.15.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fe6192744b54d729034994b7e2643519c09a8b4a00f7b0e368757fb9b5aa5145"},
+ {file = "ada_url-1.15.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:279f64ef3821cf870667063973b0f35ed6bcf9752c1e4fabeaf5d81bb6eb15fb"},
+ {file = "ada_url-1.15.3-cp39-cp39-win_amd64.whl", hash = "sha256:0466b7ec95a43a6997184a67a0338a7177d01dade3d39426b2a810a85143f0dc"},
+ {file = "ada_url-1.15.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b9a9ebcd27c2cb59b049fd06664c98f7505b3f856c3ac7df087bc45e518f8cd6"},
+ {file = "ada_url-1.15.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9b14d002085e6ede41571373b23bca367f89c6e97542be73053b0d8ddbf77c80"},
+ {file = "ada_url-1.15.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8d52db4880d274ac0936f3742685140389a2a28be932a97d9a2183efb6ecece"},
+ {file = "ada_url-1.15.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2112a62bba370db2281323afb7d757bf6ae0d0aada736b59b142233825f11998"},
+ {file = "ada_url-1.15.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:81d5a0923c9703cddd2dde90f03d132d29282d5e48a0ac796fd4c2be39ca8780"},
+ {file = "ada_url-1.15.3-pp38-pypy38_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0260dd4f1df3b33193699939b925547dc551e81348a53a65b911a50a9d5f1473"},
+ {file = "ada_url-1.15.3-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:63e5a8527573d2cf9c0c788cd104c6d784ee1af5ff9cbb9de67e1b4197186dfb"},
+ {file = "ada_url-1.15.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3906efeff1f5d34390aefcf33812fdbea4e62eeae2854358bd0b83797ae944c5"},
+ {file = "ada_url-1.15.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:823547085c3a84b00913a009c8688bfbce5e347284558777d8d2d1fe88708388"},
+ {file = "ada_url-1.15.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:be5a2210110c9a7c09c063c6c46f6501a08eba1b5d1b2ed117c7fbc48e21c8aa"},
+ {file = "ada_url-1.15.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:966401b06212f4328ba9df21a96e483e3e206659762e7bdbfe482fe6342b56cd"},
+ {file = "ada_url-1.15.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:893922defdfdcbea2363ee9fea326e095ad2f95dee117841bfdf57cac4d09d84"},
+ {file = "ada_url-1.15.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:007bcc66296e1c3076965be77147bb9e088276665d276e4d4358d6c06771f73f"},
+ {file = "ada_url-1.15.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c6bb39c48b1c1632134ed73f4eaa610aafd5d3a3a77999d07bcc338c3712c95"},
+ {file = "ada_url-1.15.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:eebc0242333320602f078d7c4d4e72191180e2db2b05797c650537083beb0560"},
+ {file = "ada_url-1.15.3.tar.gz", hash = "sha256:c1d3bd341082d887c9503bffc10a3b5b33bc514a89c425b656078065a2c8f599"},
+]
+
+[package.dependencies]
+cffi = "*"
+
[[package]]
name = "ansicolors"
version = "1.1.8"
@@ -2191,4 +2259,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
-content-hash = "d3413b28dda1b65b40e1e8df2399e23e8b4e828e652f2c92038f41b1a3818bb0"
+content-hash = "113cc70020e566bb0cdd24a14c3e08d28699fd13704bcbe9b91e64f4af9ef915"
diff --git a/pyproject.toml b/pyproject.toml
index 16c01cd5..8d4ef33f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,7 @@ version = "0.0.1"
"Organisation Homepage" = "https://free.law/"
[tool.poetry.dependencies]
+ada-url = "^1.15.3"
python = "^3.11"
art = "^6.2"
requests = "^2.32.0"