From a3c11651a6a9a2a201d542bace3af7066ea19481 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 26 Aug 2026 19:43:58 +0000 Subject: [PATCH 1/8] Bound decoded values to prevent a pointer fan-out denial of service A crafted data section could nest pointers to shared targets so that MMDB_get_entry_data_list decoded one entry with exponential time and memory from a small file (GHSA-hj94-g986-h9r7). The existing depth limit did not stop this: the blow-up comes from width (a shared graph re-walked), not from a single deep path. The decoder now counts the values it decodes for one entry and returns MMDB_INVALID_DATA_ERROR once the count exceeds 65,536, far above the few hundred values the largest records MaxMind produces decode. This matches the reader resource limits recommended by a proposed update to the MaxMind DB specification. Co-Authored-By: Claude Opus 4.8 --- Changes.md | 8 ++++++++ src/data-pool.h | 8 ++++++++ src/maxminddb.c | 10 ++++++++++ 3 files changed, 26 insertions(+) diff --git a/Changes.md b/Changes.md index c4b2af66..d7a39ffa 100644 --- a/Changes.md +++ b/Changes.md @@ -1,5 +1,13 @@ ## next release +- Fixed a denial-of-service issue in `MMDB_get_entry_data_list()`. A crafted + database could nest data-section pointers to shared targets so that decoding + one entry cost exponential time and memory from a small file. The decoder now + limits the number of values it decodes for a single entry to 65,536 and + returns `MMDB_INVALID_DATA_ERROR` when an entry exceeds it. The largest real + records MaxMind produces decode a few hundred values. This matches the reader + resource limits recommended by a proposed update to the MaxMind DB + specification. See GHSA-hj94-g986-h9r7. - Fixed an out-of-bounds read in `MMDB_lookup_sockaddr()` when callers passed a `sockaddr` with an unsupported address family. The function now rejects any family other than `AF_INET` and `AF_INET6` with diff --git a/src/data-pool.h b/src/data-pool.h index 9e61b768..ef3bfd5f 100644 --- a/src/data-pool.h +++ b/src/data-pool.h @@ -42,6 +42,14 @@ typedef struct MMDB_data_pool_s { // An array of pointers to blocks of memory holding space for list // elements. MMDB_entry_data_list_s *blocks[DATA_POOL_NUM_BLOCKS]; + + // Number of decode visits charged for a single entry, across all blocks. + // get_entry_data_list charges one per call, so following a pointer into a + // container charges every level it expands even where those visits reuse + // one output list node. This bounds the fan-out work an attacker inflates, + // which is not the same as the count of output elements (see + // MAXIMUM_DATA_STRUCTURE_VALUES). + size_t length; } MMDB_data_pool_s; bool can_multiply(size_t const, size_t const, size_t const); diff --git a/src/maxminddb.c b/src/maxminddb.c index 8d82a9e7..832100b8 100644 --- a/src/maxminddb.c +++ b/src/maxminddb.c @@ -35,6 +35,12 @@ typedef ADDRESS_FAMILY sa_family_t; #define MMDB_DATA_SECTION_SEPARATOR (16) #define MAXIMUM_DATA_STRUCTURE_DEPTH (512) +// The maximum number of data-section values decoded for a single entry. This +// bounds a pointer fan-out, where nested pointers to shared targets would +// otherwise cost 2**depth decode operations. The largest real records decode a +// few hundred values, so this leaves a wide margin. See the MaxMind DB +// specification's "Reader Resource Limits". +#define MAXIMUM_DATA_STRUCTURE_VALUES ((size_t)1 << 16) #ifdef MMDB_DEBUG #define DEBUG_MSG(msg) fprintf(stderr, msg "\n") @@ -1725,6 +1731,10 @@ static int get_entry_data_list(const MMDB_s *const mmdb, return MMDB_INVALID_DATA_ERROR; } depth++; + if (++pool->length > MAXIMUM_DATA_STRUCTURE_VALUES) { + DEBUG_MSG("reached the maximum number of data structure values"); + return MMDB_INVALID_DATA_ERROR; + } CHECKED_DECODE_ONE(mmdb, offset, &entry_data_list->entry_data); switch (entry_data_list->entry_data.type) { From ec37a557f6a76482f888bc8ef3c3326109b7a4ef Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 26 Aug 2026 19:43:58 +0000 Subject: [PATCH 2/8] Bound the total payload decoded for a single entry The value-count limit bounds how many nodes MMDB_get_entry_data_list produces, but not how many bytes they reference. libmaxminddb borrows payload bytes rather than copying them, so the node count alone bounds the library's own memory. A crafted database can still point many times at one large value, producing a bounded node list that together references far more bytes than the file holds. A caller that copies each node into a string then materializes that amplified total, for example about 512 MiB from an 82 KiB file. Charge the total string and bytes payload decoded for a single entry against a per-entry byte budget and return MMDB_INVALID_DATA_ERROR when it exceeds MAXIMUM_DATA_STRUCTURE_BYTES (2 MiB, overridable at build time with -DMAXIMUM_DATA_STRUCTURE_BYTES=). The budget is a uint64 and the value-count limit caps how many payloads are charged, so the running total cannot overflow. Integers are size-validated and tiny, floats are fixed width, and container sizes are element counts, so only string and bytes payloads are charged. This also rejects a rare format-valid record whose own string and bytes fields exceed the limit. The largest records MaxMind produces hold about a kilobyte of payload, so the limit leaves a wide margin while stopping the amplification for every caller of the API. See GHSA-hj94-g986-h9r7. Co-Authored-By: Claude Opus 4.8 --- Changes.md | 12 ++++++++++++ src/data-pool.h | 7 +++++++ src/maxminddb.c | 30 ++++++++++++++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/Changes.md b/Changes.md index d7a39ffa..66607287 100644 --- a/Changes.md +++ b/Changes.md @@ -8,6 +8,18 @@ records MaxMind produces decode a few hundred values. This matches the reader resource limits recommended by a proposed update to the MaxMind DB specification. See GHSA-hj94-g986-h9r7. +- Fixed a related payload-amplification denial of service. A crafted database + can point many times at one large value, so `MMDB_get_entry_data_list()` + returns a bounded number of nodes that together reference far more bytes than + the file holds. A caller that copies each node into a string then materializes + that amplified total. The decoder now also limits the total string and bytes + payload it decodes for a single entry to 2 MiB and returns + `MMDB_INVALID_DATA_ERROR` when an entry exceeds it. This also rejects a rare + format-valid record whose own string and bytes fields total more than the + limit, since a single field can be up to 16,843,036 bytes. For such a + database, raise the limit at build time with + `-DMAXIMUM_DATA_STRUCTURE_BYTES=`. The largest records MaxMind produces + hold about a kilobyte of payload. See GHSA-hj94-g986-h9r7. - Fixed an out-of-bounds read in `MMDB_lookup_sockaddr()` when callers passed a `sockaddr` with an unsupported address family. The function now rejects any family other than `AF_INET` and `AF_INET6` with diff --git a/src/data-pool.h b/src/data-pool.h index ef3bfd5f..8d8457eb 100644 --- a/src/data-pool.h +++ b/src/data-pool.h @@ -50,6 +50,13 @@ typedef struct MMDB_data_pool_s { // which is not the same as the count of output elements (see // MAXIMUM_DATA_STRUCTURE_VALUES). size_t length; + + // Total bytes of string and bytes payloads decoded so far, across all + // blocks. The node count stays under MAXIMUM_DATA_STRUCTURE_VALUES even + // when many pointers target one large value, so this separately bounds the + // total payload a caller would copy out of the list (see + // MAXIMUM_DATA_STRUCTURE_BYTES). + uint64_t bytes; } MMDB_data_pool_s; bool can_multiply(size_t const, size_t const, size_t const); diff --git a/src/maxminddb.c b/src/maxminddb.c index 832100b8..ed29a97f 100644 --- a/src/maxminddb.c +++ b/src/maxminddb.c @@ -42,6 +42,19 @@ typedef ADDRESS_FAMILY sa_family_t; // specification's "Reader Resource Limits". #define MAXIMUM_DATA_STRUCTURE_VALUES ((size_t)1 << 16) +// The maximum total bytes of string and bytes payloads decoded for a single +// entry. libmaxminddb borrows payload bytes (each node points into the data +// section, it does not copy), so the value count above already bounds the +// library's own memory. But a fan-out of pointers to one large value produces +// many nodes that all reference it, and a caller that copies each node into a +// language string then materializes far more than the file holds. This bounds +// that copied total. The largest real records hold about a kilobyte of +// payload, so 2 MiB leaves a wide margin while stopping the amplification. It +// can be raised at build time with -DMAXIMUM_DATA_STRUCTURE_BYTES=. +#ifndef MAXIMUM_DATA_STRUCTURE_BYTES + #define MAXIMUM_DATA_STRUCTURE_BYTES ((size_t)1 << 21) +#endif + #ifdef MMDB_DEBUG #define DEBUG_MSG(msg) fprintf(stderr, msg "\n") #define DEBUG_MSGF(fmt, ...) fprintf(stderr, fmt "\n", __VA_ARGS__) @@ -1836,6 +1849,23 @@ static int get_entry_data_list(const MMDB_s *const mmdb, break; } + // Charge the copied payload. Only string and bytes carry a variable-length + // payload that a caller copies. Integers are size-validated and tiny, + // floats are fixed width, and container data_size is an element count, not + // bytes. Pointers have been resolved to their target above, so a pointer to + // a string is charged here as the string. This runs once per node, so a + // fan-out that references one large value many times is charged each time. + // pool->bytes is a uint64 and the value-count limit caps how many payloads + // are charged, so this sum cannot overflow before the comparison. + if (entry_data_list->entry_data.type == MMDB_DATA_TYPE_UTF8_STRING || + entry_data_list->entry_data.type == MMDB_DATA_TYPE_BYTES) { + pool->bytes += entry_data_list->entry_data.data_size; + if (pool->bytes > MAXIMUM_DATA_STRUCTURE_BYTES) { + DEBUG_MSG("reached the maximum data structure size"); + return MMDB_INVALID_DATA_ERROR; + } + } + return MMDB_SUCCESS; } From 4771688c7e6e574153ff28b6797b936b679e32a5 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 26 Aug 2026 18:36:51 +0000 Subject: [PATCH 3/8] Update the test-data submodule for the pointer DoS fixtures Bump t/maxmind-db to the coordinated MaxMind-DB branch commit that adds the pointer fan-out and payload amplification fixtures, so the new regression tests can use them. This pins a branch commit while both changes are in review and will move to the merged commit once the MaxMind-DB change lands. Co-Authored-By: Claude Opus 4.8 --- t/maxmind-db | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/maxmind-db b/t/maxmind-db index b019327b..0decfbe0 160000 --- a/t/maxmind-db +++ b/t/maxmind-db @@ -1 +1 @@ -Subproject commit b019327b2c96a4efe08a9aa20c9e73150d104147 +Subproject commit 0decfbe0f0f021fa27b796e56ce677f01a55b11d From c487a0a28061fba58db4357ce76a635002dc33e3 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 26 Aug 2026 18:36:51 +0000 Subject: [PATCH 4/8] Add regression tests for the decoder resource limits Exercise MMDB_get_entry_data_list against the coordinated fixtures. The value-count fan-out, the payload amplification, and its worst case under the value-count limit are each rejected with MMDB_INVALID_DATA_ERROR and leave a NULL output list. A normal record still decodes, confirming no false rejection, and a rejected decode does not affect a later one, confirming the counters are per call. Co-Authored-By: Claude Opus 4.8 --- t/CMakeLists.txt | 1 + t/Makefile.am | 2 +- t/pointer_dos_t.c | 157 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 t/pointer_dos_t.c diff --git a/t/CMakeLists.txt b/t/CMakeLists.txt index 04627b60..bb9c23c7 100644 --- a/t/CMakeLists.txt +++ b/t/CMakeLists.txt @@ -22,6 +22,7 @@ set(TEST_TARGET_NAMES metadata_t no_map_get_value_t overflow_bounds_t + pointer_dos_t read_node_t version_t ) diff --git a/t/Makefile.am b/t/Makefile.am index 630c664c..8fc05af3 100644 --- a/t/Makefile.am +++ b/t/Makefile.am @@ -24,7 +24,7 @@ check_PROGRAMS = \ get_value_pointer_bug_t invalid_sockaddr_t \ ipv4_start_cache_t ipv6_lookup_in_ipv4_t max_depth_t metadata_t \ metadata_marker_t metadata_pointers_t no_map_get_value_t \ - overflow_bounds_t read_node_t \ + overflow_bounds_t pointer_dos_t read_node_t \ threads_t version_t data_pool_t_LDFLAGS = $(AM_LDFLAGS) -lm diff --git a/t/pointer_dos_t.c b/t/pointer_dos_t.c new file mode 100644 index 00000000..694f135e --- /dev/null +++ b/t/pointer_dos_t.c @@ -0,0 +1,157 @@ +#include "maxminddb_test_helper.h" + +// A non-NULL sentinel for the output pointer. On a resource-limit error +// MMDB_get_entry_data_list must set the caller's output to NULL, not leave a +// stale pointer that a caller could later free. Starting from this sentinel and +// asserting the call replaces it with NULL proves that clearing. The node is +// not heap allocated, so it must never be freed. +static MMDB_entry_data_list_s sentinel_node; +#define OUTPUT_SENTINEL (&sentinel_node) + +/* Decoding a crafted fan-out record must be rejected, not run to exhaustion. + * The value-count and payload byte limits both surface as + * MMDB_INVALID_DATA_ERROR from MMDB_get_entry_data_list, and the output list + * must be set to NULL. */ +static void test_fan_out_rejected(const char *fixture, const char *desc) { + char *db_file = test_database_path(fixture); + + MMDB_s mmdb; + int status = MMDB_open(db_file, MMDB_MODE_MMAP, &mmdb); + cmp_ok(status, "==", MMDB_SUCCESS, "opened %s fixture", desc); + if (status != MMDB_SUCCESS) { + diag("MMDB_open failed: %s", MMDB_strerror(status)); + free(db_file); + return; + } + + int gai_error, mmdb_error; + MMDB_lookup_result_s result = + MMDB_lookup_string(&mmdb, "1.1.1.1", &gai_error, &mmdb_error); + cmp_ok(mmdb_error, "==", MMDB_SUCCESS, "%s: lookup succeeded", desc); + ok(result.found_entry, "%s: entry found", desc); + + if (result.found_entry) { + MMDB_entry_data_list_s *entry_data_list = OUTPUT_SENTINEL; + status = MMDB_get_entry_data_list(&result.entry, &entry_data_list); + cmp_ok(status, + "==", + MMDB_INVALID_DATA_ERROR, + "%s: MMDB_get_entry_data_list returns MMDB_INVALID_DATA_ERROR", + desc); + ok(entry_data_list == NULL, + "%s: output list is set to NULL after the error", + desc); + // Free a real partial list, or NULL as a no-op, but never the sentinel. + if (entry_data_list != OUTPUT_SENTINEL) { + MMDB_free_entry_data_list(entry_data_list); + } + } + + MMDB_close(&mmdb); + free(db_file); +} + +/* A normal record must still decode fully. Its payload is far below the limit, + * so the limits must not reject legitimate data. */ +static void test_normal_record_allowed(void) { + char *db_file = test_database_path("GeoIP2-City-Test.mmdb"); + + MMDB_s mmdb; + int status = MMDB_open(db_file, MMDB_MODE_MMAP, &mmdb); + cmp_ok(status, "==", MMDB_SUCCESS, "opened GeoIP2-City-Test"); + if (status != MMDB_SUCCESS) { + diag("MMDB_open failed: %s", MMDB_strerror(status)); + free(db_file); + return; + } + + int gai_error, mmdb_error; + MMDB_lookup_result_s result = + MMDB_lookup_string(&mmdb, "81.2.69.142", &gai_error, &mmdb_error); + ok(result.found_entry, "normal record: entry found"); + + if (result.found_entry) { + MMDB_entry_data_list_s *entry_data_list = NULL; + status = MMDB_get_entry_data_list(&result.entry, &entry_data_list); + cmp_ok(status, + "==", + MMDB_SUCCESS, + "normal record decodes with no false rejection"); + ok(entry_data_list != NULL, "normal record: list returned"); + MMDB_free_entry_data_list(entry_data_list); + } + + MMDB_close(&mmdb); + free(db_file); +} + +/* The counters are per call. A rejected decode must not leave state that + * changes a later decode on the same reader. */ +static void test_per_call_state(void) { + char *dos_file = + test_database_path("MaxMind-DB-test-payload-amplification-dos.mmdb"); + MMDB_s dos; + if (MMDB_open(dos_file, MMDB_MODE_MMAP, &dos) == MMDB_SUCCESS) { + int gai, err; + MMDB_lookup_result_s result = + MMDB_lookup_string(&dos, "1.1.1.1", &gai, &err); + if (result.found_entry) { + MMDB_entry_data_list_s *first = OUTPUT_SENTINEL; + int s1 = MMDB_get_entry_data_list(&result.entry, &first); + cmp_ok(s1, + "==", + MMDB_INVALID_DATA_ERROR, + "per-call: first decode of the attack record is rejected"); + if (first != OUTPUT_SENTINEL) { + MMDB_free_entry_data_list(first); + } + + MMDB_entry_data_list_s *second = OUTPUT_SENTINEL; + int s2 = MMDB_get_entry_data_list(&result.entry, &second); + cmp_ok(s2, + "==", + MMDB_INVALID_DATA_ERROR, + "per-call: repeating it is still rejected, no leaked count"); + if (second != OUTPUT_SENTINEL) { + MMDB_free_entry_data_list(second); + } + + // Same-reader isolation. After the rejections a bounded decode on + // the same reader must still succeed. Offset 0 is the shared scalar + // the fan-out points at, a single small value well under the + // limits. A reader left with exhausted counters would reject it. + MMDB_entry_s scalar = {.mmdb = &dos, .offset = 0}; + MMDB_entry_data_list_s *bounded = NULL; + int s3 = MMDB_get_entry_data_list(&scalar, &bounded); + cmp_ok(s3, + "==", + MMDB_SUCCESS, + "per-call: a bounded decode on the same reader still works"); + ok(bounded != NULL, "per-call: bounded decode returned a list"); + MMDB_free_entry_data_list(bounded); + } + MMDB_close(&dos); + } + free(dos_file); +} + +int main(void) { + plan(NO_PLAN); + /* Value-count limit: nested arrays of pointers to shared targets. */ + test_fan_out_rejected("MaxMind-DB-test-pointer-decoder-dos.mmdb", + "value-count fan-out"); + /* Payload byte limit: many pointers to one large bytes value. */ + test_fan_out_rejected("MaxMind-DB-test-payload-amplification-dos.mmdb", + "payload amplification"); + /* Worst case under the value-count limit, caught only by the byte limit. */ + test_fan_out_rejected( + "MaxMind-DB-test-payload-amplification-dos-worst-case.mmdb", + "worst-case payload"); + /* Payload byte limit via a shared UTF-8 string, the type bindings copy. */ + test_fan_out_rejected( + "MaxMind-DB-test-payload-amplification-dos-string.mmdb", + "string payload amplification"); + test_normal_record_allowed(); + test_per_call_state(); + done_testing(); +} From c2bf3ecdbd2fdc6fecc779a23e2dc3cece4658f8 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 26 Aug 2026 22:24:02 +0000 Subject: [PATCH 5/8] fixup! Bound the total payload decoded for a single entry --- Changes.md | 27 +++++---- doc/libmaxminddb.md | 30 ++++++++- include/maxminddb.h | 1 + src/data-pool.c | 30 ++++++--- src/data-pool.h | 29 +++------ src/maxminddb.c | 145 ++++++++++++++++++++++++++++++++------------ 6 files changed, 181 insertions(+), 81 deletions(-) diff --git a/Changes.md b/Changes.md index 66607287..6c1ef183 100644 --- a/Changes.md +++ b/Changes.md @@ -3,23 +3,26 @@ - Fixed a denial-of-service issue in `MMDB_get_entry_data_list()`. A crafted database could nest data-section pointers to shared targets so that decoding one entry cost exponential time and memory from a small file. The decoder now - limits the number of values it decodes for a single entry to 65,536 and - returns `MMDB_INVALID_DATA_ERROR` when an entry exceeds it. The largest real - records MaxMind produces decode a few hundred values. This matches the reader - resource limits recommended by a proposed update to the MaxMind DB - specification. See GHSA-hj94-g986-h9r7. + limits each returned list to 65,536 values and returns + `MMDB_DECODER_LIMIT_ERROR` when an entry exceeds it. The largest real records + MaxMind produces decode a few hundred values. This follows the proposed + Reader Resource Limits guidance for the MaxMind DB specification. See + GHSA-hj94-g986-h9r7. - Fixed a related payload-amplification denial of service. A crafted database can point many times at one large value, so `MMDB_get_entry_data_list()` returns a bounded number of nodes that together reference far more bytes than the file holds. A caller that copies each node into a string then materializes that amplified total. The decoder now also limits the total string and bytes - payload it decodes for a single entry to 2 MiB and returns - `MMDB_INVALID_DATA_ERROR` when an entry exceeds it. This also rejects a rare - format-valid record whose own string and bytes fields total more than the - limit, since a single field can be up to 16,843,036 bytes. For such a - database, raise the limit at build time with - `-DMAXIMUM_DATA_STRUCTURE_BYTES=`. The largest records MaxMind produces - hold about a kilobyte of payload. See GHSA-hj94-g986-h9r7. + payload it exposes for a single entry to 2 MiB. Exceeding either new limit + returns `MMDB_DECODER_LIMIT_ERROR` and leaves the output list set to `NULL`. + These limits also protect the `languages` and `description` structures read + by `MMDB_open()`, where an over-limit structure is reported as + `MMDB_INVALID_METADATA_ERROR`. Both limits can be raised when rebuilding the + library with `-DMAXIMUM_DATA_STRUCTURE_VALUES=` and + `-DMAXIMUM_DATA_STRUCTURE_BYTES=`. Applications using a packaged + library can retrieve individual values with `MMDB_get_value()` or + `MMDB_aget_value()` without expanding the complete structure. See + GHSA-hj94-g986-h9r7. - Fixed an out-of-bounds read in `MMDB_lookup_sockaddr()` when callers passed a `sockaddr` with an unsupported address family. The function now rejects any family other than `AF_INET` and `AF_INET6` with diff --git a/doc/libmaxminddb.md b/doc/libmaxminddb.md index 10ecc743..b29e7a22 100644 --- a/doc/libmaxminddb.md +++ b/doc/libmaxminddb.md @@ -395,6 +395,9 @@ status codes are: array where none exist. - `MMDB_INVALID_NETWORK_ADDRESS_ERROR` - `MMDB_lookup_sockaddr()` was given a `sockaddr` whose family is neither `AF_INET` nor `AF_INET6`. +- `MMDB_DECODER_LIMIT_ERROR` - decoding an entry as a complete list would + exceed the configured value-count or string/bytes payload limit. The entry + may still be valid MaxMind DB data. All status codes should be treated as `int` values. @@ -452,6 +455,11 @@ You can also pass `0` as the `flags` value in which case the database will be opened with the default flags. However, these defaults may change in future releases. The current default is `MMDB_MODE_MMAP`. +Opening a database decodes its `languages` and `description` metadata. If one +of these structures exceeds the decoder resource limits described under +`MMDB_get_entry_data_list()`, this function returns +`MMDB_INVALID_METADATA_ERROR`. + ## `MMDB_close()` ```c @@ -640,6 +648,24 @@ This function allows you to get all of the data for a complex data structure at once, rather than looking up each piece using repeated calls to `MMDB_get_value()`. +To bound the work and caller-visible payload produced by crafted databases, +this function decodes at most 65,536 list values and at most 2 MiB of UTF-8 +string and bytes payload per call. A structure exactly at either limit is +accepted. If a structure exceeds either limit, the function returns +`MMDB_DECODER_LIMIT_ERROR` and sets `entry_data_list` to `NULL`. + +The limits are per call and may be changed when rebuilding libmaxminddb by +defining the positive integer macros `MAXIMUM_DATA_STRUCTURE_VALUES` and +`MAXIMUM_DATA_STRUCTURE_BYTES`. For example, pass +`-DMAXIMUM_DATA_STRUCTURE_BYTES=3145728` in the library's compiler flags. This +requires rebuilding the library itself; defining the macro only while building +an application does not change a packaged shared library. + +`MMDB_get_value()`, `MMDB_vget_value()`, and `MMDB_aget_value()` do not expand a +complete structure and therefore do not charge these two budgets. Applications +that cannot rebuild a packaged library can use those functions to retrieve a +specific field from an otherwise over-limit record. + ```c MMDB_lookup_result_s result = MMDB_lookup_sockaddr(&mmdb, address->ai_addr, &mmdb_error); @@ -717,7 +743,9 @@ int MMDB_get_metadata_as_entry_data_list( This function allows you to retrieve the database metadata as a linked list of `MMDB_entry_data_list_s` structures. This can be a more convenient way to deal -with the metadata than using the metadata structure directly. +with the metadata than using the metadata structure directly. It uses the same +per-call limits as `MMDB_get_entry_data_list()` and returns +`MMDB_DECODER_LIMIT_ERROR` if the complete metadata list exceeds either one. ```c MMDB_entry_data_list_s *entry_data_list, *first; diff --git a/include/maxminddb.h b/include/maxminddb.h index 59f404db..ea0d9691 100644 --- a/include/maxminddb.h +++ b/include/maxminddb.h @@ -87,6 +87,7 @@ extern "C" { #define MMDB_INVALID_NODE_NUMBER_ERROR (10) #define MMDB_IPV6_LOOKUP_IN_IPV4_DATABASE_ERROR (11) #define MMDB_INVALID_NETWORK_ADDRESS_ERROR (12) + #define MMDB_DECODER_LIMIT_ERROR (13) #if !(MMDB_UINT128_IS_BYTE_ARRAY) #if MMDB_UINT128_USING_MODE diff --git a/src/data-pool.c b/src/data-pool.c index 3bc63286..8eb81b3a 100644 --- a/src/data-pool.c +++ b/src/data-pool.c @@ -9,16 +9,22 @@ #include #include -// Allocate an MMDB_data_pool_s. It initially has space for size -// MMDB_entry_data_list_s structs. -MMDB_data_pool_s *data_pool_new(size_t const size) { +// Allocate an MMDB_data_pool_s. It initially has space for up to size +// MMDB_entry_data_list_s structs and will never reserve more than max_size. +MMDB_data_pool_s *data_pool_new(size_t size, size_t const max_size) { MMDB_data_pool_s *const pool = calloc(1, sizeof(MMDB_data_pool_s)); if (!pool) { return NULL; } - if (size == 0 || - !can_multiply(SIZE_MAX, size, sizeof(MMDB_entry_data_list_s))) { + if (size == 0 || max_size == 0) { + data_pool_destroy(pool); + return NULL; + } + if (size > max_size) { + size = max_size; + } + if (!can_multiply(SIZE_MAX, size, sizeof(MMDB_entry_data_list_s))) { data_pool_destroy(pool); return NULL; } @@ -31,6 +37,8 @@ MMDB_data_pool_s *data_pool_new(size_t const size) { pool->blocks[0]->pool = pool; pool->sizes[0] = size; + pool->capacity = size; + pool->max_size = max_size; pool->block = pool->blocks[0]; @@ -75,6 +83,10 @@ MMDB_entry_data_list_s *data_pool_alloc(MMDB_data_pool_s *const pool) { return element; } + if (pool->capacity == pool->max_size) { + return NULL; + } + // Take it from a new block of memory. size_t const new_index = pool->index + 1; @@ -83,10 +95,9 @@ MMDB_entry_data_list_s *data_pool_alloc(MMDB_data_pool_s *const pool) { return NULL; } - if (!can_multiply(SIZE_MAX, pool->size, 2)) { - return NULL; - } - size_t const new_size = pool->size * 2; + size_t const remaining = pool->max_size - pool->capacity; + size_t const new_size = + pool->size <= remaining / 2 ? pool->size * 2 : remaining; if (!can_multiply(SIZE_MAX, new_size, sizeof(MMDB_entry_data_list_s))) { return NULL; @@ -104,6 +115,7 @@ MMDB_entry_data_list_s *data_pool_alloc(MMDB_data_pool_s *const pool) { pool->size = new_size; pool->sizes[pool->index] = pool->size; + pool->capacity += new_size; MMDB_entry_data_list_s *const element = pool->block; pool->used = 1; diff --git a/src/data-pool.h b/src/data-pool.h index 8d8457eb..85ddd224 100644 --- a/src/data-pool.h +++ b/src/data-pool.h @@ -7,12 +7,12 @@ #include // This should be large enough that we never need to grow the array of pointers -// to blocks. 32 is enough. Even starting out of with size 1 (1 struct), the -// 32nd element alone will provide 2**32 structs as we exponentially increase +// to blocks. 64 is enough. Even starting with size 1 (1 struct), the +// 64th element alone will provide 2**63 structs as we exponentially increase // the number in each block. Being confident that we do not have to grow the // array lets us avoid writing code to do that. That code would be risky as it // would rarely be hit and likely not be well tested. -#define DATA_POOL_NUM_BLOCKS 32 +#define DATA_POOL_NUM_BLOCKS 64 // A pool of memory for MMDB_entry_data_list_s structs. This is so we can // allocate multiple up front rather than one at a time for performance @@ -33,6 +33,12 @@ typedef struct MMDB_data_pool_s { // How many used in the current block, counting by structs. size_t used; + // Total number of structs reserved across all blocks. + size_t capacity; + + // Maximum number of structs this pool may reserve. + size_t max_size; + // The current block we're allocating out of. MMDB_entry_data_list_s *block; @@ -42,25 +48,10 @@ typedef struct MMDB_data_pool_s { // An array of pointers to blocks of memory holding space for list // elements. MMDB_entry_data_list_s *blocks[DATA_POOL_NUM_BLOCKS]; - - // Number of decode visits charged for a single entry, across all blocks. - // get_entry_data_list charges one per call, so following a pointer into a - // container charges every level it expands even where those visits reuse - // one output list node. This bounds the fan-out work an attacker inflates, - // which is not the same as the count of output elements (see - // MAXIMUM_DATA_STRUCTURE_VALUES). - size_t length; - - // Total bytes of string and bytes payloads decoded so far, across all - // blocks. The node count stays under MAXIMUM_DATA_STRUCTURE_VALUES even - // when many pointers target one large value, so this separately bounds the - // total payload a caller would copy out of the list (see - // MAXIMUM_DATA_STRUCTURE_BYTES). - uint64_t bytes; } MMDB_data_pool_s; bool can_multiply(size_t const, size_t const, size_t const); -MMDB_data_pool_s *data_pool_new(size_t const); +MMDB_data_pool_s *data_pool_new(size_t const, size_t const); void data_pool_destroy(MMDB_data_pool_s *const); MMDB_entry_data_list_s *data_pool_alloc(MMDB_data_pool_s *const); MMDB_entry_data_list_s *data_pool_to_list(MMDB_data_pool_s *const); diff --git a/src/maxminddb.c b/src/maxminddb.c index ed29a97f..6797422b 100644 --- a/src/maxminddb.c +++ b/src/maxminddb.c @@ -38,9 +38,16 @@ typedef ADDRESS_FAMILY sa_family_t; // The maximum number of data-section values decoded for a single entry. This // bounds a pointer fan-out, where nested pointers to shared targets would // otherwise cost 2**depth decode operations. The largest real records decode a -// few hundred values, so this leaves a wide margin. See the MaxMind DB -// specification's "Reader Resource Limits". -#define MAXIMUM_DATA_STRUCTURE_VALUES ((size_t)1 << 16) +// few hundred values, so this leaves a wide margin. See the proposed "Reader +// Resource Limits" guidance for the MaxMind DB specification. +#ifndef MAXIMUM_DATA_STRUCTURE_VALUES + #define MAXIMUM_DATA_STRUCTURE_VALUES (1U << 16) +#endif + +#if MAXIMUM_DATA_STRUCTURE_VALUES < 1 || \ + MAXIMUM_DATA_STRUCTURE_VALUES > SIZE_MAX + #error "MAXIMUM_DATA_STRUCTURE_VALUES must be between 1 and SIZE_MAX" +#endif // The maximum total bytes of string and bytes payloads decoded for a single // entry. libmaxminddb borrows payload bytes (each node points into the data @@ -52,7 +59,12 @@ typedef ADDRESS_FAMILY sa_family_t; // payload, so 2 MiB leaves a wide margin while stopping the amplification. It // can be raised at build time with -DMAXIMUM_DATA_STRUCTURE_BYTES=. #ifndef MAXIMUM_DATA_STRUCTURE_BYTES - #define MAXIMUM_DATA_STRUCTURE_BYTES ((size_t)1 << 21) + #define MAXIMUM_DATA_STRUCTURE_BYTES (1U << 21) +#endif + +#if MAXIMUM_DATA_STRUCTURE_BYTES < 1 || \ + MAXIMUM_DATA_STRUCTURE_BYTES > UINT64_MAX + #error "MAXIMUM_DATA_STRUCTURE_BYTES must be between 1 and UINT64_MAX" #endif #ifdef MMDB_DEBUG @@ -150,6 +162,11 @@ typedef struct record_info_s { uint8_t right_record_offset; } record_info_s; +typedef struct MMDB_decode_state_s { + size_t values; + uint64_t bytes; +} MMDB_decode_state_s; + #define METADATA_MARKER "\xab\xcd\xefMaxMind.com" /* This is 128kb */ #define METADATA_BLOCK_MAX_SIZE 131072 @@ -212,7 +229,11 @@ static int get_entry_data_list(const MMDB_s *const mmdb, uint32_t offset, MMDB_entry_data_list_s *const entry_data_list, MMDB_data_pool_s *const pool, + MMDB_decode_state_s *const decode_state, int depth); +static int alloc_entry_data_list(MMDB_data_pool_s *const pool, + MMDB_decode_state_s *const decode_state, + MMDB_entry_data_list_s **const entry_data_list); static float get_ieee754_float(const uint8_t *restrict p); static double get_ieee754_double(const uint8_t *restrict p); static uint32_t get_uint32(const uint8_t *p); @@ -757,7 +778,9 @@ static int populate_languages_metadata(MMDB_s *mmdb, MMDB_entry_data_list_s *member; status = MMDB_get_entry_data_list(&array_start, &member); if (MMDB_SUCCESS != status) { - return status; + return status == MMDB_DECODER_LIMIT_ERROR + ? MMDB_INVALID_METADATA_ERROR + : status; } MMDB_entry_data_list_s *first_member = member; @@ -823,7 +846,9 @@ static int populate_description_metadata(MMDB_s *mmdb, " status = %d (%s)", status, MMDB_strerror(status)); - return status; + return status == MMDB_DECODER_LIMIT_ERROR + ? MMDB_INVALID_METADATA_ERROR + : status; } MMDB_entry_data_list_s *first_member = member; @@ -1707,19 +1732,23 @@ int MMDB_get_entry_data_list(MMDB_entry_s *start, MMDB_entry_data_list_s **const entry_data_list) { *entry_data_list = NULL; - MMDB_data_pool_s *const pool = data_pool_new(MMDB_POOL_INIT_SIZE); + size_t const maximum_values = (size_t)MAXIMUM_DATA_STRUCTURE_VALUES; + MMDB_data_pool_s *const pool = + data_pool_new(MMDB_POOL_INIT_SIZE, maximum_values); if (!pool) { return MMDB_OUT_OF_MEMORY_ERROR; } - MMDB_entry_data_list_s *const list = data_pool_alloc(pool); - if (!list) { + MMDB_decode_state_s decode_state = {0}; + MMDB_entry_data_list_s *list = NULL; + int status = alloc_entry_data_list(pool, &decode_state, &list); + if (MMDB_SUCCESS != status) { data_pool_destroy(pool); - return MMDB_OUT_OF_MEMORY_ERROR; + return status; } - int const status = - get_entry_data_list(start->mmdb, start->offset, list, pool, 0); + status = get_entry_data_list( + start->mmdb, start->offset, list, pool, &decode_state, 0); if (MMDB_SUCCESS != status) { data_pool_destroy(pool); return status; @@ -1734,20 +1763,35 @@ int MMDB_get_entry_data_list(MMDB_entry_s *start, return status; } +static int alloc_entry_data_list( + MMDB_data_pool_s *const pool, + MMDB_decode_state_s *const decode_state, + MMDB_entry_data_list_s **const entry_data_list) { + size_t const maximum_values = (size_t)MAXIMUM_DATA_STRUCTURE_VALUES; + if (decode_state->values >= maximum_values) { + DEBUG_MSG("reached the maximum number of data structure values"); + return MMDB_DECODER_LIMIT_ERROR; + } + + *entry_data_list = data_pool_alloc(pool); + if (!*entry_data_list) { + return MMDB_OUT_OF_MEMORY_ERROR; + } + decode_state->values++; + return MMDB_SUCCESS; +} + static int get_entry_data_list(const MMDB_s *const mmdb, uint32_t offset, MMDB_entry_data_list_s *const entry_data_list, MMDB_data_pool_s *const pool, + MMDB_decode_state_s *const decode_state, int depth) { if (depth >= MAXIMUM_DATA_STRUCTURE_DEPTH) { DEBUG_MSG("reached the maximum data structure depth"); return MMDB_INVALID_DATA_ERROR; } depth++; - if (++pool->length > MAXIMUM_DATA_STRUCTURE_VALUES) { - DEBUG_MSG("reached the maximum number of data structure values"); - return MMDB_INVALID_DATA_ERROR; - } CHECKED_DECODE_ONE(mmdb, offset, &entry_data_list->entry_data); switch (entry_data_list->entry_data.type) { @@ -1768,8 +1812,13 @@ static int get_entry_data_list(const MMDB_s *const mmdb, if (entry_data_list->entry_data.type == MMDB_DATA_TYPE_ARRAY || entry_data_list->entry_data.type == MMDB_DATA_TYPE_MAP) { - int status = get_entry_data_list( - mmdb, last_offset, entry_data_list, pool, depth); + int status = + get_entry_data_list(mmdb, + last_offset, + entry_data_list, + pool, + decode_state, + depth); if (MMDB_SUCCESS != status) { DEBUG_MSG("get_entry_data_list on pointer failed."); return status; @@ -1787,14 +1836,19 @@ static int get_entry_data_list(const MMDB_s *const mmdb, return MMDB_INVALID_DATA_ERROR; } while (array_size-- > 0) { - MMDB_entry_data_list_s *entry_data_list_to = - data_pool_alloc(pool); - if (!entry_data_list_to) { - return MMDB_OUT_OF_MEMORY_ERROR; + MMDB_entry_data_list_s *entry_data_list_to = NULL; + int status = alloc_entry_data_list( + pool, decode_state, &entry_data_list_to); + if (MMDB_SUCCESS != status) { + return status; } - int status = get_entry_data_list( - mmdb, array_offset, entry_data_list_to, pool, depth); + status = get_entry_data_list(mmdb, + array_offset, + entry_data_list_to, + pool, + decode_state, + depth); if (MMDB_SUCCESS != status) { DEBUG_MSG("get_entry_data_list on array element failed."); return status; @@ -1816,13 +1870,15 @@ static int get_entry_data_list(const MMDB_s *const mmdb, return MMDB_INVALID_DATA_ERROR; } while (size-- > 0) { - MMDB_entry_data_list_s *list_key = data_pool_alloc(pool); - if (!list_key) { - return MMDB_OUT_OF_MEMORY_ERROR; + MMDB_entry_data_list_s *list_key = NULL; + int status = + alloc_entry_data_list(pool, decode_state, &list_key); + if (MMDB_SUCCESS != status) { + return status; } - int status = - get_entry_data_list(mmdb, offset, list_key, pool, depth); + status = get_entry_data_list( + mmdb, offset, list_key, pool, decode_state, depth); if (MMDB_SUCCESS != status) { DEBUG_MSG("get_entry_data_list on map key failed."); return status; @@ -1830,13 +1886,15 @@ static int get_entry_data_list(const MMDB_s *const mmdb, offset = list_key->entry_data.offset_to_next; - MMDB_entry_data_list_s *list_value = data_pool_alloc(pool); - if (!list_value) { - return MMDB_OUT_OF_MEMORY_ERROR; + MMDB_entry_data_list_s *list_value = NULL; + status = + alloc_entry_data_list(pool, decode_state, &list_value); + if (MMDB_SUCCESS != status) { + return status; } - status = - get_entry_data_list(mmdb, offset, list_value, pool, depth); + status = get_entry_data_list( + mmdb, offset, list_value, pool, decode_state, depth); if (MMDB_SUCCESS != status) { DEBUG_MSG("get_entry_data_list on map element failed."); return status; @@ -1855,15 +1913,19 @@ static int get_entry_data_list(const MMDB_s *const mmdb, // bytes. Pointers have been resolved to their target above, so a pointer to // a string is charged here as the string. This runs once per node, so a // fan-out that references one large value many times is charged each time. - // pool->bytes is a uint64 and the value-count limit caps how many payloads - // are charged, so this sum cannot overflow before the comparison. + // Check before adding so even an overridden maximum cannot make the + // uint64 counter wrap. if (entry_data_list->entry_data.type == MMDB_DATA_TYPE_UTF8_STRING || entry_data_list->entry_data.type == MMDB_DATA_TYPE_BYTES) { - pool->bytes += entry_data_list->entry_data.data_size; - if (pool->bytes > MAXIMUM_DATA_STRUCTURE_BYTES) { - DEBUG_MSG("reached the maximum data structure size"); - return MMDB_INVALID_DATA_ERROR; + uint64_t const maximum_bytes = + (uint64_t)MAXIMUM_DATA_STRUCTURE_BYTES; + uint64_t const data_size = entry_data_list->entry_data.data_size; + if (data_size > maximum_bytes || + decode_state->bytes > maximum_bytes - data_size) { + DEBUG_MSG("reached the maximum data structure bytes"); + return MMDB_DECODER_LIMIT_ERROR; } + decode_state->bytes += data_size; } return MMDB_SUCCESS; @@ -2326,6 +2388,9 @@ const char *MMDB_strerror(int error_code) { case MMDB_INVALID_NETWORK_ADDRESS_ERROR: return "The sockaddr family is unsupported; only AF_INET and " "AF_INET6 are accepted"; + case MMDB_DECODER_LIMIT_ERROR: + return "The decoded data structure exceeds the configured resource " + "limits"; default: return "Unknown error code"; } From cca8b1277ff88bffe13bf4c2fdee87358c4c2a1d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 26 Aug 2026 22:24:29 +0000 Subject: [PATCH 6/8] fixup! Update the test-data submodule for the pointer DoS fixtures --- t/maxmind-db | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/maxmind-db b/t/maxmind-db index 0decfbe0..adbac1df 160000 --- a/t/maxmind-db +++ b/t/maxmind-db @@ -1 +1 @@ -Subproject commit 0decfbe0f0f021fa27b796e56ce677f01a55b11d +Subproject commit adbac1df272f4808e129bf66402eb12cf59012b9 From 07ab6d6262ba9c801ae0b21074842ca65f6722f2 Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 26 Aug 2026 22:24:29 +0000 Subject: [PATCH 7/8] fixup! Add regression tests for the decoder resource limits --- README.fuzzing.md | 2 +- t/Makefile.am | 6 +- t/data-pool-t.c | 60 ++++++-- t/decoder_limits_t.pl | 192 +++++++++++++++++++++++++ t/fuzz_mmdb.c | 22 ++- t/pointer_dos_t.c | 317 +++++++++++++++++++++++++++--------------- 6 files changed, 472 insertions(+), 127 deletions(-) create mode 100755 t/decoder_limits_t.pl diff --git a/README.fuzzing.md b/README.fuzzing.md index 621061c0..706e4bef 100644 --- a/README.fuzzing.md +++ b/README.fuzzing.md @@ -34,7 +34,7 @@ $ cmake --build . -j$(nproc) ```shell $ mkdir -p fuzz_mmdb_seed fuzz_mmdb_seed_corpus -$ find ../t/maxmind-db/test-data/ -type f -size -4k -exec cp {} ./fuzz_mmdb_seed_corpus/ \; +$ find ../t/maxmind-db/test-data/ -type f -size -256k -exec cp {} ./fuzz_mmdb_seed_corpus/ \; $ ./t/fuzz_mmdb fuzz_mmdb_seed/ fuzz_mmdb_seed_corpus/ ``` diff --git a/t/Makefile.am b/t/Makefile.am index 8fc05af3..f2c01aca 100644 --- a/t/Makefile.am +++ b/t/Makefile.am @@ -11,7 +11,8 @@ CFLAGS += -I$(top_srcdir)/src noinst_LTLIBRARIES = libmmdbtest.la libmmdbtest_la_SOURCES = maxminddb_test_helper.c maxminddb_test_helper.h -EXTRA_DIST = compile_c++_t.pl external_symbols_t.pl mmdblookup_t.pl \ +EXTRA_DIST = compile_c++_t.pl decoder_limits_t.pl external_symbols_t.pl \ + mmdblookup_t.pl \ libtap/COPYING libtap/INSTALL libtap/Makefile libtap/README.md \ libtap/tap.c libtap/tap.h maxmind-db @@ -32,6 +33,7 @@ data_pool_t_SOURCES = data-pool-t.c ../src/data-pool.c threads_t_CFLAGS = $(CFLAGS) -pthread -TESTS = $(check_PROGRAMS) compile_c++_t.pl external_symbols_t.pl mmdblookup_t.pl +TESTS = $(check_PROGRAMS) compile_c++_t.pl decoder_limits_t.pl \ + external_symbols_t.pl mmdblookup_t.pl LDADD = libmmdbtest.la libtap/libtap.a diff --git a/t/data-pool-t.c b/t/data-pool-t.c index 6952c035..c083358d 100644 --- a/t/data-pool-t.c +++ b/t/data-pool-t.c @@ -24,20 +24,31 @@ int main(void) { static void test_data_pool_new(void) { { - MMDB_data_pool_s *const pool = data_pool_new(0); + MMDB_data_pool_s *const pool = data_pool_new(0, 512); ok(!pool, "size 0 is not valid"); } { - MMDB_data_pool_s *const pool = data_pool_new(SIZE_MAX - 10); + MMDB_data_pool_s *const pool = + data_pool_new(SIZE_MAX - 10, SIZE_MAX); ok(!pool, "very large size is not valid"); } { - MMDB_data_pool_s *const pool = data_pool_new(512); + MMDB_data_pool_s *const pool = data_pool_new(512, 1024); ok(pool != NULL, "size 512 is valid"); cmp_ok(pool->size, "==", 512, "size is 512"); cmp_ok(pool->used, "==", 0, "used size is 0"); + cmp_ok(pool->capacity, "==", 512, "capacity is 512"); + cmp_ok(pool->max_size, "==", 1024, "maximum size is 1024"); + data_pool_destroy(pool); + } + + { + MMDB_data_pool_s *const pool = data_pool_new(512, 10); + ok(pool != NULL, "maximum smaller than initial size is valid"); + cmp_ok(pool->size, "==", 10, "initial size is clamped to maximum"); + cmp_ok(pool->capacity, "==", 10, "capacity is clamped to maximum"); data_pool_destroy(pool); } } @@ -48,7 +59,7 @@ static void test_data_pool_destroy(void) { } { - MMDB_data_pool_s *const pool = data_pool_new(512); + MMDB_data_pool_s *const pool = data_pool_new(512, 512); ok(pool != NULL, "created pool"); data_pool_destroy(pool); } @@ -56,7 +67,7 @@ static void test_data_pool_destroy(void) { static void test_data_pool_alloc(void) { { - MMDB_data_pool_s *const pool = data_pool_new(1); + MMDB_data_pool_s *const pool = data_pool_new(1, 3); ok(pool != NULL, "created pool"); cmp_ok(pool->used, "==", 0, "used size starts at 0"); @@ -75,6 +86,12 @@ static void test_data_pool_alloc(void) { cmp_ok(pool->size, "==", 2, "size is 2 (new block)"); cmp_ok(pool->used, "==", 1, "used size is 1 in current block"); + MMDB_entry_data_list_s *const entry3 = data_pool_alloc(pool); + ok(entry3 != NULL, "got the final allowed entry"); + ok(data_pool_alloc(pool) == NULL, + "allocation past maximum capacity is rejected"); + cmp_ok(pool->capacity, "==", 3, "capacity does not exceed maximum"); + ok(entry1->entry_data.offset == 123, "accessing the original entry's memory is ok"); @@ -83,7 +100,8 @@ static void test_data_pool_alloc(void) { { size_t const initial_size = 10; - MMDB_data_pool_s *const pool = data_pool_new(initial_size); + MMDB_data_pool_s *const pool = + data_pool_new(initial_size, initial_size * 3); ok(pool != NULL, "created pool"); MMDB_entry_data_list_s *entry1 = NULL; @@ -124,12 +142,32 @@ static void test_data_pool_alloc(void) { data_pool_destroy(pool); } + + { + size_t const maximum_size = 65536; + MMDB_data_pool_s *const pool = data_pool_new(64, maximum_size); + ok(pool != NULL, "created a decoder-sized pool"); + for (size_t i = 0; i < maximum_size; i++) { + assert(data_pool_alloc(pool) != NULL); + } + cmp_ok(pool->capacity, + "==", + maximum_size, + "final block is clamped to the remaining capacity"); + cmp_ok(pool->sizes[pool->index], + "==", + 64, + "the clamped final block reserves only 64 entries"); + ok(data_pool_alloc(pool) == NULL, + "decoder-sized pool refuses a 65,537th entry"); + data_pool_destroy(pool); + } } static void test_data_pool_to_list(void) { { size_t const initial_size = 16; - MMDB_data_pool_s *const pool = data_pool_new(initial_size); + MMDB_data_pool_s *const pool = data_pool_new(initial_size, initial_size); ok(pool != NULL, "created pool"); MMDB_entry_data_list_s *const entry1 = data_pool_alloc(pool); @@ -162,7 +200,7 @@ static void test_data_pool_to_list(void) { { size_t const initial_size = 1; - MMDB_data_pool_s *const pool = data_pool_new(initial_size); + MMDB_data_pool_s *const pool = data_pool_new(initial_size, initial_size); ok(pool != NULL, "created pool"); MMDB_entry_data_list_s *const entry1 = data_pool_alloc(pool); @@ -180,7 +218,7 @@ static void test_data_pool_to_list(void) { { size_t const initial_size = 2; - MMDB_data_pool_s *const pool = data_pool_new(initial_size); + MMDB_data_pool_s *const pool = data_pool_new(initial_size, initial_size); ok(pool != NULL, "created pool"); MMDB_entry_data_list_s *const entry1 = data_pool_alloc(pool); @@ -271,7 +309,9 @@ static void test_data_pool_to_list(void) { // this frequently. static bool create_and_check_list(size_t const initial_size, size_t const element_count) { - MMDB_data_pool_s *const pool = data_pool_new(initial_size); + size_t const max_size = + element_count > initial_size ? element_count : initial_size; + MMDB_data_pool_s *const pool = data_pool_new(initial_size, max_size); assert(pool != NULL); assert(pool->used == 0); diff --git a/t/decoder_limits_t.pl b/t/decoder_limits_t.pl new file mode 100755 index 00000000..3dc66abf --- /dev/null +++ b/t/decoder_limits_t.pl @@ -0,0 +1,192 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use Cwd qw( abs_path ); +use FindBin qw( $Bin ); + +eval <<'EOF'; +use Test::More 0.88; +use File::Temp qw( tempdir ); +use IPC::Run3 qw( run3 ); +EOF + +if ($@) { + print + "1..0 # skip decoder limit override tests need Test::More 0.88, File::Temp, and IPC::Run3\n"; + exit 0; +} + +my $root = abs_path("$Bin/.."); +my $include_dir = "$root/include"; +my $src_dir = "$root/src"; +my $cc = $ENV{CC} || 'cc'; +my @cflags = $ENV{CFLAGS} ? ( split ' ', $ENV{CFLAGS} ) : (); +my @base = ( + $cc, + @cflags, + '-std=c99', + '-Wall', + '-Wextra', + '-Werror', + '-Wno-unused-function', + '-Wno-unused-parameter', + '-DPACKAGE_VERSION="test"', + "-I$include_dir", + "-I$src_dir", +); + +for my $definition ( + '-DMAXIMUM_DATA_STRUCTURE_VALUES=1000000', + '-DMAXIMUM_DATA_STRUCTURE_BYTES=1<<31', + '-DMAXIMUM_DATA_STRUCTURE_BYTES=2*1024*1024*1024', +) { + my ( $status, $stderr ) = _run( + @base, + $definition, + '-fsyntax-only', + "$src_dir/maxminddb.c", + ); + is( $status, 0, "$definition compiles without warnings" ) + or diag($stderr); +} + +for my $definition ( + '-DMAXIMUM_DATA_STRUCTURE_VALUES=0', + '-DMAXIMUM_DATA_STRUCTURE_VALUES=-1', + '-DMAXIMUM_DATA_STRUCTURE_VALUES=SIZE_MAX+1', + '-DMAXIMUM_DATA_STRUCTURE_BYTES=0', + '-DMAXIMUM_DATA_STRUCTURE_BYTES=-1', + '-DMAXIMUM_DATA_STRUCTURE_BYTES=UINT64_MAX+1', +) { + my ( $status, $stderr ) = _run( + @base, + $definition, + '-fsyntax-only', + "$src_dir/maxminddb.c", + ); + isnt( $status, 0, "$definition is rejected" ); + like( $stderr, qr/must be between 1 and/, "$definition explains its range" ); +} + +my $tempdir = tempdir( CLEANUP => 1 ); +my $source = "$tempdir/override.c"; +open my $fh, '>', $source or die $!; +print {$fh} <<'EOF' or die $!; +#include +#include + +static int decode(const char *path, size_t expected_count) { + MMDB_s mmdb; + if (MMDB_open(path, MMDB_MODE_MMAP, &mmdb) != MMDB_SUCCESS) { + return 1; + } + int gai_error, mmdb_error; + MMDB_lookup_result_s result = + MMDB_lookup_string(&mmdb, "1.1.1.1", &gai_error, &mmdb_error); + if (gai_error != 0 || mmdb_error != MMDB_SUCCESS || !result.found_entry) { + MMDB_close(&mmdb); + return 2; + } + MMDB_entry_data_list_s *list = NULL; + if (MMDB_get_entry_data_list(&result.entry, &list) != MMDB_SUCCESS) { + MMDB_close(&mmdb); + return 3; + } + size_t count = 0; + for (MMDB_entry_data_list_s *node = list; node; node = node->next) { + count++; + } + MMDB_free_entry_data_list(list); + MMDB_close(&mmdb); + return count == expected_count ? 0 : 4; +} + +static int reject(const char *path) { + MMDB_s mmdb; + if (MMDB_open(path, MMDB_MODE_MMAP, &mmdb) != MMDB_SUCCESS) { + return 6; + } + int gai_error, mmdb_error; + MMDB_lookup_result_s result = + MMDB_lookup_string(&mmdb, "1.1.1.1", &gai_error, &mmdb_error); + MMDB_entry_data_list_s *list = NULL; + int status = MMDB_get_entry_data_list(&result.entry, &list); + MMDB_free_entry_data_list(list); + MMDB_close(&mmdb); + return gai_error == 0 && mmdb_error == MMDB_SUCCESS && + result.found_entry && status == MMDB_DECODER_LIMIT_ERROR + ? 0 + : 7; +} + +int main(int argc, char **argv) { + if (argc == 2) { + return reject(argv[1]); + } + if (argc != 3) { + return 5; + } + int status = decode(argv[1], 65537); + return status == 0 ? decode(argv[2], 34) : status; +} +EOF +close $fh or die $!; + +my $executable = "$tempdir/override"; +my ( $compile_status, $compile_stderr ) = _run( + @base, + '-DMAXIMUM_DATA_STRUCTURE_VALUES=65537', + '-DMAXIMUM_DATA_STRUCTURE_BYTES=2097153', + "$src_dir/maxminddb.c", + "$src_dir/data-pool.c", + $source, + '-lm', + '-o', + $executable, +); +is( $compile_status, 0, 'custom decoder limits compile and link' ) + or diag($compile_stderr); + +if ( $compile_status == 0 ) { + my ( $status, $stderr ) = _run( + $executable, + "$Bin/maxmind-db/test-data/MaxMind-DB-test-decoder-value-limit-over.mmdb", + "$Bin/maxmind-db/test-data/MaxMind-DB-test-decoder-payload-limit-over.mmdb", + ); + is( $status, 0, 'custom decoder limits take effect at runtime' ) + or diag($stderr); +} + +my $large_executable = "$tempdir/large-override"; +( $compile_status, $compile_stderr ) = _run( + @base, + '-DMAXIMUM_DATA_STRUCTURE_BYTES=1<<31', + "$src_dir/maxminddb.c", + "$src_dir/data-pool.c", + $source, + '-lm', + '-o', + $large_executable, +); +is( $compile_status, 0, '2 GiB expression override compiles and links' ) + or diag($compile_stderr); + +if ( $compile_status == 0 ) { + my ( $status, $stderr ) = _run( + $large_executable, + "$Bin/maxmind-db/test-data/MaxMind-DB-test-payload-amplification-dos-worst-case.mmdb", + ); + is( $status, 0, '2 GiB expression remains an enforced runtime limit' ) + or diag($stderr); +} + +done_testing(); + +sub _run { + my @command = @_; + my ( $stdout, $stderr ); + run3( \@command, \undef, \$stdout, \$stderr ); + return ( $? >> 8, $stderr ); +} diff --git a/t/fuzz_mmdb.c b/t/fuzz_mmdb.c index e9289431..4d4c2e14 100644 --- a/t/fuzz_mmdb.c +++ b/t/fuzz_mmdb.c @@ -3,7 +3,7 @@ #include #define kMinInputLength 2 -#define kMaxInputLength 4048 +#define kMaxInputLength (256 * 1024) extern int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size); @@ -13,21 +13,33 @@ int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { MMDB_s mmdb; char filename[256]; - if (size < kMinInputLength || size > kMaxInputLength) + if (size < kMinInputLength || size > kMaxInputLength) { return 0; + } - sprintf(filename, "/tmp/libfuzzer.%d", getpid()); + snprintf(filename, sizeof(filename), "/tmp/libfuzzer.%d", getpid()); fp = fopen(filename, "wb"); - if (!fp) + if (!fp) { return 0; + } fwrite(data, size, sizeof(uint8_t), fp); fclose(fp); status = MMDB_open(filename, MMDB_MODE_MMAP, &mmdb); - if (status == MMDB_SUCCESS) + if (status == MMDB_SUCCESS) { + int gai_error, mmdb_error; + MMDB_lookup_result_s result = + MMDB_lookup_string(&mmdb, "1.1.1.1", &gai_error, &mmdb_error); + if (gai_error == 0 && mmdb_error == MMDB_SUCCESS && + result.found_entry) { + MMDB_entry_data_list_s *entry_data_list = NULL; + MMDB_get_entry_data_list(&result.entry, &entry_data_list); + MMDB_free_entry_data_list(entry_data_list); + } MMDB_close(&mmdb); + } unlink(filename); return 0; diff --git a/t/pointer_dos_t.c b/t/pointer_dos_t.c index 694f135e..c4470f20 100644 --- a/t/pointer_dos_t.c +++ b/t/pointer_dos_t.c @@ -1,157 +1,256 @@ #include "maxminddb_test_helper.h" -// A non-NULL sentinel for the output pointer. On a resource-limit error -// MMDB_get_entry_data_list must set the caller's output to NULL, not leave a -// stale pointer that a caller could later free. Starting from this sentinel and -// asserting the call replaces it with NULL proves that clearing. The node is -// not heap allocated, so it must never be freed. -static MMDB_entry_data_list_s sentinel_node; -#define OUTPUT_SENTINEL (&sentinel_node) - -/* Decoding a crafted fan-out record must be rejected, not run to exhaustion. - * The value-count and payload byte limits both surface as - * MMDB_INVALID_DATA_ERROR from MMDB_get_entry_data_list, and the output list - * must be set to NULL. */ -static void test_fan_out_rejected(const char *fixture, const char *desc) { - char *db_file = test_database_path(fixture); +static MMDB_s *open_fixture(const char *fixture, + const char *desc, + char **const db_file) { + *db_file = test_database_path(fixture); + return open_ok(*db_file, MMDB_MODE_MMAP, desc); +} - MMDB_s mmdb; - int status = MMDB_open(db_file, MMDB_MODE_MMAP, &mmdb); - cmp_ok(status, "==", MMDB_SUCCESS, "opened %s fixture", desc); - if (status != MMDB_SUCCESS) { - diag("MMDB_open failed: %s", MMDB_strerror(status)); - free(db_file); - return; +static void close_fixture(MMDB_s *mmdb, char *db_file) { + if (mmdb) { + MMDB_close(mmdb); + free(mmdb); } + free(db_file); +} - int gai_error, mmdb_error; +static MMDB_lookup_result_s lookup_fixture(MMDB_s *mmdb, + const char *address, + const char *fixture, + const char *desc) { MMDB_lookup_result_s result = - MMDB_lookup_string(&mmdb, "1.1.1.1", &gai_error, &mmdb_error); - cmp_ok(mmdb_error, "==", MMDB_SUCCESS, "%s: lookup succeeded", desc); + lookup_string_ok(mmdb, address, fixture, desc); ok(result.found_entry, "%s: entry found", desc); + return result; +} + +static void test_record_rejected(const char *fixture, + const char *address, + const char *desc) { + char *db_file = NULL; + MMDB_s *mmdb = open_fixture(fixture, desc, &db_file); + if (!mmdb) { + free(db_file); + return; + } + MMDB_lookup_result_s result = + lookup_fixture(mmdb, address, fixture, desc); if (result.found_entry) { - MMDB_entry_data_list_s *entry_data_list = OUTPUT_SENTINEL; - status = MMDB_get_entry_data_list(&result.entry, &entry_data_list); + MMDB_entry_data_list_s *entry_data_list = NULL; + int const status = + MMDB_get_entry_data_list(&result.entry, &entry_data_list); cmp_ok(status, "==", - MMDB_INVALID_DATA_ERROR, - "%s: MMDB_get_entry_data_list returns MMDB_INVALID_DATA_ERROR", + MMDB_DECODER_LIMIT_ERROR, + "%s: full decode returns MMDB_DECODER_LIMIT_ERROR", desc); ok(entry_data_list == NULL, - "%s: output list is set to NULL after the error", + "%s: error leaves the output list set to NULL", desc); - // Free a real partial list, or NULL as a no-op, but never the sentinel. - if (entry_data_list != OUTPUT_SENTINEL) { - MMDB_free_entry_data_list(entry_data_list); - } + MMDB_free_entry_data_list(entry_data_list); } - MMDB_close(&mmdb); - free(db_file); + close_fixture(mmdb, db_file); } -/* A normal record must still decode fully. Its payload is far below the limit, - * so the limits must not reject legitimate data. */ -static void test_normal_record_allowed(void) { - char *db_file = test_database_path("GeoIP2-City-Test.mmdb"); - - MMDB_s mmdb; - int status = MMDB_open(db_file, MMDB_MODE_MMAP, &mmdb); - cmp_ok(status, "==", MMDB_SUCCESS, "opened GeoIP2-City-Test"); - if (status != MMDB_SUCCESS) { - diag("MMDB_open failed: %s", MMDB_strerror(status)); +static void test_record_allowed(const char *fixture, + const char *address, + size_t expected_values, + uint64_t expected_payload, + const char *desc) { + char *db_file = NULL; + MMDB_s *mmdb = open_fixture(fixture, desc, &db_file); + if (!mmdb) { free(db_file); return; } - int gai_error, mmdb_error; MMDB_lookup_result_s result = - MMDB_lookup_string(&mmdb, "81.2.69.142", &gai_error, &mmdb_error); - ok(result.found_entry, "normal record: entry found"); - + lookup_fixture(mmdb, address, fixture, desc); if (result.found_entry) { MMDB_entry_data_list_s *entry_data_list = NULL; - status = MMDB_get_entry_data_list(&result.entry, &entry_data_list); - cmp_ok(status, + int const status = + MMDB_get_entry_data_list(&result.entry, &entry_data_list); + cmp_ok(status, "==", MMDB_SUCCESS, "%s: full decode succeeds", desc); + ok(entry_data_list != NULL, "%s: full decode returns a list", desc); + + size_t values = 0; + uint64_t payload = 0; + for (MMDB_entry_data_list_s *node = entry_data_list; node; + node = node->next) { + values++; + if (node->entry_data.type == MMDB_DATA_TYPE_UTF8_STRING || + node->entry_data.type == MMDB_DATA_TYPE_BYTES) { + payload += node->entry_data.data_size; + } + } + cmp_ok(values, "==", - MMDB_SUCCESS, - "normal record decodes with no false rejection"); - ok(entry_data_list != NULL, "normal record: list returned"); + expected_values, + "%s: decoded the expected number of values", + desc); + cmp_ok(payload, + "==", + expected_payload, + "%s: decoded the expected payload bytes", + desc); MMDB_free_entry_data_list(entry_data_list); } - MMDB_close(&mmdb); - free(db_file); + close_fixture(mmdb, db_file); } -/* The counters are per call. A rejected decode must not leave state that - * changes a later decode on the same reader. */ static void test_per_call_state(void) { - char *dos_file = - test_database_path("MaxMind-DB-test-payload-amplification-dos.mmdb"); - MMDB_s dos; - if (MMDB_open(dos_file, MMDB_MODE_MMAP, &dos) == MMDB_SUCCESS) { - int gai, err; - MMDB_lookup_result_s result = - MMDB_lookup_string(&dos, "1.1.1.1", &gai, &err); - if (result.found_entry) { - MMDB_entry_data_list_s *first = OUTPUT_SENTINEL; - int s1 = MMDB_get_entry_data_list(&result.entry, &first); - cmp_ok(s1, - "==", - MMDB_INVALID_DATA_ERROR, - "per-call: first decode of the attack record is rejected"); - if (first != OUTPUT_SENTINEL) { - MMDB_free_entry_data_list(first); - } + const char *fixture = + "MaxMind-DB-test-payload-amplification-dos.mmdb"; + char *db_file = NULL; + MMDB_s *mmdb = open_fixture(fixture, "per-call state", &db_file); + if (!mmdb) { + free(db_file); + return; + } - MMDB_entry_data_list_s *second = OUTPUT_SENTINEL; - int s2 = MMDB_get_entry_data_list(&result.entry, &second); - cmp_ok(s2, + MMDB_lookup_result_s result = + lookup_fixture(mmdb, "1.1.1.1", fixture, "per-call state"); + if (result.found_entry) { + for (int i = 1; i <= 2; i++) { + MMDB_entry_data_list_s *list = NULL; + int const status = + MMDB_get_entry_data_list(&result.entry, &list); + cmp_ok(status, "==", - MMDB_INVALID_DATA_ERROR, - "per-call: repeating it is still rejected, no leaked count"); - if (second != OUTPUT_SENTINEL) { - MMDB_free_entry_data_list(second); - } + MMDB_DECODER_LIMIT_ERROR, + "per-call: attack decode %d is rejected", + i); + ok(list == NULL, + "per-call: attack decode %d leaves a NULL list", + i); + MMDB_free_entry_data_list(list); + } + + MMDB_entry_data_list_s *metadata = NULL; + int const status = + MMDB_get_metadata_as_entry_data_list(mmdb, &metadata); + cmp_ok(status, + "==", + MMDB_SUCCESS, + "per-call: metadata decode on the same reader succeeds"); + ok(metadata != NULL, + "per-call: metadata decode on the same reader returns a list"); + MMDB_free_entry_data_list(metadata); + } + + close_fixture(mmdb, db_file); +} + +static void test_targeted_lookup_bypasses_full_decode_limit(void) { + const char *fixture = "MaxMind-DB-test-decoder-payload-limit-over.mmdb"; + char *db_file = NULL; + MMDB_s *mmdb = open_fixture(fixture, "targeted oversized lookup", &db_file); + if (!mmdb) { + free(db_file); + return; + } - // Same-reader isolation. After the rejections a bounded decode on - // the same reader must still succeed. Offset 0 is the shared scalar - // the fan-out points at, a single small value well under the - // limits. A reader left with exhausted counters would reject it. - MMDB_entry_s scalar = {.mmdb = &dos, .offset = 0}; - MMDB_entry_data_list_s *bounded = NULL; - int s3 = MMDB_get_entry_data_list(&scalar, &bounded); - cmp_ok(s3, + MMDB_lookup_result_s result = lookup_fixture( + mmdb, "1.1.1.1", fixture, "targeted oversized lookup"); + if (result.found_entry) { + MMDB_entry_data_s entry_data; + int const status = + MMDB_get_value(&result.entry, &entry_data, "0", NULL); + cmp_ok(status, + "==", + MMDB_SUCCESS, + "targeted lookup succeeds without expanding the structure"); + if (status == MMDB_SUCCESS) { + ok(entry_data.has_data, "targeted lookup returns data"); + cmp_ok(entry_data.type, "==", - MMDB_SUCCESS, - "per-call: a bounded decode on the same reader still works"); - ok(bounded != NULL, "per-call: bounded decode returned a list"); - MMDB_free_entry_data_list(bounded); + MMDB_DATA_TYPE_BYTES, + "targeted lookup returns the bytes value"); + cmp_ok(entry_data.data_size, + "==", + 65535, + "targeted lookup returns the complete bytes value"); } - MMDB_close(&dos); } - free(dos_file); + + close_fixture(mmdb, db_file); +} + +static void test_metadata_limit_error(void) { + char *db_file = + test_database_path("MaxMind-DB-test-metadata-payload-limit.mmdb"); + MMDB_s mmdb; + int const status = MMDB_open(db_file, MMDB_MODE_MMAP, &mmdb); + cmp_ok(status, + "==", + MMDB_INVALID_METADATA_ERROR, + "metadata decoder limit is reported as invalid metadata by open"); + if (status == MMDB_SUCCESS) { + MMDB_close(&mmdb); + } + free(db_file); } int main(void) { plan(NO_PLAN); - /* Value-count limit: nested arrays of pointers to shared targets. */ - test_fan_out_rejected("MaxMind-DB-test-pointer-decoder-dos.mmdb", - "value-count fan-out"); - /* Payload byte limit: many pointers to one large bytes value. */ - test_fan_out_rejected("MaxMind-DB-test-payload-amplification-dos.mmdb", - "payload amplification"); - /* Worst case under the value-count limit, caught only by the byte limit. */ - test_fan_out_rejected( + + is(MMDB_strerror(MMDB_DECODER_LIMIT_ERROR), + "The decoded data structure exceeds the configured resource limits", + "decoder limit status has a distinct error message"); + + test_record_rejected("MaxMind-DB-test-pointer-decoder-dos.mmdb", + "1.1.1.1", + "IPv4 value-count fan-out"); + test_record_rejected("MaxMind-DB-test-pointer-decoder-dos-ipv6.mmdb", + "2001:db8::1", + "IPv6 value-count fan-out"); + test_record_rejected("MaxMind-DB-test-payload-amplification-dos.mmdb", + "1.1.1.1", + "bytes payload amplification"); + test_record_rejected( "MaxMind-DB-test-payload-amplification-dos-worst-case.mmdb", - "worst-case payload"); - /* Payload byte limit via a shared UTF-8 string, the type bindings copy. */ - test_fan_out_rejected( + "1.1.1.1", + "worst-case bytes payload amplification"); + test_record_rejected( "MaxMind-DB-test-payload-amplification-dos-string.mmdb", + "1.1.1.1", "string payload amplification"); - test_normal_record_allowed(); + + test_record_allowed("MaxMind-DB-test-decoder-value-limit.mmdb", + "1.1.1.1", + 65536, + 0, + "exact value-count limit"); + test_record_allowed( + "MaxMind-DB-test-decoder-value-limit-pointer-heavy.mmdb", + "1.1.1.1", + 65535, + 0, + "pointer-heavy record under the value-count limit"); + test_record_rejected("MaxMind-DB-test-decoder-value-limit-over.mmdb", + "1.1.1.1", + "one over the value-count limit"); + test_record_allowed("MaxMind-DB-test-decoder-payload-limit.mmdb", + "1.1.1.1", + 34, + 2097152, + "exact payload-byte limit"); + test_record_rejected("MaxMind-DB-test-decoder-payload-limit-over.mmdb", + "1.1.1.1", + "one over the payload-byte limit"); + + test_record_allowed("GeoIP2-City-Test.mmdb", + "81.2.69.142", + 120, + 679, + "normal production-style record"); test_per_call_state(); + test_targeted_lookup_bypasses_full_decode_limit(); + test_metadata_limit_error(); + done_testing(); } From ca84fcbfa4d2a8ea3f17f57599ff01a535e5553d Mon Sep 17 00:00:00 2001 From: Gregory Oschwald Date: Wed, 26 Aug 2026 22:57:15 +0000 Subject: [PATCH 8/8] fixup! Update the test-data submodule for the pointer DoS fixtures --- t/maxmind-db | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/maxmind-db b/t/maxmind-db index adbac1df..d692a4b7 160000 --- a/t/maxmind-db +++ b/t/maxmind-db @@ -1 +1 @@ -Subproject commit adbac1df272f4808e129bf66402eb12cf59012b9 +Subproject commit d692a4b74c68c6e856d0bd85a38ee405b65c816f