Skip to content

Commit 5e85d2f

Browse files
oschwaldclaude
andcommitted
Bound the decoded string and bytes payload per lookup
A crafted database can point many data-section pointers at one large string or bytes value. The value count stays low, but a decoder that copies each target materializes the value once per pointer, so a file of a few hundred kilobytes can force gigabytes. The decoder now charges each string and bytes value its length as it is decoded and rejects a single lookup that materializes more than 2 MiB, with an InvalidDatabaseException. Because the charge is made every time a value is decoded, re-decoding a shared pointer target recharges it, so the amplification is bounded. Charging before allocation also bounds an oversized variable-length integer, whose declared size the decoder would otherwise copy before range-checking. Map keys and inline scalars in a pointed-to container decode through the same path, so they are charged too. Metadata is decoded through the same path, so the bound covers the database-open path as well. The counter is a per-lookup field on the per-lookup decoder, so concurrent reads stay thread-safe and the common path stays cheap: small fixed-width scalars are not charged. This matches the 2 MiB payload limit used by libmaxminddb and the Go reader. See GHSA-hj94-g986-h9r7. The test-data submodule is bumped to the MaxMind-DB commit that adds the payload amplification and boundary fixtures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 872faee commit 5e85d2f

5 files changed

Lines changed: 135 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@ CHANGELOG
1717
exceeds it, along with pointer cycles and over-deep data, with an
1818
`InvalidDatabaseException`. It also rejects a map or array whose declared size
1919
is larger than the remaining data before allocating for it, so a crafted size
20-
cannot force a large list or map allocation from a small file. This matches
21-
the reader resource limits now recommended by the MaxMind DB specification.
22-
See GHSA-hj94-g986-h9r7.
20+
cannot force a large list or map allocation from a small file. A related shape
21+
points many pointers at one large string or bytes value; the decoder now
22+
bounds the total string and bytes payload it materializes for a single record,
23+
charging each value as it is decoded, so re-decoding a shared target cannot
24+
amplify a small file into gigabytes. This matches the reader resource limits
25+
now recommended by the MaxMind DB specification. See GHSA-hj94-g986-h9r7.
2326

2427
4.1.0 (2026-05-12)
2528
------------------

‎src/main/java/com/maxmind/db/Decoder.java‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import java.lang.reflect.ParameterizedType;
1010
import java.math.BigInteger;
1111
import java.net.InetAddress;
12-
import java.nio.charset.CharacterCodingException;
1312
import java.nio.charset.Charset;
1413
import java.nio.charset.CharsetDecoder;
1514
import java.nio.charset.StandardCharsets;
@@ -38,13 +37,19 @@ class Decoder {
3837
// Per-lookup decode limits recommended by the MaxMind DB specification. The
3938
// depth limit stops pointer cycles and over-deep data before the stack
4039
// overflows. The value limit stops a pointer fan-out, where nested pointers
41-
// to shared targets would otherwise cost 2**depth decode operations. A
42-
// Decoder serves a single lookup on a single thread, so these need no
43-
// synchronization. The largest real records decode a few hundred values.
40+
// to shared targets would otherwise cost 2**depth decode operations. The
41+
// payload limit stops a payload amplification, where many pointers to one
42+
// large string or bytes value would otherwise materialize N times its size:
43+
// each string or bytes value is charged its length every time it is decoded,
44+
// so re-decoding a shared target recharges it. A Decoder serves a single
45+
// lookup on a single thread, so these need no synchronization. The largest
46+
// real records decode a few hundred values and a few kilobytes of payload.
4447
private static final int MAX_DEPTH = 512;
4548
private static final int MAX_VALUES = 1 << 16;
49+
private static final long MAX_PAYLOAD_BYTES = 1 << 21;
4650
private int depth;
4751
private int valuesRemaining = MAX_VALUES;
52+
private long payloadRemaining = MAX_PAYLOAD_BYTES;
4853

4954
private final long pointerBase;
5055

@@ -116,6 +121,7 @@ <T> T decode(long offset, Class<T> cls) throws IOException {
116121
}
117122

118123
this.valuesRemaining = MAX_VALUES;
124+
this.payloadRemaining = MAX_PAYLOAD_BYTES;
119125
this.depth = 0;
120126
this.buffer.position(offset);
121127
return cls.cast(decode(cls, null).value());
@@ -269,6 +275,24 @@ private void checkContainerSize(long valueCount) throws InvalidDatabaseException
269275
}
270276
}
271277

278+
// Charge a string or bytes payload against the per-lookup budget before it
279+
// is materialized. A payload amplification points many pointers at one large
280+
// value; because the budget is charged every time the value is decoded, and
281+
// a shared pointer target is re-decoded per referencing pointer, N pointers
282+
// to an S-byte value are charged N*S and rejected once the total exceeds the
283+
// limit. Charging before allocation also bounds an oversized variable-length
284+
// integer, whose declared size the decoder would otherwise copy before
285+
// range-checking. The comparison is against the remaining budget so it
286+
// cannot overflow. The limit is inclusive: a total exactly at the limit is
287+
// allowed.
288+
private void chargePayload(long length) throws InvalidDatabaseException {
289+
if (length > this.payloadRemaining) {
290+
throw new InvalidDatabaseException(
291+
"The MaxMind DB file's data section exceeds the maximum payload size");
292+
}
293+
this.payloadRemaining -= length;
294+
}
295+
272296
private <T> Object decodeByType(
273297
Type type,
274298
int size,
@@ -445,7 +469,8 @@ private static Object coerceFromBigInteger(BigInteger value, Class<?> target) {
445469
return value;
446470
}
447471

448-
private String decodeString(long size) throws CharacterCodingException {
472+
private String decodeString(long size) throws IOException {
473+
this.chargePayload(size);
449474
var oldLimit = buffer.limit();
450475
buffer.limit(buffer.position() + size);
451476
var s = buffer.decode(utfDecoder);
@@ -493,7 +518,7 @@ static int decodeInteger(Buffer buffer, int base, int size) {
493518
return integer;
494519
}
495520

496-
private BigInteger decodeBigInteger(int size) {
521+
private BigInteger decodeBigInteger(int size) throws InvalidDatabaseException {
497522
var bytes = this.getByteArray(size);
498523
return new BigInteger(1, bytes);
499524
}
@@ -1269,7 +1294,8 @@ private CtrlData getCtrlData(long offset)
12691294
return new CtrlData(type, ctrlByte, offset, size);
12701295
}
12711296

1272-
private byte[] getByteArray(int length) {
1297+
private byte[] getByteArray(int length) throws InvalidDatabaseException {
1298+
this.chargePayload(length);
12731299
return Decoder.getByteArray(this.buffer, length);
12741300
}
12751301

‎src/test/java/com/maxmind/db/DecoderTest.java‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,57 @@ public void testAcyclicPointerToPointerThrows() {
571571
assertThat(ex.getMessage(), containsString("pointer to a pointer"));
572572
}
573573

574+
// Writes a large scalar (bytes or string) at offset 0, followed by an array
575+
// of pointerCount one-byte pointers that all target it. Every pointer
576+
// re-decodes the shared value, so the decoder is charged its size once per
577+
// pointer even though the value count stays tiny.
578+
private static byte[] sharedScalarFanOut(int scalarType, int scalarSize, int pointerCount) {
579+
var out = new ByteArrayOutputStream();
580+
// Scalar header: size code 30 covers 285..65820 bytes.
581+
out.write((scalarType << 5) | 30);
582+
var encoded = scalarSize - 285;
583+
out.write((encoded >> 8) & 0xFF);
584+
out.write(encoded & 0xFF);
585+
for (var i = 0; i < scalarSize; i++) {
586+
out.write(0);
587+
}
588+
// Array header (extended type 11), size code 29 covers 29..284 entries.
589+
out.write(29);
590+
out.write(0x04);
591+
out.write(pointerCount - 29);
592+
for (var i = 0; i < pointerCount; i++) {
593+
writePointer1(out, 0);
594+
}
595+
return out.toByteArray();
596+
}
597+
598+
@Test
599+
public void testPayloadAmplificationIsBounded() throws IOException {
600+
// 33 pointers to a 65,536-byte value would materialize just over 2 MiB,
601+
// one byte value at a time, while the value count stays tiny. Only the
602+
// payload byte bound rejects this.
603+
var scalarSize = 1 << 16;
604+
var data = sharedScalarFanOut(4, scalarSize, 33);
605+
var top = 3 + scalarSize;
606+
var decoder = new Decoder(NoCache.getInstance(), SingleBuffer.wrap(data), 0);
607+
var ex = assertThrows(
608+
InvalidDatabaseException.class,
609+
() -> decoder.decode(top, Object.class));
610+
assertThat(ex.getMessage(), containsString("exceeds the maximum payload size"));
611+
}
612+
613+
@Test
614+
public void testPayloadAtLimitIsAccepted() throws IOException {
615+
// 32 pointers to a 65,536-byte value materialize exactly 2 MiB, at the
616+
// inclusive limit, so the record must still decode.
617+
var scalarSize = 1 << 16;
618+
var data = sharedScalarFanOut(4, scalarSize, 32);
619+
var top = 3 + scalarSize;
620+
var decoder = new Decoder(NoCache.getInstance(), SingleBuffer.wrap(data), 0);
621+
var result = (List<?>) decoder.decode(top, Object.class);
622+
assertEquals(32, result.size());
623+
}
624+
574625
public static final class EmptyModel {
575626
@MaxMindDbConstructor
576627
public EmptyModel() {

‎src/test/java/com/maxmind/db/ReaderTest.java‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2203,6 +2203,50 @@ public void testNullToPrimitiveErrorMessage(int chunkSize) throws IOException {
22032203
}
22042204
}
22052205

2206+
// A crafted database can point many data-section pointers at one large
2207+
// string or bytes value. The value count stays low, but a decoder that
2208+
// copies each pointer's target materializes N times its size. Decoding must
2209+
// reject each of these before it exhausts memory.
2210+
@Test
2211+
public void testPayloadAmplificationIsRejected() throws IOException {
2212+
var fixtures = new String[] {
2213+
"MaxMind-DB-test-payload-amplification-dos.mmdb",
2214+
"MaxMind-DB-test-payload-amplification-dos-string.mmdb",
2215+
"MaxMind-DB-test-payload-amplification-dos-worst-case.mmdb",
2216+
"MaxMind-DB-test-decoder-payload-limit-over.mmdb",
2217+
};
2218+
var ip = InetAddress.getByName("1.1.1.1");
2219+
for (var fixture : fixtures) {
2220+
try (var reader = new Reader(getFile(fixture))) {
2221+
var ex = assertThrows(
2222+
InvalidDatabaseException.class,
2223+
() -> reader.get(ip, Object.class),
2224+
fixture + " should be rejected");
2225+
assertThat(ex.getMessage(), containsString("exceeds the maximum payload size"));
2226+
}
2227+
}
2228+
}
2229+
2230+
// A payload total that lands exactly on the 2 MiB limit is valid and must
2231+
// still decode, so the bound does not reject legitimate data.
2232+
@Test
2233+
public void testPayloadAtLimitDecodes() throws IOException {
2234+
try (var reader = new Reader(getFile("MaxMind-DB-test-decoder-payload-limit.mmdb"))) {
2235+
var value = reader.get(InetAddress.getByName("1.1.1.1"), Object.class);
2236+
assertNotNull(value);
2237+
}
2238+
}
2239+
2240+
// Metadata is decoded while the database is opened, so the payload bound must
2241+
// cover that path too. This fixture amplifies a string through the metadata.
2242+
@Test
2243+
public void testMetadataPayloadAmplificationIsRejected() {
2244+
var ex = assertThrows(
2245+
InvalidDatabaseException.class,
2246+
() -> new Reader(getFile("MaxMind-DB-test-metadata-payload-limit.mmdb")));
2247+
assertThat(ex.getMessage(), containsString("exceeds the maximum payload size"));
2248+
}
2249+
22062250
static File getFile(String name) {
22072251
return new File(ReaderTest.class.getResource("/maxmind-db/test-data/" + name).getFile());
22082252
}

0 commit comments

Comments
 (0)