|
|
|
|
@ -14,37 +14,6 @@ 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
|
|
|
|
|
@ -93,31 +62,31 @@ def has_control_chars(value: str) -> bool:
|
|
|
|
|
def inspect_value(value: str, source: str):
|
|
|
|
|
if XSS_PATTERN.search(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Potential XSS payload detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if SQLI_PATTERN.search(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Potential SQL injection payload detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if RCE_PATTERN.search(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Potential RCE payload detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if TRAVERSAL_PATTERN.search(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Potential Path Traversal payload detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if has_control_chars(value):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Invalid control characters detected in {source}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@ -127,7 +96,7 @@ def inspect_json(obj, path="body"):
|
|
|
|
|
for key, value in obj.items():
|
|
|
|
|
if key in FORBIDDEN_JSON_KEYS:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Forbidden JSON key detected: {path}.{key}",
|
|
|
|
|
)
|
|
|
|
|
inspect_json(value, f"{path}.{key}")
|
|
|
|
|
@ -157,28 +126,12 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
|
|
|
|
|
if len(params) > MAX_QUERY_PARAMS:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Too many query 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
|
|
|
|
|
# 2. Duplicate parameters
|
|
|
|
|
# -------------------------
|
|
|
|
|
counter = Counter(key for key, _ in params)
|
|
|
|
|
duplicates = [
|
|
|
|
|
@ -188,77 +141,33 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
|
|
|
|
|
if duplicates:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail=f"Duplicate query parameters are not allowed: {duplicates}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 4. JSON body inspection & Single source enforcement
|
|
|
|
|
# Ensuring data comes from ONLY one source (Query OR Body).
|
|
|
|
|
# -------------------------
|
|
|
|
|
content_type = request.headers.get("content-type", "")
|
|
|
|
|
has_json_header = content_type.startswith("application/json")
|
|
|
|
|
|
|
|
|
|
# Read body now so we can check if it's actually empty
|
|
|
|
|
body = b""
|
|
|
|
|
if has_json_header:
|
|
|
|
|
body = await request.body()
|
|
|
|
|
|
|
|
|
|
# We consider it a "JSON body" source ONLY if it's not empty and not just "{}"
|
|
|
|
|
has_actual_json_body = has_json_header and body and body.strip() != b"{}"
|
|
|
|
|
|
|
|
|
|
# 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_actual_json_body:
|
|
|
|
|
# If sending actual 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 actual body is discouraged/forbidden
|
|
|
|
|
if request.method == "GET":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail="GET requests must use query parameters, not JSON body.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 5. Query param inspection
|
|
|
|
|
# 3. Query param inspection
|
|
|
|
|
# -------------------------
|
|
|
|
|
pagination_size_keys = {"size", "itemsPerPage", "per_page", "limit", "items_per_page"}
|
|
|
|
|
for key, value in params:
|
|
|
|
|
if value:
|
|
|
|
|
inspect_value(value, f"query param '{key}'")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Pagination constraint: multiples of 5, max 50
|
|
|
|
|
if key in pagination_size_keys and value:
|
|
|
|
|
try:
|
|
|
|
|
size_val = int(value)
|
|
|
|
|
if size_val > 50:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Pagination size '{key}' cannot exceed 50",
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Pagination size '{key}' cannot exceed 50")
|
|
|
|
|
if size_val % 5 != 0:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Pagination size '{key}' must be a multiple of 5",
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Pagination size '{key}' must be a multiple of 5")
|
|
|
|
|
except ValueError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
detail=f"Pagination size '{key}' must be an integer",
|
|
|
|
|
)
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Pagination size '{key}' must be an integer")
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 6. Content-Type sanity
|
|
|
|
|
# 4. Content-Type sanity
|
|
|
|
|
# -------------------------
|
|
|
|
|
content_type = request.headers.get("content-type", "")
|
|
|
|
|
if content_type and not any(
|
|
|
|
|
content_type.startswith(t)
|
|
|
|
|
for t in (
|
|
|
|
|
@ -273,22 +182,32 @@ class RequestValidationMiddleware(BaseHTTPMiddleware):
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# -------------------------
|
|
|
|
|
# 7. JSON body inspection & Re-injection
|
|
|
|
|
# 5. JSON body inspection
|
|
|
|
|
# -------------------------
|
|
|
|
|
if has_json_header:
|
|
|
|
|
if content_type.startswith("application/json"):
|
|
|
|
|
body = await request.body()
|
|
|
|
|
|
|
|
|
|
#if len(body) > MAX_JSON_BODY_SIZE:
|
|
|
|
|
# raise HTTPException(
|
|
|
|
|
# status_code=413,
|
|
|
|
|
# detail="JSON body too large",
|
|
|
|
|
# )
|
|
|
|
|
|
|
|
|
|
if body:
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(body)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=422,
|
|
|
|
|
status_code=400,
|
|
|
|
|
detail="Invalid JSON body",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
inspect_json(payload)
|
|
|
|
|
|
|
|
|
|
# Re-inject body for downstream handlers
|
|
|
|
|
async def receive():
|
|
|
|
|
return {"type": "http.request", "body": body}
|
|
|
|
|
|
|
|
|
|
request._receive = receive # noqa: protected-access
|
|
|
|
|
|
|
|
|
|
return await call_next(request)
|
|
|
|
|
|