|
3 | 3 | import mmap |
4 | 4 | import sys |
5 | 5 | import unittest |
6 | | -from typing import TYPE_CHECKING, Any, ClassVar |
| 6 | +from typing import TYPE_CHECKING, Any, ClassVar, cast |
7 | 7 |
|
| 8 | +from maxminddb import MODE_MEMORY, open_database |
8 | 9 | from maxminddb.decoder import Decoder |
9 | 10 | from maxminddb.errors import InvalidDatabaseError |
10 | 11 |
|
|
15 | 16 | # cases reach the decoder's explicit limit with ample test-harness headroom. |
16 | 17 | _DEPTH_TEST_RECURSION_LIMIT = 2_000 |
17 | 18 |
|
| 19 | +# Directory holding the shared MaxMind DB test fixtures. |
| 20 | +_TEST_DATA_DIR = "tests/data/test-data" |
| 21 | +_PAYLOAD_TOO_LARGE = ( |
| 22 | + "^The MaxMind DB file's data section exceeds the maximum payload size$" |
| 23 | +) |
| 24 | + |
18 | 25 |
|
19 | 26 | class TestDecoder(unittest.TestCase): |
20 | 27 | def test_arrays(self) -> None: |
@@ -324,3 +331,121 @@ def test_oversized_map_is_bounded(self) -> None: |
324 | 331 | oversized_map = bytes([0xFE, 0x7E, 0xE4]) |
325 | 332 | with self.assertRaises(InvalidDatabaseError): |
326 | 333 | Decoder(oversized_map, pointer_base=0).decode(0) |
| 334 | + |
| 335 | + def test_oversized_string_payload_is_bounded(self) -> None: |
| 336 | + # A single string that declares one byte more than the 2 MiB payload |
| 337 | + # limit is rejected before its bytes are copied. This also covers the |
| 338 | + # wrapped-scalar variant: the charge is applied wherever a string is |
| 339 | + # decoded, not only for a direct pointer target. 0x5f: string with size |
| 340 | + # code 31; 0x1efee4: 2,097,153 - 65,821, one byte over 2 MiB. |
| 341 | + oversized_string = bytes([0x5F, 0x1E, 0xFE, 0xE4]) |
| 342 | + with self.assertRaisesRegex(InvalidDatabaseError, _PAYLOAD_TOO_LARGE): |
| 343 | + Decoder(oversized_string, pointer_base=0).decode(0) |
| 344 | + |
| 345 | + def test_oversized_bytes_payload_is_bounded(self) -> None: |
| 346 | + # As above for the bytes type. 0x9f: bytes with size code 31. |
| 347 | + oversized_bytes = bytes([0x9F, 0x1E, 0xFE, 0xE4]) |
| 348 | + with self.assertRaisesRegex(InvalidDatabaseError, _PAYLOAD_TOO_LARGE): |
| 349 | + Decoder(oversized_bytes, pointer_base=0).decode(0) |
| 350 | + |
| 351 | + def test_oversized_uint_is_bounded(self) -> None: |
| 352 | + # A uint128 that declares 17 bytes exceeds the 16-byte format maximum |
| 353 | + # and is rejected before the declared bytes are copied. 0x11: extended |
| 354 | + # type, size 17; 0x03: extended type number 10 (uint128). |
| 355 | + oversized_uint = bytes([0x11, 0x03]) |
| 356 | + with self.assertRaises(InvalidDatabaseError): |
| 357 | + Decoder(oversized_uint, pointer_base=0).decode(0) |
| 358 | + |
| 359 | + def test_oversized_int32_is_bounded(self) -> None: |
| 360 | + # An int32 that declares 5 bytes exceeds its 4-byte maximum and is |
| 361 | + # rejected before the declared bytes are copied. 0x05: extended type, |
| 362 | + # size 5; 0x01: extended type number 8 (int32). |
| 363 | + oversized_int32 = bytes([0x05, 0x01]) |
| 364 | + with self.assertRaises(InvalidDatabaseError): |
| 365 | + Decoder(oversized_int32, pointer_base=0).decode(0) |
| 366 | + |
| 367 | + |
| 368 | +class TestDecoderResourceLimits(unittest.TestCase): |
| 369 | + """Fixture-backed checks for the pure-Python decoder resource limits.""" |
| 370 | + |
| 371 | + @staticmethod |
| 372 | + def _lookup(filename: str, ip: str = "0.0.0.1") -> object: |
| 373 | + # MODE_MEMORY forces the pure-Python decoder. Each DoS fixture resolves |
| 374 | + # any IPv4 address to its single crafted record. |
| 375 | + with open_database(f"{_TEST_DATA_DIR}/{filename}", mode=MODE_MEMORY) as reader: |
| 376 | + return reader.get(ip) |
| 377 | + |
| 378 | + def test_payload_amplification_is_rejected(self) -> None: |
| 379 | + # An array of 8,192 pointers to one 65,535-byte value. The value count |
| 380 | + # stays low, but copying each target would materialize about 512 MiB. |
| 381 | + with self.assertRaisesRegex(InvalidDatabaseError, _PAYLOAD_TOO_LARGE): |
| 382 | + self._lookup("MaxMind-DB-test-payload-amplification-dos.mmdb") |
| 383 | + |
| 384 | + def test_payload_amplification_string_is_rejected(self) -> None: |
| 385 | + # The UTF-8 string variant, so the decode path for strings is exercised. |
| 386 | + with self.assertRaisesRegex(InvalidDatabaseError, _PAYLOAD_TOO_LARGE): |
| 387 | + self._lookup("MaxMind-DB-test-payload-amplification-dos-string.mmdb") |
| 388 | + |
| 389 | + def test_payload_amplification_worst_case_is_rejected(self) -> None: |
| 390 | + # 65,535 pointers to one 65,535-byte value. The record is exactly |
| 391 | + # 65,536 values under the flat rule, so only the payload budget can |
| 392 | + # reject it. |
| 393 | + with self.assertRaisesRegex(InvalidDatabaseError, _PAYLOAD_TOO_LARGE): |
| 394 | + self._lookup("MaxMind-DB-test-payload-amplification-dos-worst-case.mmdb") |
| 395 | + |
| 396 | + def test_value_count_boundary(self) -> None: |
| 397 | + # The at-limit fixture decodes to exactly 65,536 values and must decode. |
| 398 | + # The pointer-heavy fixture reaches 65,535 values through pointers, |
| 399 | + # which cost nothing beyond the values they resolve to. One value more |
| 400 | + # than the limit is rejected. |
| 401 | + self.assertIsInstance( |
| 402 | + self._lookup("MaxMind-DB-test-decoder-value-limit.mmdb"), |
| 403 | + list, |
| 404 | + ) |
| 405 | + self.assertIsInstance( |
| 406 | + self._lookup("MaxMind-DB-test-decoder-value-limit-pointer-heavy.mmdb"), |
| 407 | + list, |
| 408 | + ) |
| 409 | + with self.assertRaisesRegex( |
| 410 | + InvalidDatabaseError, |
| 411 | + "^The MaxMind DB file's data section exceeds the maximum number of values$", |
| 412 | + ): |
| 413 | + self._lookup("MaxMind-DB-test-decoder-value-limit-over.mmdb") |
| 414 | + |
| 415 | + def test_pointer_fan_out_fixture_is_rejected(self) -> None: |
| 416 | + # A full database whose record nests arrays of pointers to the level |
| 417 | + # below, the classic 2**depth fan-out. |
| 418 | + with self.assertRaises(InvalidDatabaseError): |
| 419 | + self._lookup("MaxMind-DB-test-pointer-decoder-dos.mmdb") |
| 420 | + |
| 421 | + def test_payload_at_limit_is_accepted(self) -> None: |
| 422 | + # References totaling exactly 2 MiB of payload decode successfully, so |
| 423 | + # the limit does not reject a record at the boundary. |
| 424 | + self.assertIsInstance( |
| 425 | + self._lookup("MaxMind-DB-test-decoder-payload-limit.mmdb"), |
| 426 | + list, |
| 427 | + ) |
| 428 | + |
| 429 | + def test_payload_one_over_limit_is_rejected(self) -> None: |
| 430 | + # One byte more than 2 MiB is rejected, catching an off-by-one. |
| 431 | + with self.assertRaisesRegex(InvalidDatabaseError, _PAYLOAD_TOO_LARGE): |
| 432 | + self._lookup("MaxMind-DB-test-decoder-payload-limit-over.mmdb") |
| 433 | + |
| 434 | + def test_metadata_payload_limit_is_enforced_on_open(self) -> None: |
| 435 | + # The same decoder reads metadata on open, so an over-limit metadata |
| 436 | + # structure is rejected there too. |
| 437 | + with self.assertRaisesRegex(InvalidDatabaseError, _PAYLOAD_TOO_LARGE): |
| 438 | + open_database( |
| 439 | + f"{_TEST_DATA_DIR}/MaxMind-DB-test-metadata-payload-limit.mmdb", |
| 440 | + mode=MODE_MEMORY, |
| 441 | + ) |
| 442 | + |
| 443 | + def test_normal_record_still_decodes(self) -> None: |
| 444 | + # A record with ordinary string and bytes values, which the payload |
| 445 | + # budget also charges, decodes unchanged. |
| 446 | + record = cast( |
| 447 | + "dict", |
| 448 | + self._lookup("MaxMind-DB-test-decoder.mmdb", "::1.1.1.0"), |
| 449 | + ) |
| 450 | + self.assertEqual(record["utf8_string"], "unicode! ☯ - ♫") |
| 451 | + self.assertEqual(record["bytes"], b"\x00\x00\x00*") |
0 commit comments