2026-01-27 08:24:22 +00:00
|
|
|
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
|
|
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
# See the LICENSE file for details.
|
|
|
|
|
|
2025-09-16 16:07:08 +00:00
|
|
|
# Django imports
|
|
|
|
|
from django.utils.http import url_has_allowed_host_and_scheme
|
|
|
|
|
from django.conf import settings
|
|
|
|
|
|
2025-04-02 17:39:27 +00:00
|
|
|
# Python imports
|
2026-04-20 10:03:30 +00:00
|
|
|
import os
|
2025-04-02 17:39:27 +00:00
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
2025-09-16 16:07:08 +00:00
|
|
|
|
2026-04-20 10:03:30 +00:00
|
|
|
def sanitize_filename(filename):
|
|
|
|
|
"""
|
|
|
|
|
Sanitize a filename to prevent path traversal attacks.
|
|
|
|
|
|
2026-07-28 12:22:46 +00:00
|
|
|
Strips directory components, path traversal sequences, and control
|
|
|
|
|
characters from user-supplied filenames used in upload paths and S3 object keys.
|
2026-04-20 10:03:30 +00:00
|
|
|
|
|
|
|
|
Returns None for empty/missing input so callers can still validate
|
|
|
|
|
that a filename was provided.
|
|
|
|
|
"""
|
|
|
|
|
if not filename or not isinstance(filename, str):
|
|
|
|
|
return None
|
|
|
|
|
|
2026-07-28 12:22:46 +00:00
|
|
|
# Strip ASCII control characters (0-31 and 127), including null bytes
|
|
|
|
|
filename = "".join(char for char in filename if not (ord(char) < 32 or ord(char) == 127))
|
2026-04-20 10:03:30 +00:00
|
|
|
|
|
|
|
|
# Normalize backslashes so os.path.basename handles Windows-style paths on POSIX
|
|
|
|
|
filename = filename.replace("\\", "/")
|
|
|
|
|
|
|
|
|
|
# Take only the basename to remove any directory components
|
|
|
|
|
filename = os.path.basename(filename)
|
|
|
|
|
|
|
|
|
|
# Remove any remaining path traversal sequences
|
|
|
|
|
filename = filename.replace("..", "")
|
|
|
|
|
|
|
|
|
|
# Strip whitespace before removing leading dots so " .env" is caught
|
|
|
|
|
filename = filename.strip()
|
|
|
|
|
|
|
|
|
|
# Remove leading dots (hidden files)
|
|
|
|
|
filename = filename.lstrip(".")
|
|
|
|
|
|
|
|
|
|
# Strip any remaining whitespace
|
|
|
|
|
filename = filename.strip()
|
|
|
|
|
|
|
|
|
|
if not filename:
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
return filename
|
|
|
|
|
|
|
|
|
|
|
2025-09-15 18:31:06 +00:00
|
|
|
def _contains_suspicious_patterns(path: str) -> bool:
|
|
|
|
|
"""
|
|
|
|
|
Check for suspicious patterns that might indicate malicious intent.
|
2025-09-29 13:45:32 +00:00
|
|
|
|
2025-09-15 18:31:06 +00:00
|
|
|
Args:
|
|
|
|
|
path (str): The path to check
|
2025-09-29 13:45:32 +00:00
|
|
|
|
2025-09-15 18:31:06 +00:00
|
|
|
Returns:
|
|
|
|
|
bool: True if suspicious patterns found, False otherwise
|
|
|
|
|
"""
|
|
|
|
|
suspicious_patterns = [
|
2025-09-29 13:45:32 +00:00
|
|
|
r"javascript:", # JavaScript injection
|
|
|
|
|
r"data:", # Data URLs
|
|
|
|
|
r"vbscript:", # VBScript injection
|
|
|
|
|
r"file:", # File protocol
|
|
|
|
|
r"ftp:", # FTP protocol
|
|
|
|
|
r"%2e%2e", # URL encoded path traversal
|
|
|
|
|
r"%2f%2f", # URL encoded double slash
|
|
|
|
|
r"%5c%5c", # URL encoded backslashes
|
|
|
|
|
r"<script", # Script tags
|
|
|
|
|
r"<iframe", # Iframe tags
|
|
|
|
|
r"<object", # Object tags
|
|
|
|
|
r"<embed", # Embed tags
|
|
|
|
|
r"<form", # Form tags
|
|
|
|
|
r"onload=", # Event handlers
|
|
|
|
|
r"onerror=", # Event handlers
|
|
|
|
|
r"onclick=", # Event handlers
|
2025-09-15 18:31:06 +00:00
|
|
|
]
|
2025-09-29 13:45:32 +00:00
|
|
|
|
2025-09-15 18:31:06 +00:00
|
|
|
path_lower = path.lower()
|
|
|
|
|
for pattern in suspicious_patterns:
|
|
|
|
|
if pattern in path_lower:
|
|
|
|
|
return True
|
2025-09-29 13:45:32 +00:00
|
|
|
|
2025-09-15 18:31:06 +00:00
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
2025-09-16 16:07:08 +00:00
|
|
|
def get_allowed_hosts() -> list[str]:
|
|
|
|
|
"""Get the allowed hosts from the settings."""
|
2025-09-17 10:43:32 +00:00
|
|
|
allowed_hosts = []
|
fix: Add docker pytest runner and fix bugs the suite surfaced (#9138)
* chore(api): add docker compose test runner
Adds docker-compose-test.yml at the repo root that boots an isolated
postgres / valkey / rabbitmq / minio stack with health checks and tmpfs
data dirs, then runs pytest against it and exits. Includes a usage doc
under apps/api/tests/RUNNING_TESTS.md and a pointer in AGENTS.md.
Prereq: ./setup.sh (generates apps/api/.env).
Usage:
docker compose -f docker-compose-test.yml up --build \
--abort-on-container-exit --exit-code-from api-tests
docker compose -f docker-compose-test.yml down -v
* fix(api): correct bugs surfaced by the pytest suite
Five small bugs caught by enabling the pytest contract suite end-to-end.
Each is independently justifiable:
- api/serializers/cycle.py + api/views/cycle.py: CycleCreateSerializer.validate
required project_id in the request body, but the view only ever passes
it through the URL kwarg. Cycle create/update via the public API was
returning 400 "Project ID is required". Read project_id from
serializer context (passed by the view) in addition to body/instance.
- app/views/api.py: ApiTokenEndpoint.get(pk) and patch(pk) did not filter
out is_service=True tokens, so a user could read and modify service
tokens through the user token endpoint. The list mode and delete
already filter is_service=False; aligned the other two.
- bgtasks/work_item_link_task.py: validate_url_ip checked hostname before
scheme, so file:///etc/passwd raised "No hostname found" instead of
the documented "Only HTTP and HTTPS" error. Swapped the order so the
scheme guard matches the docstring intent.
- utils/path_validator.py: get_allowed_hosts used `WEB_URL or APP_BASE_URL`
so when both are configured to different hosts (the standard local
setup: WEB_URL=:8000, APP_BASE_URL=:3000), only one was added to the
allow-list. Redirects to APP_BASE_URL then had their next_path stripped
because the host wasn't allowed. Include every configured base URL.
* chore(api): align pytest tests with current behavior, clear warnings
Test-side fixes paired with the product fixes in the previous commit, plus
deprecation cleanup that drops the test run from 104 warnings to 0.
Tests:
- tests/contract/api/test_cycles.py: project fixture sets cycle_view=True;
the Project model defaults the flag to False, so cycle create/update
always tripped "Cycles are not enabled for this project".
- tests/contract/app/test_authentication.py: next_path uses "/workspaces"
(validate_next_path rejects values without a leading slash and returns
empty, which dropped the path from the redirect URL).
- tests/unit/bg_tasks/test_copy_s3_objects.py: mocked sync_with_external_service
now returns description_json; the task unconditionally writes the value
back to the Issue, and Issue.description_json is NOT NULL on UPDATE.
- tests/unit/utils/test_url.py: three length-limit tests placed the URL at
char 970+ on a single line, which contains_url truncates away as ReDoS
defense (500-char per-line cap). Restructured to keep test intent intact
while staying inside the per-line window.
Warning cleanup (104 → 0):
- settings/common.py: removed USE_L10N=True (deprecated in Django 4.0,
removed in 5.0; default is True).
- celery.py, settings/local.py, settings/production.py: pythonjsonlogger
moved jsonlogger → json; update the import / formatter path.
2026-05-25 19:51:37 +00:00
|
|
|
# Include every configured base URL; WEB_URL and APP_BASE_URL may differ
|
|
|
|
|
# (e.g. WEB_URL points at the API host, APP_BASE_URL at the web app), and
|
|
|
|
|
# both need to be allowed for redirects to either origin to pass safety checks.
|
|
|
|
|
for setting in (settings.WEB_URL, settings.APP_BASE_URL, settings.ADMIN_BASE_URL, settings.SPACE_BASE_URL):
|
|
|
|
|
if setting:
|
|
|
|
|
host = urlparse(setting).netloc
|
|
|
|
|
if host and host not in allowed_hosts:
|
|
|
|
|
allowed_hosts.append(host)
|
2025-09-16 16:07:08 +00:00
|
|
|
return allowed_hosts
|
|
|
|
|
|
|
|
|
|
|
2025-04-02 17:39:27 +00:00
|
|
|
def validate_next_path(next_path: str) -> str:
|
2025-09-11 08:48:13 +00:00
|
|
|
"""Validates that next_path is a safe relative path for redirection."""
|
|
|
|
|
# Browsers interpret backslashes as forward slashes. Remove all backslashes.
|
2025-09-15 18:31:06 +00:00
|
|
|
if not next_path or not isinstance(next_path, str):
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
# Limit input length to prevent DoS attacks
|
|
|
|
|
if len(next_path) > 500:
|
|
|
|
|
return ""
|
|
|
|
|
|
2025-09-11 08:48:13 +00:00
|
|
|
next_path = next_path.replace("\\", "")
|
2025-04-02 17:39:27 +00:00
|
|
|
parsed_url = urlparse(next_path)
|
|
|
|
|
|
2025-09-11 08:48:13 +00:00
|
|
|
# Block absolute URLs or anything with scheme/netloc
|
2025-04-02 17:39:27 +00:00
|
|
|
if parsed_url.scheme or parsed_url.netloc:
|
|
|
|
|
next_path = parsed_url.path # Extract only the path component
|
|
|
|
|
|
2025-09-11 08:48:13 +00:00
|
|
|
# Must start with a forward slash and not be empty
|
|
|
|
|
if not next_path or not next_path.startswith("/"):
|
2025-04-02 17:39:27 +00:00
|
|
|
return ""
|
|
|
|
|
|
2025-09-11 08:48:13 +00:00
|
|
|
# Prevent path traversal
|
2025-04-02 17:39:27 +00:00
|
|
|
if ".." in next_path:
|
|
|
|
|
return ""
|
|
|
|
|
|
2025-09-15 18:31:06 +00:00
|
|
|
# Additional security checks
|
|
|
|
|
if _contains_suspicious_patterns(next_path):
|
|
|
|
|
return ""
|
|
|
|
|
|
2025-04-02 17:39:27 +00:00
|
|
|
return next_path
|
2025-09-15 18:31:06 +00:00
|
|
|
|
2025-09-29 13:45:32 +00:00
|
|
|
|
2025-09-15 18:31:06 +00:00
|
|
|
def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) -> str:
|
|
|
|
|
"""
|
|
|
|
|
Safely construct a redirect URL with validated next_path.
|
2025-09-29 13:45:32 +00:00
|
|
|
|
2025-09-15 18:31:06 +00:00
|
|
|
Args:
|
|
|
|
|
base_url (str): The base URL to redirect to
|
|
|
|
|
next_path (str): The next path to append
|
|
|
|
|
params (dict): The parameters to append
|
|
|
|
|
Returns:
|
|
|
|
|
str: The safe redirect URL
|
|
|
|
|
"""
|
2025-09-29 13:45:32 +00:00
|
|
|
from urllib.parse import urlencode
|
2025-09-15 18:31:06 +00:00
|
|
|
|
|
|
|
|
# Validate the next path
|
|
|
|
|
validated_path = validate_next_path(next_path)
|
2025-09-17 10:43:32 +00:00
|
|
|
|
2025-09-15 18:31:06 +00:00
|
|
|
# Add the next path to the parameters
|
2025-09-29 13:45:32 +00:00
|
|
|
base_url = base_url.rstrip("/")
|
2025-09-17 10:43:32 +00:00
|
|
|
|
|
|
|
|
# Prepare the query parameters
|
|
|
|
|
query_parts = []
|
|
|
|
|
encoded_params = ""
|
|
|
|
|
|
|
|
|
|
# Add the next path to the parameters
|
|
|
|
|
if validated_path:
|
|
|
|
|
query_parts.append(f"next_path={validated_path}")
|
|
|
|
|
|
|
|
|
|
# Add additional parameters
|
2025-09-16 13:14:26 +00:00
|
|
|
if params:
|
|
|
|
|
encoded_params = urlencode(params)
|
2025-09-17 10:43:32 +00:00
|
|
|
query_parts.append(encoded_params)
|
|
|
|
|
|
|
|
|
|
# Construct the url query string
|
|
|
|
|
if query_parts:
|
|
|
|
|
query_string = "&".join(query_parts)
|
|
|
|
|
url = f"{base_url}/?{query_string}"
|
2025-09-16 16:07:08 +00:00
|
|
|
else:
|
2025-09-17 10:43:32 +00:00
|
|
|
url = base_url
|
2025-09-15 18:31:06 +00:00
|
|
|
|
2025-09-16 16:07:08 +00:00
|
|
|
# Check if the URL is allowed
|
|
|
|
|
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
|
|
|
|
|
return url
|
2025-09-29 13:45:32 +00:00
|
|
|
|
2025-09-16 16:07:08 +00:00
|
|
|
# Return the base URL if the URL is not allowed
|
2025-09-29 13:45:32 +00:00
|
|
|
return base_url + (f"?{encoded_params}" if encoded_params else "")
|