From ad69c8d1a40b80d1617672e180c758696c329251 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Fri, 18 Sep 2026 12:37:16 +0300 Subject: [PATCH 1/7] docs(release): update CHANGELOG --- CHANGELOG.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9113bf1..6054abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,9 @@ We [keep a changelog.](http://keepachangelog.com/) -## [Unreleased] (1.9.1) +## [Unreleased] + +## [1.9.1] - 2026-09-21 ### Security @@ -37,7 +39,7 @@ We [keep a changelog.](http://keepachangelog.com/) - PR #65: Hardening security and stability. -## v1.9.0 - 2026-08-04 +## [1.9.0] - 2026-08-04 ### Added @@ -76,7 +78,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") @@ -505,4 +507,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 From 9e6336587d0f37679d21b668277ee3b13e217fad Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Fri, 18 Sep 2026 15:28:21 +0300 Subject: [PATCH 2/7] ci: update pre-commit hooks --- .pre-commit-config.yaml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f7a2c97..fea0dab 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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" @@ -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)" @@ -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" @@ -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" @@ -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" @@ -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" @@ -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" From 49d6dfa84f27539a94278a62f2d2037cd397af21 Mon Sep 17 00:00:00 2001 From: Serhii Kupriienko <291109589+skupriienko-mailgun@users.noreply.github.com> Date: Mon, 21 Sep 2026 10:51:00 +0300 Subject: [PATCH 3/7] test(fuzz): fix and improve fuzz tests. update fuzz.dict --- .gitignore | 1 + tests/fuzz/fuzz.dict | 33 ++++++++++++++++++ tests/fuzz/fuzz_async_evil_server.py | 4 +++ tests/fuzz/fuzz_audit_events.py | 21 ++++++++++++ tests/fuzz/fuzz_builders_advanced.py | 28 +++++++++++++++- tests/fuzz/fuzz_headers.py | 5 ++- tests/fuzz/fuzz_pagination_stream.py | 7 ++++ tests/fuzz/fuzz_spamguard.py | 50 ++++++++++++++++++++++------ 8 files changed, 136 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index ad08cff..cfbcbcc 100644 --- a/.gitignore +++ b/.gitignore @@ -227,6 +227,7 @@ tmp/ tmp.txt wget-log Downloads/ +logs/ *.gz *.rar *.zip diff --git a/tests/fuzz/fuzz.dict b/tests/fuzz/fuzz.dict index 807fcbf..f458c58 100644 --- a/tests/fuzz/fuzz.dict +++ b/tests/fuzz/fuzz.dict @@ -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" +"ccccc" +"---" +"\xff\xff\xff\xff\xff\xff\xff\xf0" +"" +"<" +" 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: diff --git a/tests/fuzz/fuzz_builders_advanced.py b/tests/fuzz/fuzz_builders_advanced.py index d36cd2b..f77bbbb 100644 --- a/tests/fuzz/fuzz_builders_advanced.py +++ b/tests/fuzz/fuzz_builders_advanced.py @@ -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(): @@ -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()) @@ -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() diff --git a/tests/fuzz/fuzz_headers.py b/tests/fuzz/fuzz_headers.py index edd3fed..57fc0aa 100755 --- a/tests/fuzz/fuzz_headers.py +++ b/tests/fuzz/fuzz_headers.py @@ -46,6 +46,9 @@ "value\x00_null_byte", "value\x09tab_separated", "https://api.mailgun.net/v3\r\n\r\n", + "锌褉芯褋褌芯-蟹邪谐芯谢芯胁芯泻", + "header-with-emoji-馃殌", + "\ud800\udc00", ] @@ -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: diff --git a/tests/fuzz/fuzz_pagination_stream.py b/tests/fuzz/fuzz_pagination_stream.py index 86e4cd9..5782b43 100644 --- a/tests/fuzz/fuzz_pagination_stream.py +++ b/tests/fuzz/fuzz_pagination_stream.py @@ -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", ] @@ -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) diff --git a/tests/fuzz/fuzz_spamguard.py b/tests/fuzz/fuzz_spamguard.py index b0c9d0e..0124ab9 100644 --- a/tests/fuzz/fuzz_spamguard.py +++ b/tests/fuzz/fuzz_spamguard.py @@ -18,12 +18,10 @@ logging.disable(logging.CRITICAL) -_MALFORMED_HTML_SNIPPETS = [ +_BENIGN_MALFORMED_SNIPPETS = [ 'Click here', 'No image', '
Hidden Spam
', - '', - '', '