Skip to content

Commit 1eff08a

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 e1446f1 commit 1eff08a

3 files changed

Lines changed: 147 additions & 16 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+
cost exponential time and memory from a small file. The decoder now limits the
12+
number of values it decodes for a single record and rejects a database that
13+
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: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,19 @@
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 largest real
27+
# records decode a few hundred values, so the limit leaves a wide margin.
28+
# Pointer cycles and over-deep data are caught separately by Python's own
29+
# recursion limit (see ``decode``).
30+
_MAX_VALUES = 1 << 16
31+
_TOO_MANY_VALUES = (
32+
"The MaxMind DB file's data section exceeds the maximum number of values"
33+
)
2234

2335

2436
class Decoder:
@@ -42,35 +54,68 @@ def __init__(
4254
self._buffer = database_buffer
4355
self._pointer_base = pointer_base
4456

45-
def _decode_array(self, size: int, offset: int) -> tuple[list[Record], int]:
57+
def _decode_array(
58+
self,
59+
size: int,
60+
offset: int,
61+
budget: list[int],
62+
) -> tuple[list[Record], int]:
63+
budget[0] -= size
64+
if budget[0] < 0:
65+
raise InvalidDatabaseError(_TOO_MANY_VALUES)
4666
array = []
4767
for _ in range(size):
48-
(value, offset) = self.decode(offset)
68+
(value, offset) = self._decode(offset, budget)
4969
array.append(value)
5070
return array, offset
5171

52-
def _decode_boolean(self, size: int, offset: int) -> tuple[bool, int]:
72+
def _decode_boolean(
73+
self,
74+
size: int,
75+
offset: int,
76+
_budget: list[int],
77+
) -> tuple[bool, int]:
5378
return size != 0, offset
5479

55-
def _decode_bytes(self, size: int, offset: int) -> tuple[bytes, int]:
80+
def _decode_bytes(
81+
self,
82+
size: int,
83+
offset: int,
84+
_budget: list[int],
85+
) -> tuple[bytes, int]:
5686
new_offset = offset + size
5787
return self._buffer[offset:new_offset], new_offset
5888

59-
def _decode_double(self, size: int, offset: int) -> tuple[float, int]:
89+
def _decode_double(
90+
self,
91+
size: int,
92+
offset: int,
93+
_budget: list[int],
94+
) -> tuple[float, int]:
6095
self._verify_size(size, 8)
6196
new_offset = offset + size
6297
packed_bytes = self._buffer[offset:new_offset]
6398
(value,) = struct.unpack(b"!d", packed_bytes)
6499
return value, new_offset
65100

66-
def _decode_float(self, size: int, offset: int) -> tuple[float, int]:
101+
def _decode_float(
102+
self,
103+
size: int,
104+
offset: int,
105+
_budget: list[int],
106+
) -> tuple[float, int]:
67107
self._verify_size(size, 4)
68108
new_offset = offset + size
69109
packed_bytes = self._buffer[offset:new_offset]
70110
(value,) = struct.unpack(b"!f", packed_bytes)
71111
return value, new_offset
72112

73-
def _decode_int32(self, size: int, offset: int) -> tuple[int, int]:
113+
def _decode_int32(
114+
self,
115+
size: int,
116+
offset: int,
117+
_budget: list[int],
118+
) -> tuple[int, int]:
74119
if size == 0:
75120
return 0, offset
76121
new_offset = offset + size
@@ -81,15 +126,29 @@ def _decode_int32(self, size: int, offset: int) -> tuple[int, int]:
81126
(value,) = struct.unpack(b"!i", packed_bytes)
82127
return value, new_offset
83128

84-
def _decode_map(self, size: int, offset: int) -> tuple[dict[str, Record], int]:
129+
def _decode_map(
130+
self,
131+
size: int,
132+
offset: int,
133+
budget: list[int],
134+
) -> tuple[dict[str, Record], int]:
135+
# A map entry decodes a key and a value, so it costs two values.
136+
budget[0] -= size * 2
137+
if budget[0] < 0:
138+
raise InvalidDatabaseError(_TOO_MANY_VALUES)
85139
container: dict[str, Record] = {}
86140
for _ in range(size):
87-
(key, offset) = self.decode(offset)
88-
(value, offset) = self.decode(offset)
141+
(key, offset) = self._decode(offset, budget)
142+
(value, offset) = self._decode(offset, budget)
89143
container[cast("str", key)] = value
90144
return container, offset
91145

92-
def _decode_pointer(self, size: int, offset: int) -> tuple[Record, int]:
146+
def _decode_pointer(
147+
self,
148+
size: int,
149+
offset: int,
150+
budget: list[int],
151+
) -> tuple[Record, int]:
93152
pointer_size = (size >> 3) + 1
94153

95154
buf = self._buffer[offset : offset + pointer_size]
@@ -109,15 +168,26 @@ def _decode_pointer(self, size: int, offset: int) -> tuple[Record, int]:
109168

110169
if self._pointer_test:
111170
return pointer, new_offset
112-
(value, _) = self.decode(pointer)
171+
172+
(value, _) = self._decode(pointer, budget)
113173
return value, new_offset
114174

115-
def _decode_uint(self, size: int, offset: int) -> tuple[int, int]:
175+
def _decode_uint(
176+
self,
177+
size: int,
178+
offset: int,
179+
_budget: list[int],
180+
) -> tuple[int, int]:
116181
new_offset = offset + size
117182
uint_bytes = self._buffer[offset:new_offset]
118183
return int.from_bytes(uint_bytes, "big"), new_offset
119184

120-
def _decode_utf8_string(self, size: int, offset: int) -> tuple[str, int]:
185+
def _decode_utf8_string(
186+
self,
187+
size: int,
188+
offset: int,
189+
_budget: list[int],
190+
) -> tuple[str, int]:
121191
new_offset = offset + size
122192
return self._buffer[offset:new_offset].decode("utf-8"), new_offset
123193

@@ -144,6 +214,19 @@ def decode(self, offset: int) -> tuple[Record, int]:
144214
offset: the location of the data structure to decode
145215
146216
"""
217+
# Bound the work per lookup so a crafted database cannot exhaust CPU or
218+
# memory. ``budget`` is a single-element list so the running count is
219+
# shared across the recursion. It is call-local, which keeps the
220+
# decoder safe for concurrent reads. There is no separate depth limit: a
221+
# pointer cycle or over-deep data exhausts Python's own recursion limit,
222+
# which is converted into an InvalidDatabaseError.
223+
try:
224+
return self._decode(offset, [_MAX_VALUES])
225+
except RecursionError as ex:
226+
msg = "The MaxMind DB file's data section exceeds the maximum depth"
227+
raise InvalidDatabaseError(msg) from ex
228+
229+
def _decode(self, offset: int, budget: list[int]) -> tuple[Record, int]:
147230
new_offset = offset + 1
148231
ctrl_byte = self._buffer[offset]
149232
type_num = ctrl_byte >> 5
@@ -160,7 +243,7 @@ def decode(self, offset: int) -> tuple[Record, int]:
160243
) from ex
161244

162245
(size, new_offset) = self._size_from_ctrl_byte(ctrl_byte, new_offset, type_num)
163-
return decoder(self, size, new_offset)
246+
return decoder(self, size, new_offset, budget)
164247

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

tests/decoder_test.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import TYPE_CHECKING, Any, ClassVar
66

77
from maxminddb.decoder import Decoder
8+
from maxminddb.errors import InvalidDatabaseError
89

910
if TYPE_CHECKING:
1011
from _typeshed import SizedBuffer
@@ -232,3 +233,40 @@ def test_real_pointers(self) -> None:
232233
self.assertEqual(({"long_key2": "long_value2"}, 59), decoder.decode(57))
233234

234235
mm.close()
236+
237+
@staticmethod
238+
def _pointer(target: int) -> bytes:
239+
# One-byte-payload pointer (type 1, pointer_size 1) with base 0.
240+
return bytes([(1 << 5) | ((target >> 8) & 0x7), target & 0xFF])
241+
242+
def test_pointer_fan_out_is_bounded(self) -> None:
243+
# A data section of nested arrays, each holding two pointers to the
244+
# node below, would cost 2**depth decode operations. The decoder bounds
245+
# the number of values it decodes per lookup and rejects the database.
246+
depth = 100
247+
buf = bytearray([0xA0]) # leaf: uint16 with value 0
248+
prev = 0
249+
for _ in range(depth):
250+
offset = len(buf)
251+
buf += bytes([0x02, 0x04]) + self._pointer(prev) + self._pointer(prev)
252+
prev = offset
253+
254+
with self.assertRaises(InvalidDatabaseError):
255+
Decoder(bytes(buf), pointer_base=0).decode(prev)
256+
257+
def test_cyclic_pointer_raises(self) -> None:
258+
# A pointer to itself must raise a catchable InvalidDatabaseError
259+
# rather than recursing until the interpreter's stack limit.
260+
cyclic = bytes([0x20, 0x00]) # pointer (base 0) to offset 0, itself
261+
with self.assertRaises(InvalidDatabaseError):
262+
Decoder(cyclic, pointer_base=0).decode(0)
263+
264+
def test_oversized_map_is_bounded(self) -> None:
265+
# A map entry decodes a key and a value, so a map of N entries costs
266+
# 2N values. A map that declares 32,769 entries reaches 65,538 values,
267+
# just past the 65,536 limit, and is rejected before any entry is read.
268+
# 0xfe: map with size code 30, then the two size bytes for
269+
# 32,769 - 285 = 32,484 (0x7ee4).
270+
oversized_map = bytes([0xFE, 0x7E, 0xE4])
271+
with self.assertRaises(InvalidDatabaseError):
272+
Decoder(oversized_map, pointer_base=0).decode(0)

0 commit comments

Comments
 (0)