Skip to content

Commit 10603fd

Browse files
committed
fixup! Bound decoder work to prevent a pointer fan-out denial of service
1 parent 1eff08a commit 10603fd

2 files changed

Lines changed: 55 additions & 14 deletions

File tree

maxminddb/decoder.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,14 @@
2525
# DB specification. It stops a pointer fan-out, where nested pointers to shared
2626
# targets would otherwise cost 2**depth decode operations. The largest real
2727
# 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``).
28+
# Pointer cycles and over-deep data are caught separately by an explicit,
29+
# call-local depth limit (see ``decode``).
3030
_MAX_VALUES = 1 << 16
31+
_MAX_DEPTH = 512
3132
_TOO_MANY_VALUES = (
3233
"The MaxMind DB file's data section exceeds the maximum number of values"
3334
)
35+
_TOO_DEEP = "The MaxMind DB file's data section exceeds the maximum depth"
3436

3537

3638
class Decoder:
@@ -63,10 +65,14 @@ def _decode_array(
6365
budget[0] -= size
6466
if budget[0] < 0:
6567
raise InvalidDatabaseError(_TOO_MANY_VALUES)
68+
budget[1] += 1
69+
if budget[1] > _MAX_DEPTH:
70+
raise InvalidDatabaseError(_TOO_DEEP)
6671
array = []
6772
for _ in range(size):
6873
(value, offset) = self._decode(offset, budget)
6974
array.append(value)
75+
budget[1] -= 1
7076
return array, offset
7177

7278
def _decode_boolean(
@@ -136,11 +142,15 @@ def _decode_map(
136142
budget[0] -= size * 2
137143
if budget[0] < 0:
138144
raise InvalidDatabaseError(_TOO_MANY_VALUES)
145+
budget[1] += 1
146+
if budget[1] > _MAX_DEPTH:
147+
raise InvalidDatabaseError(_TOO_DEEP)
139148
container: dict[str, Record] = {}
140149
for _ in range(size):
141150
(key, offset) = self._decode(offset, budget)
142151
(value, offset) = self._decode(offset, budget)
143152
container[cast("str", key)] = value
153+
budget[1] -= 1
144154
return container, offset
145155

146156
def _decode_pointer(
@@ -169,7 +179,11 @@ def _decode_pointer(
169179
if self._pointer_test:
170180
return pointer, new_offset
171181

182+
budget[1] += 1
183+
if budget[1] > _MAX_DEPTH:
184+
raise InvalidDatabaseError(_TOO_DEEP)
172185
(value, _) = self._decode(pointer, budget)
186+
budget[1] -= 1
173187
return value, new_offset
174188

175189
def _decode_uint(
@@ -215,16 +229,16 @@ def decode(self, offset: int) -> tuple[Record, int]:
215229
216230
"""
217231
# 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.
232+
# memory. ``budget`` carries the remaining value count and current
233+
# structural depth so both are shared across the recursion. It is
234+
# call-local, which keeps the decoder safe for concurrent reads. The
235+
# explicit depth limit is independent of Python's process-wide recursion
236+
# limit; RecursionError remains a fallback on interpreters whose stack
237+
# limit is reached first.
223238
try:
224-
return self._decode(offset, [_MAX_VALUES])
239+
return self._decode(offset, [_MAX_VALUES, 0])
225240
except RecursionError as ex:
226-
msg = "The MaxMind DB file's data section exceeds the maximum depth"
227-
raise InvalidDatabaseError(msg) from ex
241+
raise InvalidDatabaseError(_TOO_DEEP) from ex
228242

229243
def _decode(self, offset: int, budget: list[int]) -> tuple[Record, int]:
230244
new_offset = offset + 1

tests/decoder_test.py

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

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

@@ -255,11 +256,37 @@ def test_pointer_fan_out_is_bounded(self) -> None:
255256
Decoder(bytes(buf), pointer_base=0).decode(prev)
256257

257258
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.
259+
# A pointer to itself must hit the decoder's own depth limit even when
260+
# Python's process-wide recursion limit is much higher.
260261
cyclic = bytes([0x20, 0x00]) # pointer (base 0) to offset 0, itself
261-
with self.assertRaises(InvalidDatabaseError):
262-
Decoder(cyclic, pointer_base=0).decode(0)
262+
old_recursion_limit = sys.getrecursionlimit()
263+
try:
264+
sys.setrecursionlimit(10_000)
265+
with self.assertRaisesRegex(
266+
InvalidDatabaseError,
267+
"^The MaxMind DB file's data section exceeds the maximum depth$",
268+
):
269+
Decoder(cyclic, pointer_base=0).decode(0)
270+
finally:
271+
sys.setrecursionlimit(old_recursion_limit)
272+
273+
def test_container_depth_is_bounded_independently_of_recursion_limit(self) -> None:
274+
# Each prefix is an array with one element. Raising Python's global
275+
# recursion limit proves that the decoder's call-local limit is what
276+
# accepts 512 containers and rejects the 513th.
277+
at_limit = bytes([0x01, 0x04]) * 512 + bytes([0xA0])
278+
over_limit = bytes([0x01, 0x04]) * 513 + bytes([0xA0])
279+
old_recursion_limit = sys.getrecursionlimit()
280+
try:
281+
sys.setrecursionlimit(10_000)
282+
Decoder(at_limit, pointer_base=0).decode(0)
283+
with self.assertRaisesRegex(
284+
InvalidDatabaseError,
285+
"^The MaxMind DB file's data section exceeds the maximum depth$",
286+
):
287+
Decoder(over_limit, pointer_base=0).decode(0)
288+
finally:
289+
sys.setrecursionlimit(old_recursion_limit)
263290

264291
def test_oversized_map_is_bounded(self) -> None:
265292
# A map entry decodes a key and a value, so a map of N entries costs

0 commit comments

Comments
 (0)