From d4c1903db13d3804ca9f1ebd70bb3af731365d82 Mon Sep 17 00:00:00 2001 From: Sam Bull Date: Thu, 3 Sep 2026 16:20:11 +0100 Subject: [PATCH] Add fuzzers (#12887) --- .mypy.ini | 2 +- MANIFEST.in | 1 + fuzzers/http_parser.py | 43 +++++++++++++++++++++ fuzzers/http_payload_parser.py | 44 ++++++++++++++++++++++ fuzzers/multipart.py | 69 ++++++++++++++++++++++++++++++++++ fuzzers/no_extensions.txt | 3 ++ fuzzers/payload_url.py | 41 ++++++++++++++++++++ fuzzers/web_request.py | 54 ++++++++++++++++++++++++++ requirements/lint.in | 1 + requirements/lint.txt | 2 + 10 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 fuzzers/http_parser.py create mode 100644 fuzzers/http_payload_parser.py create mode 100644 fuzzers/multipart.py create mode 100644 fuzzers/no_extensions.txt create mode 100644 fuzzers/payload_url.py create mode 100644 fuzzers/web_request.py diff --git a/.mypy.ini b/.mypy.ini index 4d5c2eaaf09..02cdbd07e8a 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -1,5 +1,5 @@ [mypy] -files = aiohttp, docs/code, examples, tests +files = aiohttp, docs/code, examples, fuzzers, tests check_untyped_defs = True follow_imports_for_stubs = True disallow_any_decorated = True diff --git a/MANIFEST.in b/MANIFEST.in index e25497f41fb..f4dbcb70d9b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -9,6 +9,7 @@ include .coveragerc-cython.toml graft aiohttp graft docs graft examples +graft fuzzers graft tests graft tools graft requirements diff --git a/fuzzers/http_parser.py b/fuzzers/http_parser.py new file mode 100644 index 00000000000..18d0d4a45cc --- /dev/null +++ b/fuzzers/http_parser.py @@ -0,0 +1,43 @@ +#!/usr/bin/python3 + +# Copyright 2022-2025 Google LLC, 2026 aio-libs contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import sys +from contextlib import suppress +from unittest import mock + +import atheris # noqa: I900 + +with atheris.instrument_imports(): # type: ignore[attr-defined] + from aiohttp.base_protocol import BaseProtocol + from aiohttp.http_exceptions import BadHttpMessage + from aiohttp.http_parser import HttpRequestParser + +LOOP = mock.create_autospec(asyncio.AbstractEventLoop, spec_set=True, instance=True) +PROTOCOL = BaseProtocol(LOOP) + + +@atheris.instrument_func # type: ignore[attr-defined] +def TestOneInput(data: bytes) -> None: # type: ignore[misc] + parser = HttpRequestParser(PROTOCOL, LOOP, 32768) + with suppress(BadHttpMessage): + parser.feed_data(data) + parser.feed_eof() + + +if __name__ == "__main__": + atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True) # type: ignore[attr-defined] + atheris.Fuzz() # type: ignore[attr-defined] diff --git a/fuzzers/http_payload_parser.py b/fuzzers/http_payload_parser.py new file mode 100644 index 00000000000..3e76461850c --- /dev/null +++ b/fuzzers/http_payload_parser.py @@ -0,0 +1,44 @@ +#!/usr/bin/python3 + +# Copyright 2022-2025 Google LLC, 2026 aio-libs contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import sys +from contextlib import suppress +from unittest import mock + +import atheris # noqa: I900 + +with atheris.instrument_imports(): # type: ignore[attr-defined] + from aiohttp import StreamReader + from aiohttp.base_protocol import BaseProtocol + from aiohttp.http_exceptions import BadHttpMessage + from aiohttp.http_parser import HeadersParser, HttpPayloadParser + +LOOP = mock.create_autospec(asyncio.AbstractEventLoop, spec_set=True, instance=True) +PROTOCOL = BaseProtocol(LOOP) + + +@atheris.instrument_func # type: ignore[attr-defined] +def TestOneInput(data: bytes) -> None: # type: ignore[misc] + out = StreamReader(PROTOCOL, 2**16, loop=LOOP) + parser = HttpPayloadParser(out, headers_parser=HeadersParser()) + with suppress(BadHttpMessage): + parser.feed_data(data) + + +if __name__ == "__main__": + atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True) # type: ignore[attr-defined] + atheris.Fuzz() # type: ignore[attr-defined] diff --git a/fuzzers/multipart.py b/fuzzers/multipart.py new file mode 100644 index 00000000000..36f01e21d58 --- /dev/null +++ b/fuzzers/multipart.py @@ -0,0 +1,69 @@ +#!/usr/bin/python3 + +# Copyright 2022-2025 Google LLC, 2026 aio-libs contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import io +import sys +from contextlib import suppress + +import atheris # noqa: I900 + +with atheris.instrument_imports(): # type: ignore[attr-defined] + from multidict import CIMultiDict + + from aiohttp import BodyPartReader, StreamReader + from aiohttp.hdrs import CONTENT_TYPE + from aiohttp.helpers import HeadersDictProxy + + +class FuzzStream(StreamReader): + def __init__(self, content: bytes): + self.content = io.BytesIO(content) + + async def read(self, size: int | None = None) -> bytes: + return self.content.read(size) + + def at_eof(self) -> bool: + return self.content.tell() == len(self.content.getbuffer()) + + async def readline(self, *, max_line_length: int | None = None) -> bytes: + return self.content.readline() + + def unread_data(self, data: bytes) -> None: + self.content = io.BytesIO(data + self.content.read()) + + +@atheris.instrument_func # type: ignore[attr-defined] +async def fuzz_bodypart_reader(data: bytes) -> None: # type: ignore[misc] + fdp = atheris.FuzzedDataProvider(data) # type: ignore[attr-defined] + obj = BodyPartReader( + b"--:", + HeadersDictProxy(CIMultiDict({CONTENT_TYPE: fdp.ConsumeUnicode(30)})), + FuzzStream(fdp.ConsumeBytes(atheris.ALL_REMAINING)), # type: ignore[attr-defined] + ) + if not obj.at_eof(): + await obj.form() + + +@atheris.instrument_func # type: ignore[attr-defined] +def TestOneInput(data: bytes) -> None: # type: ignore[misc] + with suppress(ValueError): + asyncio.run(fuzz_bodypart_reader(data)) + + +if __name__ == "__main__": + atheris.Setup(sys.argv, TestOneInput) # type: ignore[attr-defined] + atheris.Fuzz() # type: ignore[attr-defined] diff --git a/fuzzers/no_extensions.txt b/fuzzers/no_extensions.txt new file mode 100644 index 00000000000..c159bb31ad8 --- /dev/null +++ b/fuzzers/no_extensions.txt @@ -0,0 +1,3 @@ +# This lists fuzzers which should be executed a second time with AIOHTTP_NO_EXTENSIONS + +http_parser.py diff --git a/fuzzers/payload_url.py b/fuzzers/payload_url.py new file mode 100644 index 00000000000..eb7b5769510 --- /dev/null +++ b/fuzzers/payload_url.py @@ -0,0 +1,41 @@ +#!/usr/bin/python3 + +# Copyright 2022-2025 Google LLC, 2026 aio-libs contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +from contextlib import suppress + +import atheris # noqa: I900 + +with atheris.instrument_imports(): # type: ignore[attr-defined] + from yarl import URL + + from aiohttp.payload import StringPayload + + +@atheris.instrument_func # type: ignore[attr-defined] +def TestOneInput(data: bytes) -> None: # type: ignore[misc] + fdp = atheris.FuzzedDataProvider(data) # type: ignore[attr-defined] + original = fdp.ConsumeString(sys.maxsize) + + with suppress(UnicodeEncodeError): + StringPayload(original) + with suppress(ValueError): + URL(original) + + +if __name__ == "__main__": + atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True) # type: ignore[attr-defined] + atheris.Fuzz() # type: ignore[attr-defined] diff --git a/fuzzers/web_request.py b/fuzzers/web_request.py new file mode 100644 index 00000000000..3d430678e15 --- /dev/null +++ b/fuzzers/web_request.py @@ -0,0 +1,54 @@ +#!/usr/bin/python3 + +# Copyright 2022-2025 Google LLC, 2026 aio-libs contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +import sys + +import atheris # noqa: I900 + +with atheris.instrument_imports(): # type: ignore[attr-defined] + from multidict import CIMultiDict + from yarl import URL + + from aiohttp.test_utils import make_mocked_request + + +@atheris.instrument_func # type: ignore[attr-defined] +async def fuzz_run_one_async(data: bytes) -> None: # type: ignore[misc] + fdp = atheris.FuzzedDataProvider(data) # type: ignore[attr-defined] + url_s = fdp.ConsumeString(fdp.ConsumeIntInRange(0, 512)) + try: + URL(url_s) + except ValueError: + return + + headers = CIMultiDict( + {fdp.ConsumeString(20): fdp.ConsumeString(fdp.ConsumeIntInRange(0, 512))} + ) + req = make_mocked_request("GET", url_s, headers=headers) + + req.forwarded + await req.post() + + +@atheris.instrument_func # type: ignore[attr-defined] +def TestOneInput(data: bytes) -> None: # type: ignore[misc] + asyncio.run(fuzz_run_one_async(data)) + + +if __name__ == "__main__": + atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True) # type: ignore[attr-defined] + atheris.Fuzz() # type: ignore[attr-defined] diff --git a/requirements/lint.in b/requirements/lint.in index cc06ba19b26..21d75b3b3e2 100644 --- a/requirements/lint.in +++ b/requirements/lint.in @@ -1,5 +1,6 @@ aiodns aiofastnet >= 0.19.0 +atheris backports.zstd; implementation_name == "cpython" and python_version < "3.14" blockbuster freezegun diff --git a/requirements/lint.txt b/requirements/lint.txt index 06a73dda21e..3493875409d 100644 --- a/requirements/lint.txt +++ b/requirements/lint.txt @@ -20,6 +20,8 @@ async-timeout==5.0.1 # via # aiohttp # valkey +atheris==3.0.0 + # via -r requirements/lint.in attrs==26.1.0 # via aiohttp backports-asyncio-runner==1.2.0