-
Notifications
You must be signed in to change notification settings - Fork 5.7k
Expand file tree
/
Copy pathpath_validator.py
More file actions
201 lines (155 loc) 路 6.58 KB
/
Copy pathpath_validator.py
File metadata and controls
201 lines (155 loc) 路 6.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
# Django imports
from django.utils.http import url_has_allowed_host_and_scheme
from django.conf import settings
# Python imports
import os
from urllib.parse import urlparse
def sanitize_filename(filename):
"""
Sanitize a filename to prevent path traversal attacks.
Strips directory components, path traversal sequences, and control
characters from user-supplied filenames used in upload paths and S3 object keys.
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
# 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))
# 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
def _contains_suspicious_patterns(path: str) -> bool:
"""
Check for suspicious patterns that might indicate malicious intent.
Args:
path (str): The path to check
Returns:
bool: True if suspicious patterns found, False otherwise
"""
suspicious_patterns = [
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
]
path_lower = path.lower()
for pattern in suspicious_patterns:
if pattern in path_lower:
return True
return False
def get_allowed_hosts() -> list[str]:
"""Get the allowed hosts from the settings."""
allowed_hosts = []
# 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)
return allowed_hosts
def validate_next_path(next_path: str) -> str:
"""Validates that next_path is a safe relative path for redirection."""
# Browsers interpret backslashes as forward slashes. Remove all backslashes.
if not next_path or not isinstance(next_path, str):
return ""
# Limit input length to prevent DoS attacks
if len(next_path) > 500:
return ""
next_path = next_path.replace("\\", "")
# Browsers (per the WHATWG URL spec) strip every ASCII tab/CR/LF from a
# URL before parsing it, so "/\t/\t/evil.com" is what the browser
# actually navigates on, even though urlparse() sees a netloc-free,
# scheme-free string here and a literal .startswith("//") below would
# miss it too (the second character is a tab, not a slash). Strip them
# here so every check downstream sees what the browser will.
next_path = next_path.translate(str.maketrans("", "", "\t\r\n"))
parsed_url = urlparse(next_path)
# Block absolute URLs or anything with scheme/netloc
if parsed_url.scheme or parsed_url.netloc:
next_path = parsed_url.path # Extract only the path component
# Must start with a forward slash and not be empty
if not next_path or not next_path.startswith("/"):
return ""
# Reject authority-relative paths (//, ///, ////, ...). urlparse() only
# treats a leading "//" as a netloc when what follows still looks like a
# bare host (e.g. "//example.com/"); for "///example.com/" both scheme
# and netloc come back empty, so the branch above never fires and this
# string would otherwise sail through every check below unmodified. The
# browser itself still resolves any leading "//" as authority-relative
# against an http(s) base, navigating off-domain regardless of what
# urlparse() made of it server-side.
if next_path.startswith("//"):
return ""
# Prevent path traversal
if ".." in next_path:
return ""
# Additional security checks
if _contains_suspicious_patterns(next_path):
return ""
return next_path
def get_safe_redirect_url(base_url: str, next_path: str = "", params: dict = {}) -> str:
"""
Safely construct a redirect URL with validated next_path.
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
"""
from urllib.parse import urlencode
# Validate the next path
validated_path = validate_next_path(next_path)
# Add the next path to the parameters
base_url = base_url.rstrip("/")
# 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
if params:
encoded_params = urlencode(params)
query_parts.append(encoded_params)
# Construct the url query string
if query_parts:
query_string = "&".join(query_parts)
url = f"{base_url}/?{query_string}"
else:
url = base_url
# Check if the URL is allowed
if url_has_allowed_host_and_scheme(url, allowed_hosts=get_allowed_hosts()):
return url
# Return the base URL if the URL is not allowed
return base_url + (f"?{encoded_params}" if encoded_params else "")