Repository navigation
Fix iteration over corrupt and unusual search trees #469
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b29f400
556568d
61b1f97
bf06c09
2989384
e63c8a8
4229d09
ec347d9
c81339a
ba551ef
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -88,6 +88,8 @@ typedef struct { | |
| Reader_obj *reader; | ||
| struct record *next; | ||
| uint64_t generation; | ||
| // Set once next() has raised StopIteration or an error. | ||
| bool done; | ||
| } ReaderIter_obj; | ||
|
|
||
| typedef struct { | ||
|
|
@@ -139,6 +141,10 @@ static PyObject *metadata_value(PyObject *map, const char *key); | |
| static void set_error_from_cause(PyObject *type, const char *message); | ||
| static PyObject *Metadata_node_byte_size(PyObject *self, void *closure); | ||
| static PyObject *reader_iter_next(PyObject *self); | ||
| static PyObject *corrupt_tree_error(ReaderIter_obj *ri, | ||
| maxminddb_state *state, | ||
| struct record *cur); | ||
| static void free_records(struct record *next); | ||
| static bool format_sockaddr(struct sockaddr *addr, char *dst); | ||
| static PyObject *from_entry_data_list(maxminddb_state *state, | ||
| MMDB_entry_data_list_s **entry_data_list); | ||
|
|
@@ -907,29 +913,42 @@ static bool is_ipv6(char ip[16]) { | |
| } | ||
|
|
||
| static PyObject *ReaderIter_next(PyObject *self) { | ||
| PyObject *result; | ||
| ReaderIter_obj *ri = (ReaderIter_obj *)self; | ||
| PyObject *result = NULL; | ||
| #ifdef Py_GIL_DISABLED | ||
| // The iterator's list of pending records is not thread-safe, so let only | ||
| // one thread at a time advance an iterator. The read lock is shared, so | ||
| // it does not do this. | ||
| Py_BEGIN_CRITICAL_SECTION(self); | ||
| #endif | ||
| result = reader_iter_next(self); | ||
| // An iterator that is exhausted or that raised an error stays done, even | ||
| // after the reader closes. The list of pending records cannot show this: | ||
| // it is already empty when the last record is returned, and the next call | ||
| // must still report a closed or reopened reader, as the pure Python | ||
| // iterator does. A failure to get the read lock is temporary and removes | ||
| // no record, so the iterator can continue after it. | ||
| if (!ri->done && reader_acquire_read_lock(ri->reader) == 0) { | ||
| result = reader_iter_next(self); | ||
| // Stop after StopIteration or another error, as a generator does. | ||
| if (result == NULL) { | ||
| free_records(ri->next); | ||
| ri->next = NULL; | ||
| ri->done = true; | ||
| } | ||
| } | ||
| #ifdef Py_GIL_DISABLED | ||
| Py_END_CRITICAL_SECTION(); | ||
| #endif | ||
| return result; | ||
| } | ||
|
|
||
| // The caller holds the read lock. This function releases it on every path. | ||
| static PyObject *reader_iter_next(PyObject *self) { | ||
| maxminddb_state *state = get_maxminddb_state_from_self((PyObject *)self); | ||
| if (state == NULL) { | ||
| return NULL; | ||
| } | ||
|
|
||
| ReaderIter_obj *ri = (ReaderIter_obj *)self; | ||
|
|
||
| if (reader_acquire_read_lock(ri->reader) != 0) { | ||
| maxminddb_state *state = get_maxminddb_state_from_self((PyObject *)self); | ||
| if (state == NULL) { | ||
| reader_release_read_lock(ri->reader); | ||
| return NULL; | ||
| } | ||
|
|
||
|
|
@@ -954,18 +973,24 @@ static PyObject *reader_iter_next(PyObject *self) { | |
|
|
||
| switch (cur->type) { | ||
| case MMDB_RECORD_TYPE_INVALID: | ||
| reader_release_read_lock(ri->reader); | ||
| PyErr_SetString(state->MaxMindDB_error, | ||
| "Invalid record when reading node"); | ||
| free(cur); | ||
| return NULL; | ||
| // libmaxminddb before 1.14 returns this type for a record | ||
| // that points to the root or past the data section. Later | ||
| // versions fail in MMDB_read_node instead. | ||
| return corrupt_tree_error(ri, state, cur); | ||
| case MMDB_RECORD_TYPE_SEARCH_NODE: { | ||
| if (cur->record == | ||
| ri->reader->mmdb->ipv4_start_node.node_value && | ||
| is_ipv6(cur->ip_packed)) { | ||
| // These are aliased networks. Skip them. | ||
| break; | ||
| } | ||
| // Only a corrupt tree, such as one with a cycle, has a node at | ||
| // the full address depth. Without this check, an IPv4 tree | ||
| // gives a network longer than /32, and a cycle in either tree | ||
| // writes past the end of ip_packed at depth 128. | ||
| if (cur->depth >= ri->reader->mmdb->depth) { | ||
| return corrupt_tree_error(ri, state, cur); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reject full-depth nodes before skipping IPv4 aliases. If a malformed IPv6 path reaches 🤖 Prompt for AI Agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agent reply on behalf of @oschwald. Not fixed, by decision. We keep the code simple and don't try to detect every corrupt tree. Both readers skip any record that points to the IPv4 start node under a non-IPv4 prefix, at any depth. Moving the depth check first would catch only depth 128. An alias at depths 97 to 127 is just as invalid and would still be skipped. The skip yields no network that is not in the tree, and it writes nothing to |
||
| } | ||
| MMDB_search_node_s node; | ||
| int status = MMDB_read_node( | ||
| ri->reader->mmdb, (uint32_t)cur->record, &node); | ||
|
|
@@ -1050,7 +1075,9 @@ static PyObject *reader_iter_next(PyObject *self) { | |
| int ip_start = 0; | ||
| Py_ssize_t ip_length = 4; | ||
| if (depth == 128) { | ||
| if (is_ipv6(cur->ip_packed)) { | ||
| // A network shorter than /96 is IPv6, even if its first | ||
| // 96 bits are zero. | ||
| if (is_ipv6(cur->ip_packed) || cur->depth < 96) { | ||
| // IPv6 address | ||
| ip_length = 16; | ||
| } else { | ||
|
|
@@ -1101,20 +1128,34 @@ static PyObject *reader_iter_next(PyObject *self) { | |
| return NULL; | ||
| } | ||
|
|
||
| // Sets the corrupt-tree error, releases the read lock and frees cur. | ||
| static PyObject *corrupt_tree_error(ReaderIter_obj *ri, | ||
| maxminddb_state *state, | ||
| struct record *cur) { | ||
| reader_release_read_lock(ri->reader); | ||
| PyErr_SetString(state->MaxMindDB_error, | ||
| MMDB_strerror(MMDB_CORRUPT_SEARCH_TREE_ERROR)); | ||
| free(cur); | ||
| return NULL; | ||
| } | ||
|
|
||
| static void ReaderIter_dealloc(PyObject *self) { | ||
| ReaderIter_obj *ri = (ReaderIter_obj *)self; | ||
|
|
||
| Py_DECREF(ri->reader); | ||
|
|
||
| struct record *next = ri->next; | ||
| free_records(ri->next); | ||
| PyTypeObject *type = Py_TYPE(self); | ||
| PyObject_Del(self); | ||
| Py_DECREF(type); | ||
| } | ||
|
|
||
| static void free_records(struct record *next) { | ||
| while (next != NULL) { | ||
| struct record *cur = next; | ||
| next = cur->next; | ||
| free(cur); | ||
| } | ||
| PyTypeObject *type = Py_TYPE(self); | ||
| PyObject_Del(self); | ||
| Py_DECREF(type); | ||
| } | ||
|
|
||
| // Metadata is immutable, so tp_new sets every field and there is no tp_init. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,7 @@ | |
| import contextlib | ||
| import ipaddress | ||
| from dataclasses import dataclass | ||
| from ipaddress import IPv4Address, IPv6Address | ||
| from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network | ||
| from typing import IO, TYPE_CHECKING, Any | ||
|
|
||
| from maxminddb.const import MODE_AUTO, MODE_FD, MODE_FILE, MODE_MEMORY, MODE_MMAP | ||
|
|
@@ -29,6 +29,7 @@ | |
| _IPV4_MAX_NUM = 2**32 | ||
| _REOPENED = "Attempt to iterate over a reopened MaxMind DB. Create a new iterator." | ||
| _CLOSED = "Attempt to iterate over a closed MaxMind DB." | ||
| _CORRUPT_TREE = "The MaxMind DB file's search tree is corrupt" | ||
|
|
||
|
|
||
| class Reader: | ||
|
|
@@ -48,6 +49,10 @@ class Reader: | |
| _metadata: Metadata | ||
| _record_size: int | ||
| _ipv4_start: int | ||
| _node_count: int | ||
| _address_bits: int | ||
| _pointer_base: int | ||
| _data_start: int | ||
| # Incremented on each open, so an iterator can detect a reopen. | ||
| _generation: int = 0 | ||
|
|
||
|
|
@@ -133,22 +138,22 @@ def _load( | |
| self._metadata = Metadata(**_metadata_fields(metadata, filename)) | ||
| self._record_size = self._metadata.record_size | ||
|
|
||
| # _resolve_data_pointer uses these on every lookup. A data record | ||
| # minus node_count is an offset from the end of the search tree. | ||
| search_tree_size = self._metadata.search_tree_size | ||
| self._pointer_base = search_tree_size - self._metadata.node_count | ||
| self._data_start = search_tree_size + self._DATA_SECTION_SEPARATOR_SIZE | ||
|
|
||
| # Traversal reads nodes below node_count. Once the tree fits, those | ||
| # reads need no length checks of their own. | ||
| tree_end = ( | ||
| self._metadata.search_tree_size + self._DATA_SECTION_SEPARATOR_SIZE | ||
| ) | ||
| if tree_end > self._buffer_size: | ||
| if self._data_start > self._buffer_size: | ||
| msg = ( | ||
| f"Error opening database file ({filename}). The search tree " | ||
| "extends past the end of the file." | ||
| ) | ||
| raise InvalidDatabaseError(msg) # noqa: TRY301 | ||
|
|
||
| self._decoder = Decoder( | ||
| self._buffer, | ||
| self._metadata.search_tree_size + self._DATA_SECTION_SEPARATOR_SIZE, | ||
| ) | ||
| self._decoder = Decoder(self._buffer, self._data_start) | ||
| self.closed = False | ||
|
|
||
| ipv4_start = 0 | ||
|
|
@@ -163,6 +168,13 @@ def _load( | |
| node = self._read_node(node, 0) | ||
| ipv4_start = node | ||
| self._ipv4_start = ipv4_start | ||
|
|
||
| # _generate_children uses these on every node. | ||
| self._node_count = self._metadata.node_count | ||
| if self._metadata.ip_version == 6: | ||
| self._address_bits = 128 | ||
| else: | ||
| self._address_bits = 32 | ||
| except BaseException: | ||
| _close_buffer(self.__dict__.get("_buffer")) | ||
| raise | ||
|
|
@@ -236,23 +248,36 @@ def _iterate(self, generation: int) -> Iterator: | |
| yield record | ||
|
|
||
| def _generate_children(self, node: int, depth: int, ip_acc: int) -> Iterator: | ||
| if ip_acc != 0 and node == self._ipv4_start: | ||
| # Skip nodes aliased to IPv4 | ||
| return | ||
|
|
||
| node_count = self._metadata.node_count | ||
| node_count = self._node_count | ||
| bits = self._address_bits | ||
| if node > node_count: | ||
| bits = 128 if self._metadata.ip_version == 6 else 32 | ||
| ip_acc <<= bits - depth | ||
| if ip_acc <= _IPV4_MAX_NUM and bits == 128: | ||
| depth -= 96 | ||
| yield ( | ||
| ipaddress.ip_network((ip_acc, depth)), | ||
| self._resolve_data_pointer( | ||
| node, | ||
| ), | ||
| ) | ||
| network: IPv4Network | IPv6Network | ||
| if bits == 32: | ||
| network = IPv4Network((ip_acc, depth)) | ||
| elif depth >= 96 and ip_acc < _IPV4_MAX_NUM: | ||
| # An IPv4 network in an IPv6 tree is at least /96, and its | ||
| # first 96 bits are zero. | ||
| network = IPv4Network((ip_acc, depth - 96)) | ||
| else: | ||
| network = IPv6Network((ip_acc, depth)) | ||
| yield (network, self._resolve_data_pointer(node)) | ||
| elif node < node_count: | ||
| # Skip the IPv4 subtree when an address with a set bit in its first | ||
| # 96 bits leads to it, as the C extension does. Inside the IPv4 | ||
| # subtree, or in an IPv4 tree, a record that points back to it is a | ||
| # cycle. | ||
| if ( | ||
| node == self._ipv4_start | ||
| and bits == 128 | ||
| and ip_acc >> max(depth - 96, 0) != 0 | ||
| ): | ||
| return | ||
| # A node at the full address depth has no valid children, and no | ||
| # record can point to the root. Only a corrupt tree, such as one | ||
| # with a cycle, has either. | ||
| if depth >= bits or (node == 0 and depth > 0): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This follows up the cycle comment on #466 (#466 (comment)). The depth limit that the comment suggested stops the heap corruption. But it catches a cycle early only if the cycle goes through the root. For other cycles, both readers yield networks that are not in the tree before they raise Example: the IPv4 tree The root test asserts that the reader yields no more networks than the tree has, but this is not true for other cycles. Possible fix: keep the node numbers on the current DFS path, and raise when a record points to one of them. 🤖 Comment by Claude Opus 5.5.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agent reply on behalf of @oschwald. Not fixed, by decision. We keep the code simple and don't try to detect a corrupt tree early. The depth limit stops the memory corruption, and both readers raise |
||
| raise InvalidDatabaseError(_CORRUPT_TREE) | ||
| left = self._read_node(node, 0) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The two readers give different networks for the same corrupt file. The pure Python walk checks a record only when it visits that record. The C extension, with the bundled libmaxminddb 1.14, fails in Example: Possible fix: read and check both records of a node before going down into it, as 🤖 Comment by Claude Opus 5.5.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agent reply on behalf of @oschwald. Not fixed, by decision, for the same reason: we don't try to detect a corrupt tree early. Both readers raise on this file. Only the networks before the error differ. The C result already depends on the libmaxminddb version: a system libmaxminddb before 1.14 checks each record when the iterator visits it, as pure Python does. |
||
| ip_acc <<= 1 | ||
| depth += 1 | ||
|
|
@@ -307,11 +332,12 @@ def _read_node(self, node_number: int, index: int) -> int: | |
| raise InvalidDatabaseError(msg) | ||
|
|
||
| def _resolve_data_pointer(self, pointer: int) -> Record: | ||
| resolved = pointer - self._metadata.node_count + self._metadata.search_tree_size | ||
| resolved = pointer + self._pointer_base | ||
|
|
||
| if resolved >= self._buffer_size: | ||
| msg = "The MaxMind DB file's search tree is corrupt" | ||
| raise InvalidDatabaseError(msg) | ||
| # A pointer into the separator between the tree and the data section | ||
| # is as corrupt as one past the end, as libmaxminddb checks. | ||
| if resolved < self._data_start or resolved >= self._buffer_size: | ||
| raise InvalidDatabaseError(_CORRUPT_TREE) | ||
|
|
||
| (data, _) = self._decoder.decode(resolved) | ||
| return data | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: this block repeats the
MMDB_RECORD_TYPE_INVALIDerror block at line 978 line for line (release the lock, set the corrupt-tree error, freecur, returnNULL). A shared exit path, such as agotolabel or a small helper, would keep the two in sync if the message changes.🤖 Comment by Claude Opus 5.5.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agent reply on behalf of @oschwald.
Fixed in 4229d09.
corrupt_tree_errorreleases the lock, sets the error and freescur, and both blocks call it.