Skip to content

Commit d456df0

Browse files
oschwaldclaude
andcommitted
Bound decoder work to prevent a pointer fan-out denial of service
A crafted data section could nest pointers to shared targets so that decoding one record cost exponential time and memory from a small file (GHSA-hj94-g986-h9r7). The decoder now limits the number of values it decodes for a single record and rejects a database that exceeds the limit with an InvalidDatabaseError. The limit is 65,536, far above the few hundred values the largest real records decode. Pointer cycles and over-deep data are rejected the same way rather than exhausting the stack. The limit state is call-local, so the decoder stays safe for concurrent reads. This matches the reader resource limits now recommended by the MaxMind DB specification. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8fc70e4 commit d456df0

4 files changed

Lines changed: 222 additions & 17 deletions

File tree

‎HISTORY.rst‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,16 @@
33
History
44
-------
55

6+
3.2.0
7+
+++++
8+
9+
* Fixed a denial-of-service issue in the pure Python decoder. A crafted database
10+
could nest data-section pointers to shared targets so that decoding one record
11+
could cost exponential time and memory from a small file. The decoder now
12+
limits the number of values it decodes for a single record and rejects a
13+
database that exceeds it, along with pointer cycles and over-deep data, with an
14+
``InvalidDatabaseError``. See GHSA-hj94-g986-h9r7.
15+
616
3.1.1 (2026-03-05)
717
++++++++++++++++++
818

‎maxminddb/decoder.py‎

Lines changed: 119 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,24 @@
1818
from maxminddb.file import FileBuffer
1919
from maxminddb.types import Record
2020

21-
DecoderFunc = Callable[["Decoder", int, int], tuple[Record, int]]
21+
DecoderFunc = Callable[["Decoder", int, int, list[int]], tuple[Record, int]]
22+
23+
24+
# Per-lookup limit on the number of values decoded, recommended by the MaxMind
25+
# DB specification. It stops a pointer fan-out, where nested pointers to shared
26+
# targets would otherwise cost 2**depth decode operations. The count follows
27+
# the specification's flat rule: the root is one value, each array and map
28+
# charges its declared children, and a pointer costs nothing beyond the value
29+
# it resolves to, which its container already charged. The largest real
30+
# records decode a few hundred values, so the limit leaves a wide margin.
31+
# Pointer cycles and over-deep data are caught separately by an explicit,
32+
# call-local depth limit (see ``decode``).
33+
_MAX_VALUES = 1 << 16
34+
_MAX_DEPTH = 512
35+
_TOO_MANY_VALUES = (
36+
"The MaxMind DB file's data section exceeds the maximum number of values"
37+
)
38+
_TOO_DEEP = "The MaxMind DB file's data section exceeds the maximum depth"
2239

2340

2441
class Decoder:
@@ -42,35 +59,72 @@ def __init__(
4259
self._buffer = database_buffer
4360
self._pointer_base = pointer_base
4461

45-
def _decode_array(self, size: int, offset: int) -> tuple[list[Record], int]:
62+
def _decode_array(
63+
self,
64+
size: int,
65+
offset: int,
66+
budget: list[int],
67+
) -> tuple[list[Record], int]:
68+
budget[0] -= size
69+
if budget[0] < 0:
70+
raise InvalidDatabaseError(_TOO_MANY_VALUES)
71+
budget[1] += 1
72+
if budget[1] > _MAX_DEPTH:
73+
raise InvalidDatabaseError(_TOO_DEEP)
4674
array = []
4775
for _ in range(size):
48-
(value, offset) = self.decode(offset)
76+
(value, offset) = self._decode(offset, budget)
4977
array.append(value)
78+
budget[1] -= 1
5079
return array, offset
5180

52-
def _decode_boolean(self, size: int, offset: int) -> tuple[bool, int]:
81+
def _decode_boolean(
82+
self,
83+
size: int,
84+
offset: int,
85+
_budget: list[int],
86+
) -> tuple[bool, int]:
5387
return size != 0, offset
5488

55-
def _decode_bytes(self, size: int, offset: int) -> tuple[bytes, int]:
89+
def _decode_bytes(
90+
self,
91+
size: int,
92+
offset: int,
93+
_budget: list[int],
94+
) -> tuple[bytes, int]:
5695
new_offset = offset + size
5796
return self._buffer[offset:new_offset], new_offset
5897

59-
def _decode_double(self, size: int, offset: int) -> tuple[float, int]:
98+
def _decode_double(
99+
self,
100+
size: int,
101+
offset: int,
102+
_budget: list[int],
103+
) -> tuple[float, int]:
60104
self._verify_size(size, 8)
61105
new_offset = offset + size
62106
packed_bytes = self._buffer[offset:new_offset]
63107
(value,) = struct.unpack(b"!d", packed_bytes)
64108
return value, new_offset
65109

66-
def _decode_float(self, size: int, offset: int) -> tuple[float, int]:
110+
def _decode_float(
111+
self,
112+
size: int,
113+
offset: int,
114+
_budget: list[int],
115+
) -> tuple[float, int]:
67116
self._verify_size(size, 4)
68117
new_offset = offset + size
69118
packed_bytes = self._buffer[offset:new_offset]
70119
(value,) = struct.unpack(b"!f", packed_bytes)
71120
return value, new_offset
72121

73-
def _decode_int32(self, size: int, offset: int) -> tuple[int, int]:
122+
def _decode_int32(
123+
self,
124+
size: int,
125+
offset: int,
126+
_budget: list[int],
127+
) -> tuple[int, int]:
74128
if size == 0:
75129
return 0, offset
76130
new_offset = offset + size
@@ -81,15 +135,33 @@ def _decode_int32(self, size: int, offset: int) -> tuple[int, int]:
81135
(value,) = struct.unpack(b"!i", packed_bytes)
82136
return value, new_offset
83137

84-
def _decode_map(self, size: int, offset: int) -> tuple[dict[str, Record], int]:
138+
def _decode_map(
139+
self,
140+
size: int,
141+
offset: int,
142+
budget: list[int],
143+
) -> tuple[dict[str, Record], int]:
144+
# A map entry decodes a key and a value, so it costs two values.
145+
budget[0] -= size * 2
146+
if budget[0] < 0:
147+
raise InvalidDatabaseError(_TOO_MANY_VALUES)
148+
budget[1] += 1
149+
if budget[1] > _MAX_DEPTH:
150+
raise InvalidDatabaseError(_TOO_DEEP)
85151
container: dict[str, Record] = {}
86152
for _ in range(size):
87-
(key, offset) = self.decode(offset)
88-
(value, offset) = self.decode(offset)
153+
(key, offset) = self._decode(offset, budget)
154+
(value, offset) = self._decode(offset, budget)
89155
container[cast("str", key)] = value
156+
budget[1] -= 1
90157
return container, offset
91158

92-
def _decode_pointer(self, size: int, offset: int) -> tuple[Record, int]:
159+
def _decode_pointer(
160+
self,
161+
size: int,
162+
offset: int,
163+
budget: list[int],
164+
) -> tuple[Record, int]:
93165
pointer_size = (size >> 3) + 1
94166

95167
buf = self._buffer[offset : offset + pointer_size]
@@ -109,15 +181,32 @@ def _decode_pointer(self, size: int, offset: int) -> tuple[Record, int]:
109181

110182
if self._pointer_test:
111183
return pointer, new_offset
112-
(value, _) = self.decode(pointer)
184+
185+
# The value at the pointer's position was charged by its containing
186+
# array or map, so the target costs nothing more. Only the depth changes.
187+
budget[1] += 1
188+
if budget[1] > _MAX_DEPTH:
189+
raise InvalidDatabaseError(_TOO_DEEP)
190+
(value, _) = self._decode(pointer, budget)
191+
budget[1] -= 1
113192
return value, new_offset
114193

115-
def _decode_uint(self, size: int, offset: int) -> tuple[int, int]:
194+
def _decode_uint(
195+
self,
196+
size: int,
197+
offset: int,
198+
_budget: list[int],
199+
) -> tuple[int, int]:
116200
new_offset = offset + size
117201
uint_bytes = self._buffer[offset:new_offset]
118202
return int.from_bytes(uint_bytes, "big"), new_offset
119203

120-
def _decode_utf8_string(self, size: int, offset: int) -> tuple[str, int]:
204+
def _decode_utf8_string(
205+
self,
206+
size: int,
207+
offset: int,
208+
_budget: list[int],
209+
) -> tuple[str, int]:
121210
new_offset = offset + size
122211
return self._buffer[offset:new_offset].decode("utf-8"), new_offset
123212

@@ -144,6 +233,20 @@ def decode(self, offset: int) -> tuple[Record, int]:
144233
offset: the location of the data structure to decode
145234
146235
"""
236+
# Bound the work per lookup so a crafted database cannot exhaust CPU or
237+
# memory. ``budget`` carries the remaining value count and current
238+
# nested decode depth so both are shared across the recursion. It is
239+
# call-local, which keeps the decoder safe for concurrent reads. The
240+
# root value is charged here; containers charge their children. The
241+
# explicit depth limit is independent of Python's process-wide recursion
242+
# limit; RecursionError remains a fallback on interpreters whose stack
243+
# limit is reached first.
244+
try:
245+
return self._decode(offset, [_MAX_VALUES - 1, 0])
246+
except RecursionError as ex:
247+
raise InvalidDatabaseError(_TOO_DEEP) from ex
248+
249+
def _decode(self, offset: int, budget: list[int]) -> tuple[Record, int]:
147250
new_offset = offset + 1
148251
ctrl_byte = self._buffer[offset]
149252
type_num = ctrl_byte >> 5
@@ -160,7 +263,7 @@ def decode(self, offset: int) -> tuple[Record, int]:
160263
) from ex
161264

162265
(size, new_offset) = self._size_from_ctrl_byte(ctrl_byte, new_offset, type_num)
163-
return decoder(self, size, new_offset)
266+
return decoder(self, size, new_offset, budget)
164267

165268
def _read_extended(self, offset: int) -> tuple[int, int]:
166269
next_byte = self._buffer[offset]

‎tests/decoder_test.py‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
from __future__ import annotations
22

33
import mmap
4+
import sys
45
import unittest
56
from typing import TYPE_CHECKING, Any, ClassVar
67

78
from maxminddb.decoder import Decoder
9+
from maxminddb.errors import InvalidDatabaseError
810

911
if TYPE_CHECKING:
1012
from _typeshed import SizedBuffer
1113

14+
# Each structural level uses about two Python frames. This lets the 513-level
15+
# cases reach the decoder's explicit limit with ample test-harness headroom.
16+
_DEPTH_TEST_RECURSION_LIMIT = 2_000
17+
1218

1319
class TestDecoder(unittest.TestCase):
1420
def test_arrays(self) -> None:
@@ -232,3 +238,89 @@ def test_real_pointers(self) -> None:
232238
self.assertEqual(({"long_key2": "long_value2"}, 59), decoder.decode(57))
233239

234240
mm.close()
241+
242+
@staticmethod
243+
def _pointer(target: int) -> bytes:
244+
# One-byte-payload pointer (type 1, pointer_size 1) with base 0.
245+
return bytes([(1 << 5) | ((target >> 8) & 0x7), target & 0xFF])
246+
247+
def test_pointer_fan_out_is_bounded(self) -> None:
248+
# A data section of nested arrays, each holding two pointers to the
249+
# node below, would cost 2**depth decode operations. The decoder bounds
250+
# the number of values it decodes per lookup and rejects the database.
251+
depth = 100
252+
buf = bytearray([0xA0]) # leaf: uint16 with value 0
253+
prev = 0
254+
for _ in range(depth):
255+
offset = len(buf)
256+
buf += bytes([0x02, 0x04]) + self._pointer(prev) + self._pointer(prev)
257+
prev = offset
258+
259+
with self.assertRaises(InvalidDatabaseError):
260+
Decoder(bytes(buf), pointer_base=0).decode(prev)
261+
262+
@classmethod
263+
def _scalar_pointer_array(cls, pointer_count: int) -> bytes:
264+
# A uint16 leaf at offset 0 and, at offset 1, an array of pointers to
265+
# it. 0x1e: extended type with size code 30; 0x04: array.
266+
header = bytes([0xA0, 0x1E, 0x04]) + (pointer_count - 285).to_bytes(2, "big")
267+
return header + cls._pointer(0) * pointer_count
268+
269+
def test_value_limit_follows_the_flat_rule(self) -> None:
270+
# The specification charges the root as one value and each pointer as
271+
# the value it resolves to, not as a separate value. An array of 65,535
272+
# pointers to a scalar is therefore 65,536 values, exactly the limit,
273+
# and decodes. One more pointer exceeds it.
274+
(decoded, _) = Decoder(
275+
self._scalar_pointer_array(65_535), pointer_base=0
276+
).decode(1)
277+
self.assertEqual(decoded, [0] * 65_535)
278+
279+
with self.assertRaisesRegex(
280+
InvalidDatabaseError,
281+
"^The MaxMind DB file's data section exceeds the maximum number of values$",
282+
):
283+
Decoder(self._scalar_pointer_array(65_536), pointer_base=0).decode(1)
284+
285+
def test_cyclic_pointer_raises(self) -> None:
286+
# A pointer to itself must hit the decoder's own depth limit even when
287+
# Python's process-wide recursion limit is much higher.
288+
cyclic = bytes([0x20, 0x00]) # pointer (base 0) to offset 0, itself
289+
old_recursion_limit = sys.getrecursionlimit()
290+
try:
291+
sys.setrecursionlimit(_DEPTH_TEST_RECURSION_LIMIT)
292+
with self.assertRaisesRegex(
293+
InvalidDatabaseError,
294+
"^The MaxMind DB file's data section exceeds the maximum depth$",
295+
):
296+
Decoder(cyclic, pointer_base=0).decode(0)
297+
finally:
298+
sys.setrecursionlimit(old_recursion_limit)
299+
300+
def test_container_depth_is_bounded_independently_of_recursion_limit(self) -> None:
301+
# Each prefix is an array with one element. Raising Python's global
302+
# recursion limit proves that the decoder's call-local limit is what
303+
# accepts 512 containers and rejects the 513th.
304+
at_limit = bytes([0x01, 0x04]) * 512 + bytes([0xA0])
305+
over_limit = bytes([0x01, 0x04]) * 513 + bytes([0xA0])
306+
old_recursion_limit = sys.getrecursionlimit()
307+
try:
308+
sys.setrecursionlimit(_DEPTH_TEST_RECURSION_LIMIT)
309+
Decoder(at_limit, pointer_base=0).decode(0)
310+
with self.assertRaisesRegex(
311+
InvalidDatabaseError,
312+
"^The MaxMind DB file's data section exceeds the maximum depth$",
313+
):
314+
Decoder(over_limit, pointer_base=0).decode(0)
315+
finally:
316+
sys.setrecursionlimit(old_recursion_limit)
317+
318+
def test_oversized_map_is_bounded(self) -> None:
319+
# A map entry decodes a key and a value, so a map of N entries costs
320+
# 2N values. A map that declares 32,769 entries reaches 65,538 values,
321+
# just past the 65,536 limit, and is rejected before any entry is read.
322+
# 0xfe: map with size code 30, then the two size bytes for
323+
# 32,769 - 285 = 32,484 (0x7ee4).
324+
oversized_map = bytes([0xFE, 0x7E, 0xE4])
325+
with self.assertRaises(InvalidDatabaseError):
326+
Decoder(oversized_map, pointer_base=0).decode(0)

0 commit comments

Comments
 (0)