|
|
|
|
@ -14,6 +14,37 @@ ALLOWED_MULTI_PARAMS = {
|
|
|
|
|
"exclude[]",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Whitelist of ALL allowed query parameter names across the application.
|
|
|
|
|
# Any param NOT in this set will be rejected.
|
|
|
|
|
ALLOWED_QUERY_PARAMS = {
|
|
|
|
|
# CommonParameters (from database/service.py common_parameters)
|
|
|
|
|
"currentUser",
|
|
|
|
|
"page",
|
|
|
|
|
"itemsPerPage",
|
|
|
|
|
"q",
|
|
|
|
|
"filter",
|
|
|
|
|
"sortBy[]",
|
|
|
|
|
"descending[]",
|
|
|
|
|
"all",
|
|
|
|
|
# ListQueryParams / QueryParams used across routers
|
|
|
|
|
"items_per_page",
|
|
|
|
|
"search",
|
|
|
|
|
# equipment_master specific
|
|
|
|
|
"parent_id",
|
|
|
|
|
# masterdata_simulations / plant_transaction_data_simulations specific
|
|
|
|
|
"simulation_id",
|
|
|
|
|
# exclude
|
|
|
|
|
"exclude[]",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# Query params that are ONLY allowed for "write" operations (read operations use ALLOWED_QUERY_PARAMS).
|
|
|
|
|
# For GET/POST/PUT/etc, whitelisting still applies.
|
|
|
|
|
WRITE_METHOD_ALLOWED_PARAMS = {
|
|
|
|
|
# Only auth/session params are allowed in query for write methods.
|
|
|
|
|
# Data values (like simulation_id) must be in the JSON body for these methods.
|
|
|
|
|
"currentUser",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
MAX_QUERY_PARAMS = 50
|
|
|
|
|
MAX_QUERY_LENGTH = 2000
|
|
|
|
|
MAX_JSON_BODY_SIZE = 1024 * 100 # 100 KB
|
|
|
|
|
@ -62,31 +93,31 @@ def has_control_chars(value: str) -> bool:
|
|
|
|
|
def inspect_value(value: str, source: str):
|
|
|
|
|
if XSS_PATTERN.search(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Potential XSS payload detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if SQLI_PATTERN.search(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Potential SQL injection payload detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if RCE_PATTERN.search(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Potential RCE payload detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if TRAVERSAL_PATTERN.search(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Potential Path Traversal payload detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if has_control_chars(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Invalid control characters detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@ -96,7 +127,7 @@ def inspect_json(obj, path="body"):
|
|
|
|
|
for key, value in obj.items():
|
|
|
|
|
if key in FORBIDDEN_JSON_KEYS:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Forbidden JSON key detected: {path}.{key}",
|
|
|
|
|
)
|
|
|
|
|
inspect_json(value, f"{path}.{key}")
|
|
|
|
|
@ -126,12 +157,28 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
|
|
|
|
|
if len(params) > MAX_QUERY_PARAMS:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail="Too many query parameters",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 2. Duplicate parameters
|
|
|
|
|
# 2. Query param whitelist
|
|
|
|
|
# -------------------------
|
|
|
|
|
# For GET, we allow data parameters like page, search, etc.
|
|
|
|
|
# For POST, PUT, DELETE, PATCH, we ONLY allow auth/session params.
|
|
|
|
|
active_whitelist = ALLOWED_QUERY_PARAMS if request.method == "GET" else WRITE_METHOD_ALLOWED_PARAMS
|
|
|
|
|
|
|
|
|
|
unknown_params = [
|
|
|
|
|
key for key, _ in params if key not in active_whitelist
|
|
|
|
|
]
|
|
|
|
|
if unknown_params:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Unknown query parameters are not allowed for {request.method} request: {unknown_params}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 3. Duplicate parameters
|
|
|
|
|
# -------------------------
|
|
|
|
|
counter = Counter(key for key, _ in params)
|
|
|
|
|
duplicates = [
|
|
|
|
|
@ -141,12 +188,40 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
|
|
|
|
|
if duplicates:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Duplicate query parameters are not allowed: {duplicates}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 3. Query param inspection
|
|
|
|
|
# 4. Single source enforcement
|
|
|
|
|
# Ensuring data comes from ONLY one source (Query OR Body).
|
|
|
|
|
# -------------------------
|
|
|
|
|
content_type = request.headers.get("content-type", "")
|
|
|
|
|
has_json_body = content_type.startswith("application/json")
|
|
|
|
|
|
|
|
|
|
# Check for data parameters in query (anything whitelisted as 'data' but not 'session/auth')
|
|
|
|
|
data_params_in_query = [
|
|
|
|
|
key for key, _ in params
|
|
|
|
|
if key in ALLOWED_QUERY_PARAMS and key not in WRITE_METHOD_ALLOWED_PARAMS
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
if has_json_body:
|
|
|
|
|
# If sending JSON body, we forbid any data in query string (one source only)
|
|
|
|
|
if data_params_in_query:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Single source enforcement: Data received from both JSON body and query string ({data_params_in_query}). Use only one source.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Special case: GET with body is discouraged/forbidden in many strict security contexts
|
|
|
|
|
if request.method == "GET":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail="GET requests must use query parameters, not JSON body.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 5. Query param inspection
|
|
|
|
|
# -------------------------
|
|
|
|
|
pagination_size_keys = {"size", "itemsPerPage", "per_page", "limit", "items_per_page"}
|
|
|
|
|
for key, value in params:
|
|
|
|
|
@ -159,24 +234,23 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
size_val = int(value)
|
|
|
|
|
if size_val > 50:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Pagination size '{key}' cannot exceed 50",
|
|
|
|
|
)
|
|
|
|
|
if size_val % 5 != 0:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Pagination size '{key}' must be a multiple of 5",
|
|
|
|
|
)
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Pagination size '{key}' must be an integer",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 4. Content-Type sanity
|
|
|
|
|
# 6. Content-Type sanity
|
|
|
|
|
# -------------------------
|
|
|
|
|
content_type = request.headers.get("content-type", "")
|
|
|
|
|
if content_type and not any(
|
|
|
|
|
content_type.startswith(t)
|
|
|
|
|
for t in (
|
|
|
|
|
@ -191,7 +265,7 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 5. JSON body inspection
|
|
|
|
|
# 7. JSON body inspection
|
|
|
|
|
# -------------------------
|
|
|
|
|
if content_type.startswith("application/json"):
|
|
|
|
|
body = await request.body()
|
|
|
|
|
@ -207,7 +281,7 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
payload = json.loads(body)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=400,
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail="Invalid JSON body",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|