Repository navigation
Reject truncated values and non-string map keys #463
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
base: main
Are you sure you want to change the base?
Changes from all commits
bc24faa
0684158
8288fab
1afe10e
22b26b2
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 |
|---|---|---|
|
|
@@ -12,9 +12,9 @@ | |
| mmap = None # type: ignore[assignment] | ||
|
|
||
| from maxminddb.errors import InvalidDatabaseError | ||
| from maxminddb.file import FileBuffer | ||
|
|
||
| if TYPE_CHECKING: | ||
| from maxminddb.file import FileBuffer | ||
| from maxminddb.types import Record | ||
|
|
||
|
|
||
|
|
@@ -49,6 +49,8 @@ | |
| "The MaxMind DB file's data section contains bad data " | ||
| "(unknown data type or corrupt data)" | ||
| ) | ||
| # The same message as the C extension. | ||
| _NON_STRING_KEY = "Invalid map key: the key is not a string." | ||
|
|
||
|
|
||
| @dataclass | ||
|
|
@@ -68,18 +70,31 @@ def __init__( | |
| database_buffer: FileBuffer | mmap.mmap | bytes | bytearray, | ||
| pointer_base: int = 0, | ||
| pointer_test: bool = False, # noqa: FBT001, FBT002 | ||
| data_end: int | None = None, | ||
| ) -> None: | ||
| """Create a Decoder for a MaxMind DB. | ||
| Arguments: | ||
| database_buffer: an mmap'd MaxMind DB file. | ||
| pointer_base: the base number to use when decoding a pointer | ||
| pointer_test: used for internal unit testing of pointer code | ||
| data_end: the offset where the decoded section ends. No payload | ||
| may extend past it, and no pointer may target it or a later | ||
| offset. Control and size bytes are not checked. The default | ||
|
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.
Example: The Reader is safe only because the first byte of the metadata marker ( 🤖 Comment by Claude Opus 5.5. |
||
| is the end of the buffer. | ||
| """ | ||
| self._pointer_test = pointer_test | ||
| self._buffer = database_buffer | ||
| self._pointer_base = pointer_base | ||
| if data_end is None: | ||
| # FileBuffer has no __len__, because len() fails for a size past | ||
| # sys.maxsize, as on 32-bit Python. | ||
| if isinstance(database_buffer, FileBuffer): | ||
|
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 🤖 Comment by Claude Opus 5.5. |
||
| data_end = database_buffer.size() | ||
| else: | ||
| data_end = len(database_buffer) | ||
| self._data_end = data_end | ||
|
|
||
| def _decode_array( | ||
| self, | ||
|
|
@@ -124,6 +139,8 @@ def _decode_bytes( | |
| raise InvalidDatabaseError(_TOO_LARGE) | ||
| budget.payload_left = remaining | ||
| new_offset = offset + size | ||
| if new_offset > self._data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| return self._buffer[offset:new_offset], new_offset | ||
|
|
||
| def _decode_double( | ||
|
|
@@ -134,6 +151,8 @@ def _decode_double( | |
| ) -> tuple[float, int]: | ||
| self._verify_size(size, 8) | ||
| new_offset = offset + size | ||
| if new_offset > self._data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| packed_bytes = self._buffer[offset:new_offset] | ||
| (value,) = struct.unpack(b"!d", packed_bytes) | ||
| return value, new_offset | ||
|
|
@@ -146,6 +165,8 @@ def _decode_float( | |
| ) -> tuple[float, int]: | ||
| self._verify_size(size, 4) | ||
| new_offset = offset + size | ||
| if new_offset > self._data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| packed_bytes = self._buffer[offset:new_offset] | ||
| (value,) = struct.unpack(b"!f", packed_bytes) | ||
| return value, new_offset | ||
|
|
@@ -161,6 +182,8 @@ def _decode_int32( | |
| if size == 0: | ||
| return 0, offset | ||
| new_offset = offset + size | ||
| if new_offset > self._data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| packed_bytes = self._buffer[offset:new_offset] | ||
|
|
||
| if size != 4: | ||
|
|
@@ -185,17 +208,60 @@ def _decode_map( | |
| budget.depth = depth | ||
| container: dict[str, Record] = {} | ||
| decode = self._decode | ||
| decode_key = self._decode_key | ||
| for _ in range(size): | ||
| (key, offset) = decode(offset, budget, False) # noqa: FBT003 | ||
| (key, offset) = decode_key(offset, budget) | ||
| (value, offset) = decode(offset, budget, False) # noqa: FBT003 | ||
| try: | ||
| container[key] = value # type: ignore[index] | ||
| except TypeError as ex: | ||
| # The key cannot be hashed, such as a list. | ||
| raise InvalidDatabaseError(_BAD_DATA) from ex | ||
| container[key] = value | ||
| budget.depth -= 1 | ||
| return container, offset | ||
|
|
||
| def _decode_key(self, offset: int, budget: _DecodeBudget) -> tuple[str, int]: # noqa: C901 | ||
|
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.
A small shared helper that returns 🤖 Comment by Claude Opus 5.5.
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. It looks like this was intentional. |
||
| # A key is a string or a pointer to one. Real databases share most keys | ||
| # through pointers, so decode both here without the generic dispatch. | ||
| # A string cannot nest, so a pointer key does not count toward the | ||
| # depth. This repeats the pointer arithmetic of _decode_pointer to save | ||
| # a call per key. pointer_test applies to pointer values only. | ||
| buffer = self._buffer | ||
| data_end = self._data_end | ||
| ctrl_byte = buffer[offset] | ||
| new_offset = offset + 1 | ||
| is_pointer = ctrl_byte >> 5 == 1 | ||
| if is_pointer: | ||
| size = ctrl_byte & 0x1F | ||
| pointer_size = (size >> 3) + 1 | ||
| pointer_end = new_offset + pointer_size | ||
| if pointer_end > data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| pointer = int.from_bytes(buffer[new_offset:pointer_end], "big") | ||
| if pointer_size < 4: | ||
| pointer |= (size & 0x7) << (pointer_size << 3) | ||
| pointer += _POINTER_VALUE_OFFSETS[pointer_size] | ||
| pointer += self._pointer_base | ||
| if pointer >= data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| ctrl_byte = buffer[pointer] | ||
| new_offset = pointer + 1 | ||
| elif offset >= data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| if ctrl_byte >> 5 != 2: | ||
| raise InvalidDatabaseError(_NON_STRING_KEY) | ||
|
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. For a map key that is a pointer to another pointer, this raises Example data section: The decoder test case 🤖 Comment by Claude Opus 5.5.
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 doesn't seem important. |
||
| size = ctrl_byte & 0x1F | ||
| if size >= 29: | ||
| (size, new_offset) = self._size_from_ctrl_byte(size, new_offset) | ||
| remaining = budget.payload_left - size | ||
| if remaining < 0: | ||
| raise InvalidDatabaseError(_TOO_LARGE) | ||
| budget.payload_left = remaining | ||
| end = new_offset + size | ||
| if end > data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| key = buffer[new_offset:end].decode("utf-8") | ||
| if is_pointer: | ||
| # The map continues after the pointer, not after its target. | ||
| return key, pointer_end | ||
| return key, end | ||
|
|
||
| def _decode_pointer( | ||
| self, | ||
| size: int, | ||
|
|
@@ -204,10 +270,9 @@ def _decode_pointer( | |
| ) -> tuple[Record, int]: | ||
| pointer_size = (size >> 3) + 1 | ||
| new_offset = offset + pointer_size | ||
| pointer_bytes = self._buffer[offset:new_offset] | ||
| if len(pointer_bytes) != pointer_size: | ||
| if new_offset > self._data_end: | ||
|
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 PR removes the Example:
🤖 Comment by Claude Opus 5.5.
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. Also not sure. |
||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| pointer = int.from_bytes(pointer_bytes, "big") | ||
| pointer = int.from_bytes(self._buffer[offset:new_offset], "big") | ||
| if pointer_size < 4: | ||
| # The low three bits of the ctrl byte are the high bits of the | ||
| # pointer, and sizes 2 and 3 add a fixed offset. | ||
|
|
@@ -218,6 +283,11 @@ def _decode_pointer( | |
| if self._pointer_test: | ||
| return pointer, new_offset | ||
|
|
||
| # The payload checks do not cover a target with no payload, such as | ||
| # an empty map. | ||
| if pointer >= self._data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
|
|
||
| # The value at the pointer's position was charged by its containing | ||
| # array or map, so the target costs nothing more. Only the depth changes. | ||
| depth = budget.depth + 1 | ||
|
|
@@ -239,6 +309,8 @@ def _decode_uint( | |
| if size > _MAX_UINT_BYTES: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| new_offset = offset + size | ||
| if new_offset > self._data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| uint_bytes = self._buffer[offset:new_offset] | ||
| return int.from_bytes(uint_bytes, "big"), new_offset | ||
|
|
||
|
|
@@ -297,6 +369,8 @@ def _decode( # noqa: C901, PLR0911, PLR0912 | |
| raise InvalidDatabaseError(_TOO_LARGE) | ||
| budget.payload_left = remaining | ||
| end = new_offset + size | ||
| if end > self._data_end: | ||
| raise InvalidDatabaseError(_BAD_DATA) | ||
| return self._buffer[new_offset:end].decode("utf-8"), end | ||
| case 1: | ||
| if pointer_target: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,8 @@ | |
| import os | ||
| from typing import TYPE_CHECKING, overload | ||
|
|
||
| from maxminddb.errors import InvalidDatabaseError | ||
|
|
||
| try: | ||
| from multiprocessing import Lock | ||
| except ImportError: | ||
|
|
@@ -13,6 +15,9 @@ | |
| if TYPE_CHECKING: | ||
| from maxminddb.types import StrOrBytesPath | ||
|
|
||
| # A read inside the size at open came back short, so the file shrank. | ||
| _SHORT_READ = "The database file changed after the reader opened it." | ||
|
|
||
|
|
||
| class FileBuffer: | ||
| """A slice-able file reader.""" | ||
|
|
@@ -32,16 +37,28 @@ def __getitem__(self, index: slice) -> bytes: ... | |
|
|
||
| def __getitem__(self, index: slice | int) -> bytes | int: | ||
| """Get item by index.""" | ||
| # Past the size at open, a read acts as on bytes: a slice returns | ||
| # fewer bytes, and an index raises IndexError. | ||
| if isinstance(index, slice): | ||
| return self._read(index.stop - index.start, index.start) | ||
| size = index.stop - index.start | ||
| data = self._read(size, index.start) | ||
| if len(data) != size and index.stop <= self._size: | ||
| raise InvalidDatabaseError(_SHORT_READ) | ||
|
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 short-read check does not work on platforms without Example: read HISTORY.rst says that a file that shrinks after open now raises 🤖 Comment by Claude Opus 5.5.
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. Hmm, not sure about this one. |
||
| return data | ||
| if isinstance(index, int): | ||
| return self._read(1, index)[0] | ||
| data = self._read(1, index) | ||
| if data: | ||
| return data[0] | ||
| if index < self._size: | ||
| raise InvalidDatabaseError(_SHORT_READ) | ||
| msg = "FileBuffer index out of range" | ||
| raise IndexError(msg) | ||
| msg = "Invalid argument type." | ||
| raise TypeError(msg) | ||
|
|
||
| def rfind(self, needle: bytes, start: int) -> int: | ||
| """Reverse find needle from start.""" | ||
| pos = self._read(self._size - start - 1, start).rfind(needle) | ||
| pos = self._read(self._size - start, start).rfind(needle) | ||
| if pos == -1: | ||
| return pos | ||
| return start + pos | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -52,6 +52,8 @@ class Reader: | |
|
|
||
| _buffer: bytes | bytearray | FileBuffer | "mmap.mmap" # noqa: UP037 | ||
| _buffer_size: int | ||
| # The offset of the metadata marker, where the data section ends. | ||
| _data_end: int | ||
| # No database is open until __init__ succeeds. | ||
| closed: bool = True | ||
| _decoder: Decoder | ||
|
|
@@ -137,6 +139,7 @@ def _load( | |
| msg, | ||
| ) | ||
|
|
||
| self._data_end = metadata_start | ||
| metadata_start += len(self._METADATA_START_MARKER) | ||
| metadata_decoder = Decoder(self._buffer, metadata_start) | ||
| # For a repeated key, the decoder keeps the last value, but | ||
|
|
@@ -167,14 +170,13 @@ def _load( | |
|
|
||
| # Traversal reads nodes below node_count. Once the tree fits, those | ||
| # reads need no length checks of their own. | ||
| 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 | ||
| _check_search_tree_fits( | ||
| self._data_start, self._data_end, self._buffer_size, filename | ||
| ) | ||
|
|
||
| self._decoder = Decoder(self._buffer, self._data_start) | ||
| self._decoder = Decoder( | ||
| self._buffer, self._data_start, data_end=self._data_end | ||
| ) | ||
| self.closed = False | ||
|
|
||
| ipv4_start = 0 | ||
|
|
@@ -362,9 +364,9 @@ def _read_node(self, node_number: int, index: int) -> int: | |
| def _resolve_data_pointer(self, pointer: int) -> Record: | ||
| resolved = pointer + self._pointer_base | ||
|
|
||
| # 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: | ||
| # A record must point into the data section. libmaxminddb also rejects | ||
| # a pointer into the separator between the tree and the data section. | ||
| if resolved < self._data_start or resolved >= self._data_end: | ||
| raise InvalidDatabaseError(_CORRUPT_TREE) | ||
|
|
||
| (data, _) = self._decoder.decode(resolved) | ||
|
|
@@ -501,10 +503,6 @@ def _metadata_fields(metadata: RecordDict, filename: object) -> dict[str, Any]: | |
| A new minor version of the format can add keys. This ignores them. | ||
| """ | ||
| prefix = f"Error reading metadata in database file ({filename})." | ||
| # The C extension also rejects a key that is not a string. | ||
| if not all(type(k) is str for k in metadata): | ||
| msg = f"{prefix} A metadata key is not a string." | ||
| raise InvalidDatabaseError(msg) | ||
| fields: dict[str, Any] = {} | ||
| for key, value_type in _METADATA_TYPES.items(): | ||
| value = metadata.get(key) | ||
|
|
@@ -513,7 +511,7 @@ def _metadata_fields(metadata: RecordDict, filename: object) -> dict[str, Any]: | |
| if valid and isinstance(value, list): | ||
| valid = all(type(v) is str for v in value) | ||
| elif valid and isinstance(value, dict): | ||
| valid = all(type(k) is str and type(v) is str for k, v in value.items()) | ||
| valid = all(type(v) is str for v in value.values()) | ||
| if not valid: | ||
| msg = f"{prefix} The {key} value is missing or has the wrong type." | ||
| raise InvalidDatabaseError(msg) | ||
|
|
@@ -549,6 +547,23 @@ def _check_metadata_ranges(fields: dict[str, Any], prefix: str) -> None: | |
| raise InvalidDatabaseError(msg) | ||
|
|
||
|
|
||
| def _check_search_tree_fits( | ||
| data_start: int, | ||
| data_end: int, | ||
| file_size: int, | ||
| filename: object, | ||
| ) -> None: | ||
| """Raise InvalidDatabaseError unless the search tree ends before the metadata.""" | ||
| if data_start <= data_end: | ||
|
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 now rejects an empty search tree ( The message is misleading for this case. The tree is empty, and the separator is the part that overlaps. The docstring does not mention the separator, and HISTORY.rst does not mention this change. 🤖 Comment by Claude Opus 5.5.
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. Seems not important |
||
| return | ||
| if data_start > file_size: | ||
| problem = "extends past the end of the file" | ||
| else: | ||
| problem = "extends into the metadata section" | ||
| msg = f"Error opening database file ({filename}). The search tree {problem}." | ||
| raise InvalidDatabaseError(msg) | ||
|
|
||
|
|
||
| @dataclass(kw_only=True, frozen=True) | ||
| class Metadata: | ||
| """Metadata for the MaxMind DB reader.""" | ||
|
|
||
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.
Previously, it raised OSErroris true only whereos.preadexists. On Windows, the old_read(-1, 0)calledhandle.read(-1), which returnedb''.rfindthen returned -1, soMODE_FILEalready raisedInvalidDatabaseErrorthere.🤖 Comment by Claude Opus 5.5.