| #!/usr/bin/env python3 |
| """OSS-Fuzz target for the whole-domain and per-label API. |
| |
| Each input selects an operation and flag combination from its leading bytes |
| and feeds the remainder as the domain or label. The harness asserts the same |
| invariants as ``tests/test_idna_properties.py``: only :class:`idna.IDNAError` |
| may escape, successful output is bounded ASCII, encoding then decoding then |
| encoding again is stable, and UTS #46 remapping is NFC and idempotent. |
| |
| OSS-Fuzz builds every ``fuzz_*.py`` it finds in the checkout, so this file |
| needs no registration there. To run it locally:: |
| |
| pip install atheris . |
| python tests/fuzz_idna_api.py -max_total_time=60 |
| |
| Any libFuzzer flag is accepted; a crash writes a ``crash-*`` file which can be |
| passed back as an argument to reproduce. ``tests/test_idna_fuzz_targets.py`` |
| smoke-tests this harness in the ordinary test suite without atheris. |
| """ |
| |
| import sys |
| import unicodedata |
| |
| import atheris # ty: ignore[unresolved-import] |
| |
| with atheris.instrument_imports(): |
| import idna |
| |
| MAX_INPUT = 1100 # just past idna's 1024-character input cap |
| STD3_ASCII = frozenset("abcdefghijklmnopqrstuvwxyz0123456789-.") |
| |
| |
| def _only_idnaerror(fn, *args, **kwargs): |
| try: |
| return fn(*args, **kwargs) |
| except idna.IDNAError: |
| return None |
| |
| |
| def fuzz_encode(fdp): |
| strict, uts46, std3 = fdp.ConsumeBool(), fdp.ConsumeBool(), fdp.ConsumeBool() |
| s = fdp.ConsumeUnicode(MAX_INPUT) |
| encoded = _only_idnaerror(idna.encode, s, strict=strict, uts46=uts46, std3_rules=std3) |
| if encoded is None: |
| return |
| encoded.decode("ascii") |
| assert len(encoded) <= 254, encoded |
| for label in encoded.rstrip(b".").split(b"."): |
| assert 0 < len(label) <= 63, encoded |
| # Anything encode() produced must decode, and re-encode to itself |
| # (up to ASCII case, since ulabel() lowercases while alabel() does not). |
| decoded = idna.decode(encoded) |
| assert idna.encode(decoded) == encoded.lower(), (encoded, decoded) |
| assert idna.decode(encoded, display=True) == decoded |
| |
| |
| def fuzz_decode(fdp): |
| strict, uts46, std3, display = fdp.ConsumeBool(), fdp.ConsumeBool(), fdp.ConsumeBool(), fdp.ConsumeBool() |
| data = fdp.ConsumeUnicode(MAX_INPUT) if fdp.ConsumeBool() else fdp.ConsumeBytes(MAX_INPUT) |
| decoded = _only_idnaerror(idna.decode, data, strict=strict, uts46=uts46, std3_rules=std3, display=display) |
| if decoded is None or not strict or uts46 or display: |
| return |
| # RFC 5891 §5.3: an A-label that decodes must re-encode to itself, so |
| # under strict non-UTS46 processing any ASCII input that decodes is (up |
| # to case) its own encoding. decode() does not enforce the 63-octet |
| # label limit (nor does UTS #46 ToUnicode) but encode() does, so skip |
| # overlong labels. |
| if isinstance(data, str): |
| if not data.isascii(): |
| return |
| data = data.encode("ascii") |
| if all(len(label) <= 63 for label in data.split(b".")): |
| assert idna.encode(decoded, strict=True) == data.lower(), (data, decoded) |
| |
| |
| def fuzz_uts46_remap(fdp): |
| std3 = fdp.ConsumeBool() |
| s = fdp.ConsumeUnicode(MAX_INPUT) |
| out = _only_idnaerror(idna.uts46_remap, s, std3_rules=std3) |
| if out is None: |
| return |
| assert unicodedata.is_normalized("NFC", out), out |
| # Mapping can expand the input (U+FDFA maps to 18 characters), so the |
| # output may exceed the defensive input-length cap that a second pass |
| # would reject; idempotency only holds for output within the cap. |
| if len(out) <= idna.core._max_input_length: |
| assert idna.uts46_remap(out, std3_rules=std3) == out, out |
| if std3: # UTS #46 §4.1 UseSTD3ASCIIRules |
| assert all(c in STD3_ASCII for c in out if c.isascii()), out |
| |
| |
| def fuzz_labels(fdp): |
| label = fdp.ConsumeUnicode(MAX_INPUT) |
| for fn in ( |
| idna.alabel, |
| idna.ulabel, |
| idna.check_label, |
| idna.check_bidi, |
| idna.check_hyphen_ok, |
| idna.check_initial_combiner, |
| idna.check_nfc, |
| idna.valid_label_length, |
| ): |
| _only_idnaerror(fn, label) |
| _only_idnaerror(idna.ulabel, label.encode("utf-8", "surrogatepass")) |
| _only_idnaerror(idna.check_label, label.encode("utf-8", "surrogatepass")) |
| encoded = _only_idnaerror(idna.alabel, label) |
| if encoded is not None: |
| assert idna.alabel(idna.ulabel(encoded)) == encoded.lower(), (label, encoded) |
| |
| |
| OPERATIONS = (fuzz_encode, fuzz_decode, fuzz_uts46_remap, fuzz_labels) |
| |
| |
| def TestOneInput(data): |
| fdp = atheris.FuzzedDataProvider(data) |
| OPERATIONS[fdp.ConsumeIntInRange(0, len(OPERATIONS) - 1)](fdp) |
| |
| |
| def main(): |
| atheris.Setup(sys.argv, TestOneInput, enable_python_coverage=True) |
| atheris.Fuzz() |
| |
| |
| if __name__ == "__main__": |
| main() |