Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ tmp/
tmp.txt
wget-log
Downloads/
logs/
*.gz
*.rar
*.zip
Expand Down
18 changes: 9 additions & 9 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ repos:
name: "🔒 security · Detect private keys"

- repo: https://github.com/commitizen-tools/commitizen
rev: v4.17.0
rev: v4.18.1
hooks:
- id: commitizen
name: "🌳 git · Validate commit message"
Expand Down Expand Up @@ -134,7 +134,7 @@ repos:
additional_dependencies: [".[toml]"]

- repo: https://github.com/semgrep/pre-commit
rev: 'v1.171.0'
rev: 'v1.177.0'
hooks:
- id: semgrep
name: "🔒 security · Static analysis (semgrep)"
Expand All @@ -143,14 +143,14 @@ repos:

# Spelling and typos
- repo: https://github.com/crate-ci/typos
rev: v1.48.0
rev: v1.50.2
hooks:
- id: typos
name: "📝 spelling · Check typos"

# CI/CD validation
- repo: https://github.com/python-jsonschema/check-jsonschema
rev: 0.37.4
rev: 0.38.0
hooks:
- id: check-dependabot
name: "🔧 ci/cd · Validate Dependabot config"
Expand All @@ -159,7 +159,7 @@ repos:
files: ^\.github/workflows/.*\.ya?ml$

- repo: https://github.com/ariebovenberg/slotscheck
rev: v0.20.1
rev: v0.21.0
hooks:
- id: slotscheck
name: "🔍 check · slotscheck"
Expand All @@ -172,7 +172,7 @@ repos:
- responses

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.1
rev: v0.16.8
hooks:
- id: ruff-check
name: "🐍 lint · Check with Ruff"
Expand All @@ -191,7 +191,7 @@ repos:

# Python type checking
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.3.0
rev: v2.3.1
hooks:
- id: mypy
name: "🐍 types · Check with mypy"
Expand All @@ -202,14 +202,14 @@ repos:
exclude: ^mailgun/examples/

- repo: https://github.com/RobertCraigie/pyright-python
rev: v1.1.411
rev: v1.1.414
hooks:
- id: pyright
name: "🐍 types · Check with pyright"

# Python project configuration
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.25
rev: '0.26'
hooks:
- id: validate-pyproject
name: "🐍 config · Validate pyproject.toml"
Expand Down
18 changes: 14 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

We [keep a changelog.](http://keepachangelog.com/)

## [Unreleased] (1.9.1)
## [Unreleased]

## [1.9.1] - 2026-09-21

### Security

Expand Down Expand Up @@ -35,9 +37,14 @@ We [keep a changelog.](http://keepachangelog.com/)

### Pull Requests Merged

- PR #61: build(deps): Bump the minor-and-patch group across 1 directory with 3 updates.
- PR #63: build(deps): Bump the minor-and-patch group across 1 directory with 2 updates.
- PR #64: build(deps): Bump github/codeql-action/upload-sarif from 4.37.8 to 4.37.9 in the minor-and-patch group.
- PR #65: Hardening security and stability.
- PR #66: Release 1.9.1.
- PR #67: build(deps): Bump github/codeql-action/upload-sarif from 4.37.9 to 4.38.0 in the minor-and-patch group.

## v1.9.0 - 2026-08-04
## [1.9.0] - 2026-08-04

### Added

Expand Down Expand Up @@ -76,7 +83,7 @@ We [keep a changelog.](http://keepachangelog.com/)
- [PR_58](https://github.com/mailgun/mailgun-python/pull/58) - build(deps): Bump the minor-and-patch group with 2 updates.
- [PR_59](https://github.com/mailgun/mailgun-python/pull/59) - build(deps): Bump actions/setup-python from 6.3.0 to 7.0.0.

## v1.8.0 - 2026-07-20
## [1.8.0] - 2026-07-20

### 🌟 Top Highlights (The "Big Wins")

Expand Down Expand Up @@ -505,4 +512,7 @@ We [keep a changelog.](http://keepachangelog.com/)
[1.6.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.6.0
[1.7.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.7.0
[1.7.1]: https://github.com/mailgun/mailgun-python/releases/tag/v1.7.1
[unreleased]: https://github.com/mailgun/mailgun-python/compare/v1.8.0...HEAD
[1.8.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.8.0
[1.9.0]: https://github.com/mailgun/mailgun-python/releases/tag/v1.9.0
[1.9.1]: https://github.com/mailgun/mailgun-python/releases/tag/v1.9.1
[unreleased]: https://github.com/mailgun/mailgun-python/compare/v1.9.1...HEAD
2 changes: 1 addition & 1 deletion mailgun/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.9.0"
__version__ = "1.9.1"
33 changes: 33 additions & 0 deletions tests/fuzz/fuzz.dict
Original file line number Diff line number Diff line change
Expand Up @@ -2209,3 +2209,36 @@
"}{\xef\xbf\xbd"
"}}}}\xef\xbf\xbd"
"~\x13"
"\xff\xff\xff\xff\xff\xff\xff\xc8"
"qq;qCF %2}\x7f\xef\xbf\xbd{\x7f\x7f"
"_\x00\x00\x00\x00\x00\x00\x00"
"<!>E"
"im1"
"<!><!I\x04I\x14"
"vii"
"\x00\x00\x00\x00\x00\x00\x00$"
"<!-q"
"<!do\x1ct}py"
"<!/>ccccc"
"<!doc3%\x00\x00"
"7.\x02\x02{\"%80"
"!%D5G%D\x7f\x7f\x7f\x7f\x01\x00"
"\x01\x00\x00\x00\x00\x00\x00\x17"
"\xcf\xff\xff\xff\xff\xff\xff\xff"
"\xf9\xff\xff\xff\xff\xff\xff\xff"
"<!$-->---"
"\xff\xff\xff\xff\xff\xff\xff\xf0"
"<![CDATa&"
"ieg"
"\x89\x0f\x00\x00\x00\x00\x00\x00"
"<!><!</p>"
"<!</p></b"
"<!doc\x0b<f/"
"\xff\xff\xff\xff\xff\xff\xff,"
"<!0000uoo"
"<!YCD/</p"
"<!><"
"<!d\x104}xe&"
"\xf7\xff\x0f\x00\x00\x00\x00\x00"
"\x00\x00\x00\x00\x00\x00\x08l"
"\xec\x00\x00\x00\x00\x00\x00\x00"
4 changes: 4 additions & 0 deletions tests/fuzz/fuzz_async_evil_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
"Wed, 21 Oct 2026 07:28:00 GMT",
"invalid-date-string",
"9999999999999999999999999999999",
"1e400",
"-1e400",
"1e999",
"9" * 150,
]


Expand Down
21 changes: 21 additions & 0 deletions tests/fuzz/fuzz_audit_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,27 @@ def TestOneInput(data: bytes) -> None:
action = getattr(client.domains, fuzzed_method)
action(domain=fuzzed_domain)


# Probe header injection & control character audit hooks
if fdp.ConsumeBool():
hostile_headers = {
fdp.ConsumeUnicodeNoSurrogates(16): fdp.PickValueInList(
["valid", "bad\r\nHeader: 1", "control\x01char", "null\x00byte"]
)
}
client.messages.create(
domain=fuzzed_domain,
data={"from": "test@example.com", "to": "user@example.com"},
headers=hostile_headers,
)

# Probe SSRF URL audit hooks
if fdp.ConsumeBool():
hostile_url = fdp.PickValueInList(
["ftp://api.mailgun.net", "gopher://127.0.0.1", "http://attacker.com/v3"]
)
client.messages.api_call(method="get", url=hostile_url)

# Verify invariant: if audit hook fired, arguments must be safe
for event, args in _AUDIT_LOG:
for arg in args:
Expand Down
28 changes: 27 additions & 1 deletion tests/fuzz/fuzz_builders_advanced.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,28 @@

import atheris

from mailgun.security import IdempotencyGuard


with atheris.instrument_imports():
from mailgun.builders import ChunkedStreamer, MailgunMessageBuilder

logging.disable(logging.CRITICAL)


def _test_cyclic_and_stream_pointer_invariants(
fdp: atheris.FuzzedDataProvider, builder: MailgunMessageBuilder
) -> None:
# Explicitly annotate cyclic_dict as dict[str, Any] to allow self-referential structures
cyclic_dict: dict[str, Any] = {"domain": "test.com", "to": ["a@b.com"]}
cyclic_dict["self"] = cyclic_dict
cyclic_dict["nested"] = [cyclic_dict]

key = IdempotencyGuard.generate_key("test.com", cyclic_dict)
assert isinstance(key, str)
assert len(key) == 64


def _generate_nested_ast(fdp: atheris.FuzzedDataProvider, depth: int = 0) -> Any:
"""Generate arbitrary nested JSON structures to stress idempotency hashing."""
if depth > 4 or fdp.ConsumeBool():
Expand Down Expand Up @@ -57,7 +73,7 @@ def TestOneInput(data: bytes) -> None:
try:
num_operations = fdp.ConsumeIntInRange(1, 10)
for _ in range(num_operations):
op_code = fdp.ConsumeIntInRange(0, 5)
op_code = fdp.ConsumeIntInRange(0, 6)

if op_code == 0:
builder.set_idempotency_safe(safe=fdp.ConsumeBool())
Expand Down Expand Up @@ -101,6 +117,16 @@ def TestOneInput(data: bytes) -> None:
# Expected during fuzzing: optional API may be missing or reject malformed input.
pass

elif op_code == 6:
# Direct cyclic graph insertion into builder payload
cyclic: dict[str, Any] = {"nested": []}
cyclic["self"] = cyclic
cyclic["nested"].append(cyclic)
builder.add_custom_variable("cyclic_prop", cyclic)

# Exercise the standalone idempotency cyclic invariant
_test_cyclic_and_stream_pointer_invariants(fdp, builder)

# Build and trigger hash serialization
final_payload, files = builder.build()

Expand Down
5 changes: 4 additions & 1 deletion tests/fuzz/fuzz_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@
"value\x00_null_byte",
"value\x09tab_separated",
"https://api.mailgun.net/v3\r\n\r\n<script>alert(1)</script>",
"просто-заголовок",
"header-with-emoji-🚀",
"\ud800\udc00",
]


Expand Down Expand Up @@ -118,7 +121,7 @@ def TestOneInput(data: bytes) -> None:
if "\r" in v or "\n" in v or "\x00" in v:
raise RuntimeError(f"INJECTION LEAK in header value: {repr(v)}")

except (TypeError, ValueError):
except (TypeError, ValueError, UnicodeEncodeError):
# Expected security rejection for malformed headers or control characters
pass
except Exception as e:
Expand Down
7 changes: 7 additions & 0 deletions tests/fuzz/fuzz_pagination_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
"https://api.mailgun.net/v3/events?limit=10&ascending=true&ascending=false",
"https://api.mailgun.net/v3/events?tags=tag1&tags=tag2&tags=tag3",
"https://api.mailgun.net/v3/events?\x00=corrupted",
"https://api.mailgun.net/v3/events?ascending=yes&limit=NaN&score=Infinity",
"https://api.mailgun.net/v3/events?tags=promo&tags=newsletter&threshold=1e300",
]


Expand All @@ -48,6 +50,11 @@ def TestOneInput(data: bytes) -> None:
["delivered", "failed", "opened", "clicked", None]
)

if fdp.ConsumeBool():
initial_filters["tags"] = [
fdp.ConsumeUnicodeNoSurrogates(10) for _ in range(fdp.ConsumeIntInRange(1, 3))
]

# Next URL selection: either from hostile seeds or dynamically fuzzed
if fdp.ConsumeBool():
next_url = fdp.PickValueInList(_HOSTILE_PAGING_URLS)
Expand Down
47 changes: 36 additions & 11 deletions tests/fuzz/fuzz_spamguard.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,38 +18,56 @@

logging.disable(logging.CRITICAL)

_MALFORMED_HTML_SNIPPETS = [
_BENIGN_MALFORMED_SNIPPETS = [
'<a href="http://evil.com">Click here</a>',
'<img src="cid:missing.png" alt="No image">',
'<div style="display:none;font-size:0px;color:#ffffff;background-color:#ffffff">Hidden Spam</div>',
'<script>alert("xss")</script>',
'<iframe src="javascript:alert(1)"></iframe>',
'<!-- ' * 50 + 'Unclosed Comment',
'<table' + ' border=1' * 200 + '><tr><td>Deep attr</td></tr></table>',
'<a href="javascript:void(0)">Spam</a>' * 50,
'<p>\u200b\u200c\u200dHidden zero-width tokens\ufeff</p>',
'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"' + '>' * 100,
]

_HOSTILE_EXECUTABLE_SNIPPETS = [
'<script>alert("xss")</script>',
'<iframe src="javascript:alert(1)"></iframe>',
'<object data="exploit.swf"></object>',
'<embed src="exploit.pdf">',
'<applet code="Exploit.class"></applet>',
'<img src="x" onerror="alert(1)">',
'<div onmouseover="stealCookies()">Hover me</div>',
'<body onload="init()">',
'<button onclick="leak()">Click</button>',
]


def TestOneInput(data: bytes) -> None:
if len(data) < 8:
return

fdp = atheris.FuzzedDataProvider(data)

mode = fdp.ConsumeIntInRange(0, 2)
mode = fdp.ConsumeIntInRange(0, 3)

if mode == 0:
# Mode 0: Structured HTML with known deliverability traps
# Mode 0: Mixed HTML snippets
num_snippets = fdp.ConsumeIntInRange(1, 6)
parts = [
fdp.PickValueInList(_MALFORMED_HTML_SNIPPETS)
for _ in range(num_snippets)
]
parts = []
for _ in range(num_snippets):
if fdp.ConsumeBool():
parts.append(fdp.PickValueInList(_BENIGN_MALFORMED_SNIPPETS))
else:
parts.append(fdp.PickValueInList(_HOSTILE_EXECUTABLE_SNIPPETS))
html_content = f"<html><body>{''.join(parts)}</body></html>"

elif mode == 1:
# Mode 1: Boundary stress test around MAX_HTML_SIZE_BYTES (100,000 bytes)
# Mode 1: Guaranteed un-commented exploit tag to verify detection invariant
exploit = fdp.PickValueInList(_HOSTILE_EXECUTABLE_SNIPPETS)
html_content = f"<html><body><div>{exploit}</div></body></html>"

elif mode == 2:
# Mode 2: Boundary stress test around MAX_HTML_SIZE_BYTES (100,000 bytes)
size_choice = fdp.ConsumeIntInRange(0, 2)
if size_choice == 0:
target_size = 99_950
Expand All @@ -62,7 +80,7 @@ def TestOneInput(data: bytes) -> None:
html_content = f"<html><body><p>{base_str}</p></body></html>"

else:
# Mode 2: Unconstrained chaotic Unicode noise
# Mode 3: Unconstrained chaotic Unicode noise
html_content = fdp.ConsumeUnicodeNoSurrogates(fdp.ConsumeIntInRange(10, 40000))

try:
Expand All @@ -85,6 +103,13 @@ def TestOneInput(data: bytes) -> None:
if not isinstance(report["issues"], list):
raise RuntimeError(f"TYPE DRIFT: Issues must be a list, got {type(report['issues'])}")

# Invariant 4: Standalone unmasked hostile executable snippets must be flagged unsafe
if mode == 1:
if report["is_safe"]:
raise RuntimeError(
f"SECURITY BYPASS: Active executable snippet marked safe: {html_content!r}"
)

except (TypeError, ValueError):
# Expected rejection for oversized payloads exceeding MAX_HTML_SIZE_BYTES
pass
Expand Down
Loading