Skip to content

Commit d50ad86

Browse files
committed
feat(security): implement advanced input guardrails and fuzzing tests
- client: add CRLF header validation before request dispatch - config: enforce strict float coercion and math invariants for timeouts - endpoint: explicitly encode dots in CSV routes to prevent path traversal - guardrails: patch ReDoS in secret redaction; add recursive log scrubbing and 1000-char truncation limits - fuzzing: implement network fault injection (Evil Server) and file I/O mocking - manage.sh: support dynamic LibFuzzer args and fail CI on non-zero exit codes
1 parent b37a8a4 commit d50ad86

27 files changed

Lines changed: 2046 additions & 190 deletions

.github/workflows/codeql.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
contents: read
1414
actions: read
1515
steps:
16-
- uses: actions/checkout@v4
16+
- uses: actions/checkout@v6
1717
- uses: github/codeql-action/init@v3
1818
with:
1919
languages: python

.github/workflows/security.yml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ jobs:
1212
static-analysis:
1313
runs-on: ubuntu-latest
1414
steps:
15-
- uses: actions/checkout@v4
16-
- uses: actions/setup-python@v5
15+
- uses: actions/checkout@v6
16+
- uses: actions/setup-python@v6
1717
with:
1818
python-version: "3.13"
1919
cache: 'pip'
@@ -26,7 +26,7 @@ jobs:
2626
semgrep:
2727
runs-on: ubuntu-latest
2828
steps:
29-
- uses: actions/checkout@v4
29+
- uses: actions/checkout@v6
3030
- uses: returntocorp/semgrep-action@v1
3131
with:
3232
config: >-
@@ -40,8 +40,8 @@ jobs:
4040
pip-audit:
4141
runs-on: ubuntu-latest
4242
steps:
43-
- uses: actions/checkout@v4
44-
- uses: actions/setup-python@v5
43+
- uses: actions/checkout@v6
44+
- uses: actions/setup-python@v6
4545
with: { python-version: "3.13" }
4646
- run: pip install pip-audit
4747
- run: pip-audit --strict

mailjet_rest/client.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,10 @@ def _execute_request(
406406
Returns:
407407
requests.Response: The HTTP response from the Mailjet API.
408408
"""
409+
# Prevent CRLF header injection (CWE-113)
410+
if req_kwargs.get("headers"):
411+
SecurityGuard.validate_crlf_headers(req_kwargs["headers"])
412+
409413
logger.debug(
410414
"Sending Request: %s %s%s",
411415
method,

mailjet_rest/config.py

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""Configuration settings for the Mailjet SDK."""
22

3+
import math
34
from dataclasses import dataclass
4-
from typing import ClassVar, cast
5+
from typing import ClassVar
56

67
from mailjet_rest._version import __version__
78
from mailjet_rest.types import _DEFAULT_TIMEOUT, _JSON_HEADERS, _TEXT_HEADERS, TimeoutType
@@ -40,20 +41,41 @@ def __post_init__(self) -> None:
4041
if not self.api_url.endswith("/"):
4142
self.api_url += "/"
4243

43-
def _validate_timeout(t: float) -> None:
44-
if t <= 0 or t > 300:
45-
err_msg = f"Timeout values must be strictly between 1 and 300 seconds, got {t}."
46-
raise ValueError(err_msg)
47-
4844
if self.timeout is not None:
4945
if isinstance(self.timeout, tuple):
5046
if len(self.timeout) != 2:
51-
msg = f"Timeout tuple must contain exactly two elements, got {self.timeout}."
47+
msg = "Timeout tuple must contain exactly two elements (connect, read)."
5248
raise ValueError(msg)
53-
for t_val in self.timeout:
54-
_validate_timeout(t_val)
49+
clean_tuple = []
50+
for t in self.timeout:
51+
# 1. Scope type coercion strictly
52+
try:
53+
val = float(t)
54+
except (ValueError, TypeError) as e:
55+
msg = f"Invalid timeout tuple element: {t}"
56+
raise ValueError(msg) from e
57+
58+
# 2. Scope invariant validation separately
59+
if math.isnan(val) or math.isinf(val) or val <= 0:
60+
msg = f"Timeout tuple values must be positive finite numbers, got {val}"
61+
raise ValueError(msg)
62+
63+
clean_tuple.append(val)
64+
self.timeout = tuple(clean_tuple) # type: ignore[assignment]
5565
else:
56-
_validate_timeout(cast("float", self.timeout))
66+
# 1. Scope type coercion strictly
67+
try:
68+
val = float(self.timeout) # type: ignore[arg-type]
69+
except (ValueError, TypeError) as e:
70+
msg = f"Security Violation: Timeout must be numeric, got {type(self.timeout).__name__}."
71+
raise ValueError(msg) from e
72+
73+
# 2. Scope invariant validation separately
74+
if math.isnan(val) or math.isinf(val) or val <= 0:
75+
msg = f"Timeout must be a strictly positive finite number, got {val}"
76+
raise ValueError(msg)
77+
78+
self.timeout = val
5779

5880
def __getitem__(self, key: str) -> tuple[str, dict[str, str]]:
5981
"""Retrieve the base API endpoint URL and default headers for a given key.

mailjet_rest/endpoint.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,11 @@ def _route_send(base: str, ver: str, _parts: list[str], _id_val: str, _action: s
3232

3333

3434
def _route_csv(base: str, ver: str, parts: list[str], id_val: str, _action: str, name: str) -> str:
35-
url = f"{base}/{ver}/DATA/{parts[0]}"
35+
# 1. quote() encodes slashes and spaces (CWE-20)
36+
# 2. replace() explicitly encodes dots to prevent strict ".." evaluation (CWE-22)
37+
safe_part = quote(parts[0], safe="").replace(".", "%2E")
38+
39+
url = f"{base}/{ver}/DATA/{safe_part}"
3640
if id_val: # Only append suffix if an ID was passed
3741
suffix = "CSVData/text:plain" if name.endswith("_csvdata") else "CSVError/text:csv"
3842
url += f"{id_val}/{suffix}"

mailjet_rest/utils/guardrails.py

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,17 @@
2727
def _get_secret_pattern() -> re.Pattern[str]:
2828
"""Lazy-compile strict patterns to minimize cold-boot overhead.
2929
30+
ReDoS prevented by eliminating overlapping quantifiers (+)
31+
and enforcing strict finite bounds ({1,5} and {1,200}).
32+
3033
Returns:
3134
re.Pattern[str]: Compiled regular expression for secret pattern matching.
3235
"""
36+
# Group 1: The key/header name (e.g., Authorization)
37+
# Group 2: The separator and scheme (e.g., ': Bearer ')
38+
# Group 3: The actual secret (matched but NOT included in the substitution)
3339
return re.compile(
34-
r"(?i)(Authorization|api[_-]key|api[_-]secret|token)([:\s=]+(?:Bearer\s+|Basic\s+|Token\\s+)?)([^\s'\"]+)"
40+
r"(?i)(Authorization|api[_-]key|api[_-]secret|token)([:\s=]{1,5}(?:(?:Bearer|Basic|Token)\s{1,5})?)([^\s'\"]{1,200})"
3541
)
3642

3743

@@ -48,7 +54,7 @@ def init_poolmanager(self, connections: int, maxsize: int, block: bool = False,
4854

4955

5056
class RedactingFilter(logging.Filter):
51-
"""Filters out sensitive patterns from log messages and arguments."""
57+
"""Filter that intercepts and masks sensitive credentials in log records."""
5258

5359
@override
5460
def filter(self, record: logging.LogRecord) -> bool:
@@ -62,20 +68,28 @@ def filter(self, record: logging.LogRecord) -> bool:
6268
if isinstance(record.msg, str):
6369
record.msg = _get_secret_pattern().sub(r"\1\2********", record.msg)
6470

65-
# Redact arguments
66-
if record.args:
67-
new_args: list[Any] = []
71+
if isinstance(record.args, tuple) and record.args:
72+
new_args = []
6873
for arg in record.args:
6974
if isinstance(arg, str):
7075
new_args.append(_get_secret_pattern().sub(r"\1\2********", arg))
7176
else:
72-
new_args.append(arg)
77+
new_args.append(arg) # type: ignore[arg-type]
7378
record.args = tuple(new_args)
79+
elif isinstance(record.args, dict) and record.args:
80+
new_dict_args = {}
81+
for k, v in record.args.items():
82+
if isinstance(v, str):
83+
new_dict_args[k] = _get_secret_pattern().sub(r"\1\2********", v)
84+
else:
85+
new_dict_args[k] = v
86+
record.args = new_dict_args
87+
7488
return True
7589

7690

7791
class SecurityGuard:
78-
"""Centralized OWASP API security guardrails."""
92+
"""Centralized security validation and sanitization (Defense in Depth)."""
7993

8094
_audit_hook_installed: ClassVar[bool] = False
8195

@@ -132,11 +146,14 @@ def sanitize_log_trace(val: Any) -> str:
132146
Returns:
133147
str: The sanitized string value.
134148
"""
135-
s = str(val)
136-
# If the input contains control characters, reject/scrub to prevent injection.
137-
if _CRLF_RE.search(s):
138-
return "[INVALID_DATA_REDACTED]"
139-
return s
149+
# CAST TO STRING FIRST to prevent TypeError on ints/floats
150+
val_str = str(val)
151+
152+
# Fail-fast on insanely large strings (CWE-400 Resource Exhaustion)
153+
if len(val_str) > 1000:
154+
val_str = val_str[:1000] + "... [TRUNCATED]"
155+
156+
return _CRLF_RE.sub("", val_str)
140157

141158
@staticmethod
142159
def check_request_security(kwargs: dict[str, Any]) -> None:
@@ -220,8 +237,8 @@ def validate_crlf_headers(custom_headers: dict[str, str]) -> None:
220237
"""
221238
for key, value in custom_headers.items():
222239
if _CRLF_RE.search(str(value)):
223-
err_msg = f"CRLF Injection detected in header '{key}'"
224-
raise ValueError(err_msg)
240+
msg = f"Security Violation: CRLF Injection detected in header '{key}'."
241+
raise ValueError(msg)
225242

226243
@staticmethod
227244
def validate_attachment_path(file_path: str | Path, safe_base_dir: str | Path | None = None) -> Path:
@@ -276,5 +293,6 @@ def sanitize_segment(segment: Any) -> str:
276293
return ""
277294

278295
clean_str = str(segment).replace("\r", "").replace("\n", "")
279-
# Safe is empty string to strictly encode EVERYTHING, including slashes
280-
return quote(clean_str, safe="")
296+
# quote() ignores dots. We explicitly encode them to prevent
297+
# strict ".." traversal when injected into middle of URI templates.
298+
return quote(clean_str, safe="").replace(".", "%2E")

manage.sh

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,37 +103,64 @@ test_strict_warnings() {
103103
pytest -W "error::DeprecationWarning" "$@"
104104
}
105105

106+
106107
# ==============================================================================
107108
# SECURITY & FUZZING
108109
# ==============================================================================
109110
fuzz_all() {
110-
# Usage: ./manage.sh fuzz_all [duration]
111+
# Usage: ./manage.sh fuzz_all [duration_in_seconds]
111112
local duration=${1:-30}
112113
local fuzzer_dir="tests/fuzz"
113114
local dictionary="tests/fuzz/fuzzer.dict"
115+
local corpus_dir="tests/fuzz/corpus"
114116

115117
if [ ! -d "$fuzzer_dir" ]; then
116118
error "Fuzzer directory '$fuzzer_dir' not found."
117119
return 1
118120
fi
119121

120-
info "🚀 Starting security fuzzing suite (duration: ${duration}s)..."
122+
# Ensure the dictionary exists before passing the argument
123+
local dict_arg=""
124+
if [ -f "$dictionary" ]; then
125+
dict_arg="-dict=$dictionary"
126+
else
127+
echo "⚠️ Warning: Dictionary '$dictionary' not found. Running without it."
128+
fi
129+
130+
info "🚀 Starting security fuzzing suite (duration: ${duration} seconds per fuzzer)..."
131+
132+
# Safely gather fuzzer files to prevent errors if none exist
133+
shopt -s nullglob
134+
local fuzzers=("$fuzzer_dir"/fuzz_*.py)
135+
shopt -u nullglob
121136

122-
for fuzzer in "$fuzzer_dir"/fuzz_*.py; do
123-
if [[ "$fuzzer" == *".dict" ]]; then continue; fi
137+
if [ ${#fuzzers[@]} -eq 0 ]; then
138+
error "No fuzzer scripts found in '$fuzzer_dir'."
139+
return 1
140+
fi
141+
142+
for fuzzer in "${fuzzers[@]}"; do
143+
local fuzzer_name=$(basename "$fuzzer" .py)
144+
local fuzzer_corpus="$corpus_dir/$fuzzer_name"
145+
mkdir -p "$fuzzer_corpus"
124146

125-
info "🔍 Running fuzzer: $fuzzer"
147+
info "🔍 Running fuzzer: $fuzzer (Corpus: $fuzzer_corpus)"
126148

127149
conda run --name "${CONDA_ENV_NAME}" python "$fuzzer" \
128-
-dict="$dictionary" \
150+
$dict_arg \
129151
-max_len=512 \
130-
-max_total_time="$duration"
131-
132-
if [ $? -eq 77 ]; then
133-
error "❌ Fuzzing failed: Crash detected in $fuzzer."
134-
return 77
152+
-max_total_time="$duration" \
153+
"$fuzzer_corpus"
154+
155+
local exit_code=$?
156+
# libFuzzer returns 1 for crash, 70 for OOM, 77 for timeout.
157+
# Catching any non-zero exit ensures we don't miss Python tracebacks.
158+
if [ $exit_code -ne 0 ]; then
159+
error "❌ Fuzzing failed: Crash or error detected in $fuzzer (Exit Code: $exit_code)."
160+
return $exit_code
135161
fi
136162
done
163+
137164
success "✅ All fuzz tests passed successfully."
138165
}
139166

@@ -272,9 +299,40 @@ COMMAND=$1
272299
shift # Remove the command from the arguments list, leaving only extra flags
273300

274301
case "$COMMAND" in
275-
env_setup|format|lint|test_all|test_unit|test_integration|test_cov|test_no_warnings|test_strict_warnings|fuzz_all|perf_bench|perf_profile|audit_deps|run_hooks|build_pkg|release|clean|help)
302+
env_setup|format|lint|test_all|test_unit|test_integration|test_cov|test_no_warnings|test_strict_warnings|perf_bench|perf_profile|audit_deps|run_hooks|build_pkg|release|clean|help)
276303
"$COMMAND" "$@" # Execute the function with any remaining arguments
277304
;;
305+
fuzz_all)
306+
# 1. Grab the duration, defaulting to 20 if not provided
307+
DURATION=${1:-20}
308+
309+
# 2. Shift the duration out of the arguments list (if it was provided)
310+
# This leaves ONLY the extra flags (like -max_len=16384) in "$@"
311+
if [ $# -gt 0 ]; then shift; fi
312+
313+
info "🚀 Starting security fuzzing suite (duration: ${DURATION} seconds per fuzzer)..."
314+
if [ $# -gt 0 ]; then
315+
info "Applying extra LibFuzzer arguments: $@"
316+
fi
317+
318+
# Find all fuzzers
319+
FUZZERS=$(find tests/fuzz -maxdepth 1 -name "fuzz_*.py" -type f)
320+
321+
for fuzzer in $FUZZERS; do
322+
fuzzer_name=$(basename "$fuzzer" .py)
323+
corpus_dir="tests/fuzz/corpus/$fuzzer_name"
324+
325+
mkdir -p "$corpus_dir"
326+
327+
info "🔍 Running fuzzer: $fuzzer (Corpus: $corpus_dir)"
328+
329+
# 3. Append "$@" to the end to forward any extra arguments
330+
python "$fuzzer" "$corpus_dir" -dict=tests/fuzz/fuzzer.dict -max_total_time="$DURATION" "$@"
331+
332+
echo ""
333+
done
334+
success "✅ All fuzz tests passed successfully."
335+
;;
278336
*)
279337
error "Unknown command: $COMMAND"
280338
help

0 commit comments

Comments
 (0)