Skip to content

Commit 29829f7

Browse files
authored
fuzz: initial integration (#117)
* fuzz: initial integration * ci: skip atheris for 3.12
1 parent 810ac1a commit 29829f7

File tree

10 files changed

+167
-0
lines changed

10 files changed

+167
-0
lines changed

fuzz/README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Fuzz Testing
2+
3+
Fuzz testing is:
4+
5+
> An automated software testing technique that involves providing invalid, unexpected, or random data as inputs to a program.
6+
7+
We use coverage guided fuzz testing to automatically discover bugs in python-multipart.
8+
9+
This `fuzz/` directory contains the configuration and the fuzz tests for python-multipart.
10+
To generate and run fuzz tests, we use the [Atheris](https://github.com/google/atheris) library.
11+
12+
## Running a fuzzer
13+
14+
This directory contains fuzzers like for example `fuzz_form.py`. You can run it with:
15+
16+
Run fuzz target:
17+
```sh
18+
$ python fuzz/fuzz_form.py
19+
```
20+
21+
You should see output that looks something like this:
22+
23+
```
24+
#2 INITED cov: 32 ft: 32 corp: 1/1b exec/s: 0 rss: 49Mb
25+
#3 NEW cov: 33 ft: 33 corp: 2/2b lim: 4 exec/s: 0 rss: 49Mb L: 1/1 MS: 1 ChangeByte-
26+
#4 NEW cov: 97 ft: 97 corp: 3/4b lim: 4 exec/s: 0 rss: 49Mb L: 2/2 MS: 1 InsertByte-
27+
#11 NEW cov: 116 ft: 119 corp: 4/5b lim: 4 exec/s: 0 rss: 49Mb L: 1/2 MS: 2 ChangeBinInt-EraseBytes-
28+
#30 NEW cov: 131 ft: 134 corp: 5/8b lim: 4 exec/s: 0 rss: 49Mb L: 3/3 MS: 4 ChangeByte-ChangeBit-InsertByte-CopyPart-
29+
#31 NEW cov: 135 ft: 138 corp: 6/11b lim: 4 exec/s: 0 rss: 49Mb L: 3/3 MS: 1 CrossOver-
30+
#39 NEW cov: 135 ft: 142 corp: 7/15b lim: 4 exec/s: 0 rss: 49Mb L: 4/4 MS: 3 ChangeBit-CrossOver-CopyPart-
31+
```
32+
33+
It will continue to generate random inputs forever, until it finds a
34+
bug or is terminated. The testcases for bugs it finds can be seen in
35+
the form of `crash-*` or `timeout-*` at the place from where command is run.
36+
You can rerun the fuzzer on a single input by passing it on the
37+
command line `python fuzz/fuzz_form.py /path/to/testcase`.

fuzz/corpus/fuzz_decoders/fuzz_decoders

Whitespace-only changes.

fuzz/corpus/fuzz_form/fuzz_form

Whitespace-only changes.

fuzz/corpus/fuzz_options_header/fuzz_options_header

Whitespace-only changes.

fuzz/fuzz_decoders.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import io
2+
import sys
3+
4+
import atheris
5+
from helpers import EnhancedDataProvider
6+
7+
with atheris.instrument_imports():
8+
from multipart.decoders import Base64Decoder, DecodeError, QuotedPrintableDecoder
9+
10+
11+
def fuzz_base64_decoder(fdp: EnhancedDataProvider) -> None:
12+
decoder = Base64Decoder(io.BytesIO())
13+
decoder.write(fdp.ConsumeRandomBytes())
14+
decoder.finalize()
15+
16+
17+
def fuzz_quoted_decoder(fdp: EnhancedDataProvider) -> None:
18+
decoder = QuotedPrintableDecoder(io.BytesIO())
19+
decoder.write(fdp.ConsumeRandomBytes())
20+
decoder.finalize()
21+
22+
23+
def TestOneInput(data: bytes) -> None:
24+
fdp = EnhancedDataProvider(data)
25+
targets = [fuzz_base64_decoder, fuzz_quoted_decoder]
26+
target = fdp.PickValueInList(targets)
27+
28+
try:
29+
target(fdp)
30+
except DecodeError:
31+
return
32+
33+
34+
def main():
35+
atheris.Setup(sys.argv, TestOneInput)
36+
atheris.Fuzz()
37+
38+
39+
if __name__ == "__main__":
40+
main()

fuzz/fuzz_form.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import io
2+
import sys
3+
from unittest.mock import Mock
4+
5+
import atheris
6+
from helpers import EnhancedDataProvider
7+
8+
with atheris.instrument_imports():
9+
from multipart.exceptions import FormParserError
10+
from multipart.multipart import parse_form
11+
12+
on_field = Mock()
13+
on_file = Mock()
14+
15+
16+
def parse_octet_stream(fdp: EnhancedDataProvider) -> None:
17+
header = {"Content-Type": "application/octet-stream"}
18+
parse_form(header, io.BytesIO(fdp.ConsumeRandomBytes()), on_field, on_file)
19+
20+
21+
def parse_url_encoded(fdp: EnhancedDataProvider) -> None:
22+
header = {"Content-Type": "application/x-url-encoded"}
23+
parse_form(header, io.BytesIO(fdp.ConsumeRandomBytes()), on_field, on_file)
24+
25+
26+
def parse_form_urlencoded(fdp: EnhancedDataProvider) -> None:
27+
header = {"Content-Type": "application/x-www-form-urlencoded"}
28+
parse_form(header, io.BytesIO(fdp.ConsumeRandomBytes()), on_field, on_file)
29+
30+
31+
def parse_multipart_form_data(fdp: EnhancedDataProvider) -> None:
32+
header = {"Content-Type": "multipart/form-data; boundary=--boundary"}
33+
parse_form(header, io.BytesIO(fdp.ConsumeRandomBytes()), on_field, on_file)
34+
35+
36+
def TestOneInput(data: bytes) -> None:
37+
fdp = EnhancedDataProvider(data)
38+
targets = [parse_octet_stream, parse_url_encoded, parse_form_urlencoded, parse_multipart_form_data]
39+
target = fdp.PickValueInList(targets)
40+
41+
try:
42+
target(fdp)
43+
except FormParserError:
44+
return
45+
46+
47+
def main():
48+
atheris.Setup(sys.argv, TestOneInput)
49+
atheris.Fuzz()
50+
51+
52+
if __name__ == "__main__":
53+
main()

fuzz/fuzz_options_header.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import sys
2+
3+
import atheris
4+
from helpers import EnhancedDataProvider
5+
6+
with atheris.instrument_imports():
7+
from multipart.multipart import parse_options_header
8+
9+
10+
def TestOneInput(data: bytes) -> None:
11+
fdp = EnhancedDataProvider(data)
12+
try:
13+
parse_options_header(fdp.ConsumeRandomBytes())
14+
except AssertionError:
15+
return
16+
except TypeError:
17+
return
18+
19+
20+
def main():
21+
atheris.Setup(sys.argv, TestOneInput)
22+
atheris.Fuzz()
23+
24+
25+
if __name__ == "__main__":
26+
main()

fuzz/helpers.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import atheris
2+
3+
4+
class EnhancedDataProvider(atheris.FuzzedDataProvider):
5+
def ConsumeRandomBytes(self) -> bytes:
6+
return self.ConsumeBytes(self.ConsumeIntInRange(0, self.remaining_bytes()))
7+
8+
def ConsumeRandomString(self) -> str:
9+
return self.ConsumeUnicodeNoSurrogates(self.ConsumeIntInRange(0, self.remaining_bytes()))

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ dev = [
4646
"pytest-timeout==2.2.0",
4747
"ruff==0.3.0",
4848
"hatch",
49+
"atheris==2.3.0; python_version != '3.12'",
4950
]
5051
docs = [
5152
"mkdocs==1.5.3",

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ py==1.11.0
88
pytest==8.0.2
99
PyYAML==6.0.1
1010
ruff==0.3.0
11+
atheris==2.3.0

0 commit comments

Comments
 (0)