mirror of
https://github.com/wanderer-industries/wanderer
synced 2025-12-11 10:15:41 +00:00
517 lines
13 KiB
Elixir
517 lines
13 KiB
Elixir
defmodule WandererAppWeb.Plugs.RequestValidator do
|
|
@moduledoc """
|
|
Comprehensive request validation and sanitization middleware.
|
|
|
|
This plug provides:
|
|
- Input validation against schemas
|
|
- Parameter sanitization (XSS, SQL injection prevention)
|
|
- Request size limits
|
|
- Content type validation
|
|
- Rate limiting integration
|
|
- Malicious pattern detection
|
|
"""
|
|
|
|
import Plug.Conn
|
|
|
|
alias WandererApp.SecurityAudit
|
|
alias WandererApp.Audit.RequestContext
|
|
|
|
# 10MB
|
|
@max_request_size 10 * 1024 * 1024
|
|
@max_param_length 10_000
|
|
@max_nested_depth 10
|
|
|
|
# Common XSS patterns to detect
|
|
@xss_patterns [
|
|
~r/<script[^>]*>.*?<\/script>/i,
|
|
~r/<iframe[^>]*>.*?<\/iframe>/i,
|
|
~r/javascript:/i,
|
|
~r/on\w+\s*=/i,
|
|
~r/<object[^>]*>.*?<\/object>/i,
|
|
~r/<embed[^>]*>/i,
|
|
~r/expression\s*\(/i,
|
|
~r/vbscript:/i,
|
|
~r/data:text\/html/i
|
|
]
|
|
|
|
# SQL injection patterns
|
|
@sql_injection_patterns [
|
|
~r/(\bunion\b.*\bselect\b)|(\bselect\b.*\bunion\b)/i,
|
|
~r/(\bor\b\s+[\w\'"]+\s*=\s*[\w\'"]+)|(\band\b\s+[\w\'"]+\s*=\s*[\w\'"]+)/i,
|
|
~r/(\bdrop\b\s+\btable\b)|(\bdelete\b\s+\bfrom\b)|(\binsert\b\s+\binto\b)/i,
|
|
~r/(\bexec\b\s*\()|(\bexecute\b\s*\()/i,
|
|
~r/(\bsp_\w+)|(\bxp_\w+)/i,
|
|
~r/(\bconcat\b\s*\()|(\bchar\b\s*\()/i,
|
|
~r/(\bhaving\b\s+[\w\'"]+\s*=)|(\bgroup\b\s+\bby\b\s+[\w\'"]+\s*=)/i,
|
|
~r/(\bwaitfor\b\s+\bdelay\b)|(\bwaitfor\b\s+\btime\b)/i
|
|
]
|
|
|
|
# Path traversal patterns
|
|
@path_traversal_patterns [
|
|
~r/\.\.\/|\.\.\\|%2e%2e%2f|%2e%2e\\/i,
|
|
~r/\/etc\/passwd|\/etc\/shadow|\/windows\/system32/i,
|
|
~r/\.\.%2f|\.\.%5c|%2e%2e%2f|%2e%2e%5c/i
|
|
]
|
|
|
|
def init(opts) do
|
|
opts
|
|
|> Keyword.put_new(:max_request_size, @max_request_size)
|
|
|> Keyword.put_new(:max_param_length, @max_param_length)
|
|
|> Keyword.put_new(:max_nested_depth, @max_nested_depth)
|
|
|> Keyword.put_new(:validate_content_type, true)
|
|
|> Keyword.put_new(:sanitize_params, true)
|
|
|> Keyword.put_new(:detect_malicious_patterns, true)
|
|
end
|
|
|
|
def call(conn, opts) do
|
|
start_time = System.monotonic_time(:millisecond)
|
|
|
|
conn
|
|
|> validate_request_size(opts)
|
|
|> validate_content_type(opts)
|
|
|> detect_malicious_patterns(opts)
|
|
|> validate_and_sanitize_params(opts)
|
|
|> log_validation_metrics(start_time)
|
|
rescue
|
|
error ->
|
|
handle_validation_error(conn, error, opts)
|
|
end
|
|
|
|
# Request size validation
|
|
defp validate_request_size(conn, opts) do
|
|
max_size = Keyword.get(opts, :max_request_size, @max_request_size)
|
|
|
|
case get_req_header(conn, "content-length") do
|
|
[content_length] ->
|
|
size = String.to_integer(content_length)
|
|
|
|
if size > max_size do
|
|
conn
|
|
|> send_validation_error(413, "Request too large", %{
|
|
size: size,
|
|
max_allowed: max_size
|
|
})
|
|
|> halt()
|
|
else
|
|
conn
|
|
end
|
|
|
|
[] ->
|
|
# No content-length header, let it pass
|
|
conn
|
|
end
|
|
end
|
|
|
|
# Content type validation
|
|
defp validate_content_type(%{halted: true} = conn, _opts), do: conn
|
|
|
|
defp validate_content_type(conn, opts) do
|
|
if Keyword.get(opts, :validate_content_type, true) do
|
|
case get_req_header(conn, "content-type") do
|
|
[] ->
|
|
# No content-type, check if method requires it
|
|
if conn.method in ["POST", "PUT", "PATCH"] do
|
|
conn
|
|
|> send_validation_error(400, "Content-Type header required", %{
|
|
method: conn.method,
|
|
path: conn.request_path
|
|
})
|
|
|> halt()
|
|
else
|
|
conn
|
|
end
|
|
|
|
[content_type] ->
|
|
validate_content_type_value(conn, content_type, opts)
|
|
end
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
defp validate_content_type_value(conn, content_type, _opts) do
|
|
# Extract media type without parameters
|
|
media_type = content_type |> String.split(";") |> List.first() |> String.trim()
|
|
|
|
allowed_types = [
|
|
"application/json",
|
|
"application/x-www-form-urlencoded",
|
|
"multipart/form-data",
|
|
"text/plain"
|
|
]
|
|
|
|
if media_type in allowed_types do
|
|
conn
|
|
else
|
|
conn
|
|
|> send_validation_error(415, "Unsupported media type", %{
|
|
received: media_type,
|
|
allowed: allowed_types
|
|
})
|
|
|> halt()
|
|
end
|
|
end
|
|
|
|
# Parameter validation and sanitization
|
|
defp validate_and_sanitize_params(%{halted: true} = conn, _opts), do: conn
|
|
|
|
defp validate_and_sanitize_params(conn, opts) do
|
|
if Keyword.get(opts, :sanitize_params, true) do
|
|
conn
|
|
|> validate_param_structure(opts)
|
|
|> sanitize_parameters(opts)
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
defp validate_param_structure(conn, opts) do
|
|
max_length = Keyword.get(opts, :max_param_length, @max_param_length)
|
|
max_depth = Keyword.get(opts, :max_nested_depth, @max_nested_depth)
|
|
|
|
# Validate query parameters
|
|
case validate_params(conn.query_params, max_length, max_depth, 0) do
|
|
:ok ->
|
|
# Validate body parameters if present
|
|
case validate_params(conn.body_params, max_length, max_depth, 0) do
|
|
:ok ->
|
|
conn
|
|
|
|
{:error, reason} ->
|
|
conn
|
|
|> send_validation_error(400, "Invalid body parameters", %{reason: reason})
|
|
|> halt()
|
|
end
|
|
|
|
{:error, reason} ->
|
|
conn
|
|
|> send_validation_error(400, "Invalid query parameters", %{reason: reason})
|
|
|> halt()
|
|
end
|
|
end
|
|
|
|
defp validate_params(params, max_length, max_depth, current_depth) when is_map(params) do
|
|
if current_depth > max_depth do
|
|
{:error, "Maximum nesting depth exceeded"}
|
|
else
|
|
params
|
|
|> Enum.reduce_while(:ok, fn {key, value}, :ok ->
|
|
case validate_param_value(key, value, max_length, max_depth, current_depth + 1) do
|
|
:ok -> {:cont, :ok}
|
|
error -> {:halt, error}
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
defp validate_params(params, max_length, max_depth, current_depth) when is_list(params) do
|
|
if current_depth > max_depth do
|
|
{:error, "Maximum nesting depth exceeded"}
|
|
else
|
|
params
|
|
|> Enum.reduce_while(:ok, fn value, :ok ->
|
|
case validate_param_value("list_item", value, max_length, max_depth, current_depth + 1) do
|
|
:ok -> {:cont, :ok}
|
|
error -> {:halt, error}
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
defp validate_params(_params, _max_length, _max_depth, _current_depth), do: :ok
|
|
|
|
defp validate_param_value(key, value, max_length, max_depth, current_depth)
|
|
when is_binary(value) do
|
|
cond do
|
|
String.length(value) > max_length ->
|
|
{:error, "Parameter '#{key}' exceeds maximum length"}
|
|
|
|
String.valid?(value) ->
|
|
:ok
|
|
|
|
true ->
|
|
{:error, "Parameter '#{key}' contains invalid UTF-8"}
|
|
end
|
|
end
|
|
|
|
defp validate_param_value(key, value, max_length, max_depth, current_depth)
|
|
when is_map(value) do
|
|
validate_params(value, max_length, max_depth, current_depth)
|
|
end
|
|
|
|
defp validate_param_value(key, value, max_length, max_depth, current_depth)
|
|
when is_list(value) do
|
|
validate_params(value, max_length, max_depth, current_depth)
|
|
end
|
|
|
|
defp validate_param_value(_key, _value, _max_length, _max_depth, _current_depth), do: :ok
|
|
|
|
# Parameter sanitization
|
|
defp sanitize_parameters(conn, _opts) do
|
|
sanitized_query_params = sanitize_params(conn.query_params)
|
|
sanitized_body_params = sanitize_params(conn.body_params)
|
|
|
|
conn
|
|
|> Map.put(:query_params, sanitized_query_params)
|
|
|> Map.put(:body_params, sanitized_body_params)
|
|
|> Map.put(:params, Map.merge(sanitized_query_params, sanitized_body_params))
|
|
end
|
|
|
|
defp sanitize_params(params) when is_map(params) do
|
|
params
|
|
|> Enum.map(fn {key, value} ->
|
|
{sanitize_param_key(key), sanitize_param_value(value)}
|
|
end)
|
|
|> Enum.into(%{})
|
|
end
|
|
|
|
defp sanitize_params(params) when is_list(params) do
|
|
Enum.map(params, &sanitize_param_value/1)
|
|
end
|
|
|
|
defp sanitize_params(params), do: params
|
|
|
|
defp sanitize_param_key(key) when is_binary(key) do
|
|
key
|
|
|> String.trim()
|
|
|> String.replace(~r/[^\w\-_]/, "")
|
|
# Limit key length
|
|
|> String.slice(0, 100)
|
|
end
|
|
|
|
defp sanitize_param_key(key), do: key
|
|
|
|
defp sanitize_param_value(value) when is_binary(value) do
|
|
value
|
|
|> String.trim()
|
|
|> html_escape()
|
|
|> remove_null_bytes()
|
|
|> normalize_whitespace()
|
|
end
|
|
|
|
defp sanitize_param_value(value) when is_map(value) do
|
|
sanitize_params(value)
|
|
end
|
|
|
|
defp sanitize_param_value(value) when is_list(value) do
|
|
sanitize_params(value)
|
|
end
|
|
|
|
defp sanitize_param_value(value), do: value
|
|
|
|
# HTML escaping
|
|
defp html_escape(text) do
|
|
text
|
|
|> String.replace("&", "&")
|
|
|> String.replace("<", "<")
|
|
|> String.replace(">", ">")
|
|
|> String.replace("\"", """)
|
|
|> String.replace("'", "'")
|
|
|> String.replace("/", "/")
|
|
end
|
|
|
|
# Remove null bytes
|
|
defp remove_null_bytes(text) do
|
|
String.replace(text, <<0>>, "")
|
|
end
|
|
|
|
# Normalize whitespace
|
|
defp normalize_whitespace(text) do
|
|
text
|
|
|> String.replace(~r/\s+/, " ")
|
|
|> String.trim()
|
|
end
|
|
|
|
# Malicious pattern detection
|
|
defp detect_malicious_patterns(%{halted: true} = conn, _opts), do: conn
|
|
|
|
defp detect_malicious_patterns(conn, opts) do
|
|
if Keyword.get(opts, :detect_malicious_patterns, true) do
|
|
check_for_malicious_patterns(conn)
|
|
else
|
|
conn
|
|
end
|
|
end
|
|
|
|
defp check_for_malicious_patterns(conn) do
|
|
# Check all string parameters for malicious patterns
|
|
all_params = extract_all_string_params(conn)
|
|
|
|
case detect_patterns(all_params) do
|
|
{:ok, []} ->
|
|
conn
|
|
|
|
{:ok, threats} ->
|
|
# Log security threat
|
|
user_id = get_user_id(conn)
|
|
|
|
request_details = RequestContext.build_request_details(conn)
|
|
|
|
SecurityAudit.log_event(
|
|
:security_alert,
|
|
user_id,
|
|
Map.put(request_details, :threats, threats)
|
|
)
|
|
|
|
conn
|
|
|> send_validation_error(400, "Malicious content detected", %{
|
|
threats: length(threats),
|
|
blocked: true
|
|
})
|
|
|> halt()
|
|
end
|
|
end
|
|
|
|
defp extract_all_string_params(conn) do
|
|
all_params = Map.merge(conn.query_params, conn.body_params)
|
|
extract_strings_from_params(all_params)
|
|
end
|
|
|
|
defp extract_strings_from_params(params) when is_map(params) do
|
|
params
|
|
|> Enum.flat_map(fn {_key, value} ->
|
|
extract_strings_from_params(value)
|
|
end)
|
|
end
|
|
|
|
defp extract_strings_from_params(params) when is_list(params) do
|
|
params
|
|
|> Enum.flat_map(&extract_strings_from_params/1)
|
|
end
|
|
|
|
defp extract_strings_from_params(param) when is_binary(param) do
|
|
[param]
|
|
end
|
|
|
|
defp extract_strings_from_params(_param), do: []
|
|
|
|
defp detect_patterns(strings) do
|
|
threats =
|
|
strings
|
|
|> Enum.flat_map(&check_string_for_threats/1)
|
|
|> Enum.uniq()
|
|
|
|
{:ok, threats}
|
|
end
|
|
|
|
defp check_string_for_threats(string) do
|
|
threats = []
|
|
|
|
# Check for XSS patterns
|
|
threats =
|
|
if has_xss_pattern?(string) do
|
|
[%{type: "xss", pattern: "potential_xss", value: String.slice(string, 0, 100)} | threats]
|
|
else
|
|
threats
|
|
end
|
|
|
|
# Check for SQL injection patterns
|
|
threats =
|
|
if has_sql_injection_pattern?(string) do
|
|
[
|
|
%{
|
|
type: "sql_injection",
|
|
pattern: "potential_sql_injection",
|
|
value: String.slice(string, 0, 100)
|
|
}
|
|
| threats
|
|
]
|
|
else
|
|
threats
|
|
end
|
|
|
|
# Check for path traversal patterns
|
|
threats =
|
|
if has_path_traversal_pattern?(string) do
|
|
[
|
|
%{
|
|
type: "path_traversal",
|
|
pattern: "potential_path_traversal",
|
|
value: String.slice(string, 0, 100)
|
|
}
|
|
| threats
|
|
]
|
|
else
|
|
threats
|
|
end
|
|
|
|
threats
|
|
end
|
|
|
|
defp has_xss_pattern?(string) do
|
|
Enum.any?(@xss_patterns, &Regex.match?(&1, string))
|
|
end
|
|
|
|
defp has_sql_injection_pattern?(string) do
|
|
Enum.any?(@sql_injection_patterns, &Regex.match?(&1, string))
|
|
end
|
|
|
|
defp has_path_traversal_pattern?(string) do
|
|
Enum.any?(@path_traversal_patterns, &Regex.match?(&1, string))
|
|
end
|
|
|
|
# Utility functions
|
|
defp get_user_id(conn) do
|
|
case conn.assigns[:current_user] do
|
|
%{id: user_id} -> user_id
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp send_validation_error(conn, status, message, details) do
|
|
error_response = %{
|
|
error: message,
|
|
status: status,
|
|
details: details,
|
|
timestamp: DateTime.utc_now()
|
|
}
|
|
|
|
conn
|
|
|> put_status(status)
|
|
|> put_resp_content_type("application/json")
|
|
|> send_resp(status, Jason.encode!(error_response))
|
|
end
|
|
|
|
defp handle_validation_error(conn, error, _opts) do
|
|
# Log the validation error
|
|
user_id = get_user_id(conn)
|
|
|
|
request_details = RequestContext.build_request_details(conn)
|
|
|
|
SecurityAudit.log_event(
|
|
:security_alert,
|
|
user_id,
|
|
request_details
|
|
|> Map.put(:error, "validation_error")
|
|
|> Map.put(:message, Exception.message(error))
|
|
)
|
|
|
|
conn
|
|
|> send_validation_error(500, "Request validation failed", %{
|
|
error: "internal_validation_error"
|
|
})
|
|
|> halt()
|
|
end
|
|
|
|
defp log_validation_metrics(%{halted: true} = conn, _start_time), do: conn
|
|
|
|
defp log_validation_metrics(conn, start_time) do
|
|
end_time = System.monotonic_time(:millisecond)
|
|
duration = end_time - start_time
|
|
|
|
# Emit telemetry for validation performance
|
|
:telemetry.execute(
|
|
[:wanderer_app, :request_validation],
|
|
%{duration: duration, count: 1},
|
|
%{
|
|
method: conn.method,
|
|
path: conn.request_path,
|
|
status: conn.status || 200,
|
|
user_id: get_user_id(conn)
|
|
}
|
|
)
|
|
|
|
conn
|
|
end
|
|
end
|