Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion docs/developer-notes/security-hardening.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,20 @@ Rebuild the image so `uv sync --frozen` installs the new lockfile and `collectst
## Follow-up work

- Aggregate upload limits and storage quotas, actual file-content validation, durable upload staging, and orphan cleanup.
- Restore CSRF checks on browser session mutation and submission endpoints, with matching JavaScript changes and CSRF-enforcing tests.
- Stop unnecessary session writes on polling; test expiry behavior before changing the global session setting.
- Measure web workflows and extraction recovery under realistic load, including PostgreSQL contention, upstream rate limits, and difficult PDFs. Unit interleaving tests do not establish production throughput.
- Validate and version compiled YAML configurations; add derived-response caching only where profiling shows a benefit.

The submission claim and ambiguous-outcome safeguards are unchanged. Extraction retries must not be reused as an automatic court-submission retry mechanism.

## Confidentiality fixes from issue #345

The browser token API returns only the active account's jurisdiction-specific Tyler token, never the EFSP service key. Profile, payment, fee, and filing-history APIs require both a matching Django identity and a court token. Responses must not be cached. Jurisdictions must match installed configurations; case-lookup court codes cannot contain path traversal, URL delimiters, or encoded aliases. These proxy requests do not follow redirects carrying credentials.

Browser login, logout, session updates, fee quotes, case lookup POSTs, and submissions use Django's CSRF middleware. JSON login also requires the JSON content type and does not echo malformed credentials. Existing browser requests send the CSRF token; bearer-authenticated interview handoff APIs retain their separate authentication boundary.

Fee quotes and submissions rebuild each document from the current draft's private S3 key and saved filing selections, including its confidentiality code. The proxy receives a fresh one-hour presigned URL. Browser URLs, nested attachments, page counts, and arbitrary document properties are discarded. Missing drafts, unavailable documents, and stale document IDs fail closed. Corrections use the same document binding. Court-code normalization is separate from this mandatory preparation step; forwarding views must call `prepare_efile_payload`.

Deploy the patch to every exposed environment, then rotate and revoke previously exposed EFSP service keys and review proxy access logs. A source patch cannot revoke a key already obtained. This PR does not rotate keys or change deployed services. Confirm the deployed EFSP and Gotenberg versions and outbound network policies separately; the audit did not establish production metadata theft or read live infrastructure settings.

Regression coverage uses CSRF-enforcing clients, synthetic accounts, owned and foreign draft documents, and mocked external storage/HTTP. It checks both forwarding boundaries, rejected traversal, nested attachment removal, and successful same-origin login. Bandit alone does not detect these trust-boundary failures.
30 changes: 16 additions & 14 deletions efile_app/efile/api/auth_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from django.conf import settings
from django.contrib.auth import authenticate, login
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from requests.exceptions import RequestException, Timeout

Expand All @@ -18,6 +17,7 @@
from efile.services.court_payments import BANK_ACCOUNT_TYPE, bank_payment_policy, policy_message
from efile.services.current_drafts import get_current_draft
from efile.services.payment_accounts import is_active
from efile.utils.efsp_security import require_efsp_login, validated_jurisdiction
from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request

from .base import APIResponseMixin
Expand All @@ -30,15 +30,18 @@ class AuthAPIViews(APIResponseMixin):

@staticmethod
@require_http_methods(["POST"])
@csrf_exempt
def user_login(request):
"""Handle user login"""
if request.content_type != "application/json":
return AuthAPIViews.error_response("Expected application/json", 415)
try:
data = json.loads(request.body)
if not isinstance(data, dict):
return AuthAPIViews.error_response("Expected a JSON object", 400)

username = data.get("username")
password = data.get("password")
jurisdiction = data.get("jurisdiction")
jurisdiction = validated_jurisdiction(data.get("jurisdiction"))

if not username or not password or not jurisdiction:
return AuthAPIViews.error_response("Username, password, and jurisdiction required")
Expand All @@ -64,14 +67,13 @@ def user_login(request):
else:
return AuthAPIViews.error_response("Invalid credentials", 401)

except json.JSONDecodeError as e:
return AuthAPIViews.error_response(f"Invalid JSON data: {str(e)}, {request.body}")
except (ValueError, UnicodeDecodeError):
return AuthAPIViews.error_response("Invalid login data", 400)
except Exception as e:
return AuthAPIViews.error_response(f"Error: {str(e)}")

@staticmethod
@require_http_methods(["POST"])
@csrf_exempt
def user_logout(request):
"""Handle user logout"""
from efile.authentication import SuffolkEFileBackend
Expand All @@ -84,6 +86,7 @@ def user_logout(request):

@staticmethod
@require_http_methods(["GET"])
@require_efsp_login
def user_profile(request):
"""Get current user profile from external Suffolk eFile API"""
try:
Expand Down Expand Up @@ -154,22 +157,20 @@ def external_profile(request):

@staticmethod
@require_http_methods(["GET"])
@require_efsp_login
def tyler_token(request):
"""Get Tyler token and API key for external form submissions"""
"""Return only the signed-in user's token for the payment form."""
try:
# Get jurisdiction and Tyler token dynamically
jurisdiction = get_jurisdiction_from_request(request)
tyler_token = get_tyler_token(request, jurisdiction)
api_key = getattr(settings, "SUFFOLK_EFILE_API_KEY", None)

return AuthAPIViews.success_response(
{"tyler_token": tyler_token, "api_key": api_key, "state": jurisdiction}
)
return AuthAPIViews.success_response({"tyler_token": tyler_token, "state": jurisdiction})
except Exception as e:
return AuthAPIViews.error_response(f"Error: {str(e)}")

@staticmethod
@require_http_methods(["GET"])
@require_efsp_login
def payment_accounts(request):
"""Get payment accounts from Suffolk eFile API with proper authentication"""
try:
Expand All @@ -196,7 +197,7 @@ def payment_accounts(request):

url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/payments/payment-accounts/"
logger.debug("GET %s header keys=%s", url, list(headers.keys()))
api_response = requests.get(url, headers=headers, timeout=10)
api_response = requests.get(url, headers=headers, timeout=10, allow_redirects=False)
logger.debug(
"Payment accounts response: status=%s content_type=%s",
api_response.status_code,
Expand Down Expand Up @@ -251,6 +252,7 @@ def payment_accounts(request):

@staticmethod
@require_http_methods(["GET"])
@require_efsp_login
def payment_account_types(request):
"""List the court's payment account types (e.g. "CC", "WV") with names.

Expand All @@ -273,7 +275,7 @@ def payment_account_types(request):

url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/payments/types"
logger.debug("GET %s header keys=%s", url, list(headers.keys()))
api_response = requests.get(url, headers=headers, timeout=10)
api_response = requests.get(url, headers=headers, timeout=10, allow_redirects=False)

if api_response.status_code == 200:
return AuthAPIViews.success_response(api_response.json())
Expand Down
14 changes: 9 additions & 5 deletions efile_app/efile/api/filing_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
import requests
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods

from efile.utils.efsp_security import require_efsp_login, validated_code, validated_jurisdiction
from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request

from ..services.court_payments import payment_error, validate_filing_payment
Expand Down Expand Up @@ -51,6 +51,7 @@ def list_filing_data(request, jurisdiction, *, start_date=None):
accidentally validate a browser-supplied case ID instead.
"""

jurisdiction = validated_jurisdiction(jurisdiction)
api_url = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}/filingreview/courts/0/filings"
headers = get_headers()
tyler_token = get_tyler_token(request, jurisdiction)
Expand All @@ -64,6 +65,7 @@ def list_filing_data(request, jurisdiction, *, start_date=None):
params={"start_date": start_date or None, "before_date": None},
headers=headers,
timeout=30,
allow_redirects=False,
)
logger.debug(
"Get filings response: status=%s content_type=%s",
Expand Down Expand Up @@ -97,6 +99,7 @@ class FilingAPIViews(APIResponseMixin):

@staticmethod
@require_http_methods(["GET"])
@require_efsp_login
def get_filings(request):
try:
# Get all filings in all courts between now and a specified time
Expand Down Expand Up @@ -148,7 +151,7 @@ def convert_filing_data(filing):

@staticmethod
@require_http_methods(["POST"])
@csrf_exempt
@require_efsp_login(session_jurisdiction=True)
def payment_fees(request):
try:
logger.info("Test in payment fees!")
Expand All @@ -157,14 +160,15 @@ def payment_fees(request):
if not efile_data:
return JsonResponse({"success": False, "error": "No efile data provided in request"}, status=400)

jurisdiction_id = request.session.get("jurisdiction")
jurisdiction_id = validated_jurisdiction(request.session.get("jurisdiction"))
auth_tokens = request.session.get("auth_tokens", {})
case_data = get_case_data(request, jurisdiction_id)
court_id = case_data.get("court", "")

draft = get_current_draft(request, jurisdiction=jurisdiction_id, resume_latest=False)
if draft is not None:
court_id = draft.court_code
court_id = validated_code(court_id)
account_id = str(data.get("payment_account_id") or (draft.selected_payment_account_id if draft else ""))
try:
validate_filing_payment(
Expand Down Expand Up @@ -213,13 +217,13 @@ def payment_fees(request):
priced_current_draft = draft is not None and bool(inputs_token) and fee_inputs_token(draft) == inputs_token

logger.info(f"Making request!: {url}")
response = requests.post(url, json=efile_data, headers=headers, timeout=60)
response = requests.post(url, json=efile_data, headers=headers, timeout=60, allow_redirects=False)
logger.info(f"Made request: {response.status_code}")

if response.status_code == 200 or response.status_code == 201:
response_data = response.json()

logger.info(f"Sending back: {response_data}")
logger.debug("Fee calculation succeeded")
# Kept on the draft with what it was priced on, so Review shows
# it only while it still describes the filing. A quote for a
# version of the filing that has since changed is not kept.
Expand Down
25 changes: 15 additions & 10 deletions efile_app/efile/api/suffolk_api_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
import requests
from django.conf import settings
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from requests.exceptions import RequestException

from efile.utils.case_data_utils import get_case_data, update_case_data
from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request, get_jurisdiction_token
from efile.utils.efsp_security import validated_code, validated_jurisdiction
from efile.utils.jurisdiction_stuff import get_jurisdiction_from_request, get_jurisdiction_token, has_jurisdiction_login
from efile.utils.proxy_connection import get_headers

logger = logging.getLogger(__name__)
Expand All @@ -34,7 +34,6 @@ def set_access_control_headers(response):
return response


@csrf_exempt
@require_http_methods(["GET", "POST", "OPTIONS"])
def lookup_case(request):
"""
Expand Down Expand Up @@ -73,6 +72,10 @@ def lookup_case(request):
state = jurisdiction.lower()
else:
state = get_jurisdiction_from_request(request)
state = validated_jurisdiction(state)
court = validated_code(court)
if not has_jurisdiction_login(request, state):
return JsonResponse({"success": False, "error": "Sign in to this jurisdiction first."}, status=401)
api_url = f"{settings.EFSP_URL}/jurisdictions/{state}/cases/courts/{court}/cases"

# Add query parameter for docket number
Expand All @@ -95,7 +98,7 @@ def lookup_case(request):
)

# Make the API request - using GET with query parameters
response = requests.get(api_url, params=params, headers=headers, timeout=30)
response = requests.get(api_url, params=params, headers=headers, timeout=30, allow_redirects=False)

response.raise_for_status()
api_data = response.json()
Expand All @@ -119,8 +122,8 @@ def lookup_case(request):
# Extract case category
if "caseCategoryText" in case_value and "value" in case_value["caseCategoryText"]:
case_info["caseCategoryCode"] = case_value["caseCategoryText"]["value"]
api_url = f"{settings.EFSP_URL}/jurisdictions/{state}/codes/courts/{court}/categories/{case_info['caseCategoryCode']}"
category_resp = requests.get(api_url, headers=headers, timeout=30)
api_url = f"{settings.EFSP_URL}/jurisdictions/{state}/codes/courts/{court}/categories/{validated_code(case_info['caseCategoryCode'])}"
category_resp = requests.get(api_url, headers=headers, timeout=30, allow_redirects=False)
if category_resp.ok:
data = category_resp.json()
case_info["caseCategoryName"] = data["name"]
Expand All @@ -142,8 +145,10 @@ def lookup_case(request):
if "value" in item and "caseTypeText" in item["value"]:
if "value" in item["value"]["caseTypeText"]:
case_info["caseTypeCode"] = item["value"]["caseTypeText"]["value"]
api_url = f"{settings.EFSP_URL}/jurisdictions/{state}/codes/courts/{court}/case_types/{case_info['caseTypeCode']}"
case_resp = requests.get(api_url, headers=headers, timeout=30)
api_url = f"{settings.EFSP_URL}/jurisdictions/{state}/codes/courts/{court}/case_types/{validated_code(case_info['caseTypeCode'])}"
case_resp = requests.get(
api_url, headers=headers, timeout=30, allow_redirects=False
)
if case_resp.ok:
data = case_resp.json()
case_info["caseTypeName"] = data["name"]
Expand Down Expand Up @@ -174,9 +179,9 @@ def lookup_case(request):

return set_access_control_headers(response)

except json.JSONDecodeError:
except (ValueError, UnicodeDecodeError):
# This only applies to POST requests
response = JsonResponse({"success": False, "error": "Invalid JSON in request body"}, status=400)
response = JsonResponse({"success": False, "error": "Invalid jurisdiction, court, or request data"}, status=400)
return set_access_control_headers(response)

except RequestException as e:
Expand Down
6 changes: 4 additions & 2 deletions efile_app/efile/services/account_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,10 @@ def fetch_account_profile(request, jurisdiction, timeout=10):
headers = _headers(request, jurisdiction)
base = f"{settings.EFSP_URL}/jurisdictions/{jurisdiction}"
try:
firm_response = requests.get(f"{base}/firmattorneyservice/firm", headers=headers, timeout=timeout)
self_response = requests.get(f"{base}/adminusers/user", headers=headers, timeout=timeout)
firm_response = requests.get(
f"{base}/firmattorneyservice/firm", headers=headers, timeout=timeout, allow_redirects=False
)
self_response = requests.get(f"{base}/adminusers/user", headers=headers, timeout=timeout, allow_redirects=False)
except RequestException:
logger.exception("Could not reach the e-filing service for the account profile")
return None
Expand Down
25 changes: 2 additions & 23 deletions efile_app/efile/services/corrections.py
Original file line number Diff line number Diff line change
Expand Up @@ -376,8 +376,7 @@ def correction_readiness(draft):

def refresh_correction_documents(payload, draft):
"""Use the reviewed durable bytes and metadata, never a stale browser URL."""
from efile.services.efsp_payload import PayloadValidationError
from efile.utils.s3_upload_handler import S3UploadHandler
from efile.services.efsp_payload import bind_filing_documents

payload.update(
efile_case_category=draft.case_category_code,
Expand All @@ -394,24 +393,4 @@ def refresh_correction_documents(payload, draft):
payload["docket_number"] = draft.docket_number
else:
payload.pop("docket_number", None)
documents = list(draft.documents.order_by("role", "sort_order", "pk"))
bundles = payload.get("al_court_bundle")
if not isinstance(bundles, list) or len(bundles) != len(documents):
raise PayloadValidationError("The documents changed. Reload Review before submitting.")
handler = S3UploadHandler()
for bundle, document in zip(bundles, documents, strict=True):
if not isinstance(bundle, dict) or not document.s3_key:
raise PayloadValidationError("Replace the unavailable PDF before submitting.")
try:
url = handler.get_public_url(document.s3_key)
except (BotoCoreError, ClientError, RuntimeError) as error:
raise PayloadValidationError("Document storage is unavailable. Retry before submitting.") from error
bundle.update(
data_url=url,
filename=document.name,
filing_type=document.filing_type_code,
document_type=document.document_type_code,
filing_component=document.filing_component_code,
optional_services=document.requested_optional_services,
courtesy_copies=[document.courtesy_copy_email] if document.courtesy_copy_email else [],
)
bind_filing_documents(payload, draft)
4 changes: 4 additions & 0 deletions efile_app/efile/services/court_payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from efile.services.draft_urls import draft_url
from efile.services.payment_accounts import account_headers, payment_accounts
from efile.utils.efsp_security import validated_code, validated_jurisdiction

BANK_ACCOUNT_TYPE = "BankAccount"
RESTRICTED_MESSAGE = (
Expand All @@ -25,10 +26,13 @@ def bank_payment_policy(jurisdiction, court):
if not court:
return "unknown"
try:
jurisdiction = validated_jurisdiction(jurisdiction)
court = validated_code(court)
response = requests.get(
f"{settings.EFSP_URL}/jurisdictions/{quote(jurisdiction, safe='')}/codes/courts/{quote(court, safe='')}/codes",
headers=account_headers(jurisdiction, ""),
timeout=10,
allow_redirects=False,
)
response.raise_for_status()
data = response.json()
Expand Down
2 changes: 1 addition & 1 deletion efile_app/efile/services/drafts.py
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ def write_upload_data(


def _document_file(doc: FilingDocument, urls: dict[int, str] | None = None) -> dict[str, Any]:
file_obj: dict[str, Any] = {}
file_obj: dict[str, Any] = {"document_id": doc.pk}
_put(file_obj, "name", doc.name)
_put(file_obj, "url", (urls or {}).get(doc.pk, doc.public_url))
_put(file_obj, "s3_key", doc.s3_key)
Expand Down
Loading
Loading