-
Notifications
You must be signed in to change notification settings - Fork 83
Bound decoder work to prevent a pointer fan-out DoS (STF-1570) #281
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
f15a433
753d614
9b474f5
cef34e7
91cf91b
5658655
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 | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -30,6 +30,29 @@ class Decoder | |||||||||||||
| */ | ||||||||||||||
| private $switchByteOrder; | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Remaining decoded-value allowance for the current decode() call. | ||||||||||||||
| * | ||||||||||||||
| * @var int | ||||||||||||||
| */ | ||||||||||||||
| private $budget = 0; | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Remaining string and bytes payload allowance for the current decode() | ||||||||||||||
| * call. | ||||||||||||||
| * | ||||||||||||||
| * @var int | ||||||||||||||
| */ | ||||||||||||||
| private $byteBudget = 0; | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Stream position after the last read of the current decode() call, or -1 | ||||||||||||||
| * when unknown. | ||||||||||||||
| * | ||||||||||||||
| * @var int | ||||||||||||||
| */ | ||||||||||||||
| private $position = -1; | ||||||||||||||
|
|
||||||||||||||
| private const _EXTENDED = 0; | ||||||||||||||
| private const _POINTER = 1; | ||||||||||||||
| private const _UTF8_STRING = 2; | ||||||||||||||
|
|
@@ -47,6 +70,33 @@ class Decoder | |||||||||||||
| private const _BOOLEAN = 14; | ||||||||||||||
| private const _FLOAT = 15; | ||||||||||||||
|
|
||||||||||||||
| // Per-lookup decode limits recommended by the MaxMind DB specification. The | ||||||||||||||
| // depth limit stops pointer cycles and over-deep data. The value limit | ||||||||||||||
| // stops a pointer fan-out, where nested pointers to shared targets would | ||||||||||||||
| // otherwise cost 2**depth decode operations. The count follows the | ||||||||||||||
| // specification's flat rule: the root is one value, each array and map | ||||||||||||||
| // charges its declared children (a map entry costs two, key and value), | ||||||||||||||
| // and a pointer costs nothing beyond the value it resolves to, which its | ||||||||||||||
| // container already charged. The largest real records decode a few hundred | ||||||||||||||
| // values, so the limit leaves a wide margin. | ||||||||||||||
| private const MAX_DEPTH = 512; | ||||||||||||||
| private const MAX_VALUES = 1 << 16; | ||||||||||||||
|
|
||||||||||||||
| // The value limit alone does not stop payload amplification: an array of | ||||||||||||||
| // pointers to one large string or bytes value keeps the value count low | ||||||||||||||
| // while forcing the reader to copy the target once per pointer. This | ||||||||||||||
| // second, independent limit bounds the total string and bytes payload | ||||||||||||||
| // copied for one lookup to 2 MiB, matching libmaxminddb and the Go reader. | ||||||||||||||
|
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. Citing sibling implementations rather than the spec. The libmaxminddb half is true today: The spec's Reader Resource Limits section is the stable reference, and libmaxminddb's own comments cite it rather than us. The CHANGELOG already names it. 🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
| // No real record approaches it, and re-decoding a shared target charges its | ||||||||||||||
| // payload again, so the fan-out is bounded. | ||||||||||||||
| private const MAX_PAYLOAD_BYTES = 1 << 21; | ||||||||||||||
|
|
||||||||||||||
| // A fixed-width scalar (a float, double, or integer) never needs more than | ||||||||||||||
| // 16 bytes (the width of a uint128). A larger declared size is either | ||||||||||||||
| // corrupt or an attempt to amplify the read of an oversized variable-length | ||||||||||||||
| // integer, so it is rejected before the bytes are materialized. | ||||||||||||||
| private const MAX_SCALAR_BYTES = 16; | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * @param resource $fileStream | ||||||||||||||
| */ | ||||||||||||||
|
|
@@ -68,7 +118,31 @@ public function __construct( | |||||||||||||
| */ | ||||||||||||||
| public function decode(int $offset): array | ||||||||||||||
| { | ||||||||||||||
| $ctrlByte = \ord(Util::read($this->fileStream, $offset, 1)); | ||||||||||||||
| // Bound the work per lookup so a crafted database cannot exhaust CPU or | ||||||||||||||
| // memory. $budget counts decoded values and stops the pointer fan-out; | ||||||||||||||
| // $byteBudget counts copied string and bytes payload and stops payload | ||||||||||||||
| // amplification. Both live on the decoder and are reset here, so every | ||||||||||||||
| // call starts with the full allowance. Passing them by reference | ||||||||||||||
| // through each recursive call instead costs a few percent per lookup. | ||||||||||||||
| // No other lookup can observe them mid-decode: PHP runs one request | ||||||||||||||
| // per thread, and the decoder never yields while it decodes. The root | ||||||||||||||
| // value is charged here; containers charge their children. | ||||||||||||||
| $this->budget = self::MAX_VALUES - 1; | ||||||||||||||
| $this->byteBudget = self::MAX_PAYLOAD_BYTES; | ||||||||||||||
|
|
||||||||||||||
| // Other readers of the stream, such as the search tree walk, may have | ||||||||||||||
| // moved it since the last call. | ||||||||||||||
| $this->position = -1; | ||||||||||||||
|
|
||||||||||||||
| return $this->decodeWithBudget($offset, 0); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * @return array<mixed> | ||||||||||||||
| */ | ||||||||||||||
| private function decodeWithBudget(int $offset, int $depth): array | ||||||||||||||
| { | ||||||||||||||
| $ctrlByte = \ord($this->read($offset, 1)); | ||||||||||||||
| ++$offset; | ||||||||||||||
|
|
||||||||||||||
| $type = $ctrlByte >> 5; | ||||||||||||||
|
|
@@ -84,13 +158,22 @@ public function decode(int $offset): array | |||||||||||||
| return [$pointer]; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| [$result] = $this->decode($pointer); | ||||||||||||||
| if ($depth >= self::MAX_DEPTH) { | ||||||||||||||
| throw new InvalidDatabaseException( | ||||||||||||||
| "The MaxMind DB file's data section exceeds the maximum depth" | ||||||||||||||
|
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 limit messages carry no numbers, so an operator cannot size the problem. This one, and the payload and value-count messages, say what was exceeded but not the limit, not the amount asked for, and not the offset. The limits are Include the limit, the amount and the offset in all four messages. 🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // The value at the pointer's position was charged by its containing | ||||||||||||||
| // array or map, so the target costs nothing more. Only the depth | ||||||||||||||
| // grows. | ||||||||||||||
| [$result] = $this->decodeWithBudget($pointer, $depth + 1); | ||||||||||||||
|
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. Pointer-to-pointer is not rejected, so a 198 KB file still costs 9.8 s per lookup. The spec says it is illegal ( That lets an attacker multiply the maximum array width by the maximum chain length: an array of 65,535 pointers, each aimed at one shared 508-long pointer chain. I built it and ran it against this branch:
About 98,000x, and it stays inside all three new limits. This is bounded and linear rather than exponential, so the PR meets its stated goal, but 9.8 s of CPU from a 198 KB file is still usable as a denial of service. Rejecting pointer-to-pointer removes the chain multiplier and costs nothing for valid data. I instrumented the decoder and counted pointer-to-pointer follows across 57,600 lookups on all 40 valid test fixtures: zero. The depth tests added in this PR use pointer-to-array ( Also worth noting that the pure PHP reader and the bundled extension currently disagree on this input. 🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
|
|
||||||||||||||
| return [$result, $offset]; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| if ($type === self::_EXTENDED) { | ||||||||||||||
| $nextByte = \ord(Util::read($this->fileStream, $offset, 1)); | ||||||||||||||
| $nextByte = \ord($this->read($offset, 1)); | ||||||||||||||
|
|
||||||||||||||
| $type = $nextByte + 7; | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -108,35 +191,62 @@ public function decode(int $offset): array | |||||||||||||
|
|
||||||||||||||
| [$size, $offset] = $this->sizeFromCtrlByte($ctrlByte, $offset); | ||||||||||||||
|
|
||||||||||||||
| return $this->decodeByType($type, $offset, $size); | ||||||||||||||
| return $this->decodeByType($type, $offset, $size, $depth); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * @param int<0, max> $size | ||||||||||||||
| * | ||||||||||||||
| * @return array{0:mixed, 1:int} | ||||||||||||||
| */ | ||||||||||||||
| private function decodeByType(int $type, int $offset, int $size): array | ||||||||||||||
| private function decodeByType(int $type, int $offset, int $size, int $depth): array | ||||||||||||||
| { | ||||||||||||||
| switch ($type) { | ||||||||||||||
| case self::_MAP: | ||||||||||||||
| return $this->decodeMap($size, $offset); | ||||||||||||||
| return $this->decodeMap($size, $offset, $depth); | ||||||||||||||
|
|
||||||||||||||
| case self::_ARRAY: | ||||||||||||||
| return $this->decodeArray($size, $offset); | ||||||||||||||
| return $this->decodeArray($size, $offset, $depth); | ||||||||||||||
|
|
||||||||||||||
| case self::_BOOLEAN: | ||||||||||||||
| return [$this->decodeBoolean($size), $offset]; | ||||||||||||||
|
|
||||||||||||||
| case self::_BYTES: | ||||||||||||||
| case self::_UTF8_STRING: | ||||||||||||||
| // A string or bytes value is copied into a native string, so N | ||||||||||||||
| // pointers to one large value copy N times its length. Charge | ||||||||||||||
| // the payload against the byte budget wherever it is decoded, | ||||||||||||||
| // including inline inside a pointed-to container, so a shared | ||||||||||||||
| // target recharges each time it is followed. Compare before | ||||||||||||||
| // subtracting so an oversized declared size cannot drive the | ||||||||||||||
| // budget negative. A total exactly at the limit is allowed. | ||||||||||||||
| if ($size > $this->byteBudget) { | ||||||||||||||
| throw new InvalidDatabaseException( | ||||||||||||||
| "The MaxMind DB file's data section exceeds the maximum payload size" | ||||||||||||||
|
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 metadata breach reports "data section". This message reaches the caller from The failure is in the metadata and it names the wrong section. The C extension gets this right and reports
🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| $this->byteBudget -= $size; | ||||||||||||||
|
|
||||||||||||||
| return [$this->read($offset, $size), $offset + $size]; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // The remaining valid types are fixed-width scalars, none wider than a | ||||||||||||||
| // uint128. A few other control bytes also reach here: the container | ||||||||||||||
| // (12) and end-marker (13) types, and any unknown extended type. The | ||||||||||||||
| // size guard below rejects one that declares an oversized size, and the | ||||||||||||||
| // default case at the end of the switch rejects the rest. Reject an | ||||||||||||||
| // oversized declared size before materializing the bytes, so an | ||||||||||||||
| // oversized variable-length integer cannot amplify the read. | ||||||||||||||
| if ($size > self::MAX_SCALAR_BYTES) { | ||||||||||||||
| throw new InvalidDatabaseException( | ||||||||||||||
| "The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)" | ||||||||||||||
|
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 reuses the generic corrupt-data message for a distinct condition. The string is byte-identical to the one in The six-line comment above explains exactly what this branch means. None of that reaches the operator. Say the type, the declared size, and the offset.
🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| $newOffset = $offset + $size; | ||||||||||||||
| $bytes = Util::read($this->fileStream, $offset, $size); | ||||||||||||||
| $bytes = $this->read($offset, $size); | ||||||||||||||
|
|
||||||||||||||
| switch ($type) { | ||||||||||||||
| case self::_BYTES: | ||||||||||||||
| case self::_UTF8_STRING: | ||||||||||||||
| return [$bytes, $newOffset]; | ||||||||||||||
|
|
||||||||||||||
| case self::_DOUBLE: | ||||||||||||||
| $this->verifySize(8, $size); | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -163,6 +273,39 @@ private function decodeByType(int $type, int $offset, int $size): array | |||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Reads from the stream, seeking only when the read does not continue where | ||||||||||||||
| * the previous one ended. Most values in a record are laid out in order, and | ||||||||||||||
| * fseek() discards PHP's read buffer, so seeking before every read turned | ||||||||||||||
| * each small read into a system call. Skipping the seek makes a City lookup | ||||||||||||||
| * about 40% faster. | ||||||||||||||
|
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 hard percentage in a permanent comment will rot. There is no benchmark in the repo, no PHP version and no workload definition beyond "a City lookup", so nothing will re-verify this. Same at While I am here: the mechanism as stated is a little strong. 🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
| * | ||||||||||||||
| * @param int<0, max> $numberOfBytes | ||||||||||||||
| */ | ||||||||||||||
| private function read(int $offset, int $numberOfBytes): string | ||||||||||||||
|
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 position cache turns re-entrancy into a silently wrong record.
The comment at
A wrong record, no exception, nothing to grep for. Reachability is low, but the failure mode moved from loud to silent, and that is the part worth fixing. A guard inside 🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
| { | ||||||||||||||
| if ($numberOfBytes === 0) { | ||||||||||||||
| return ''; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| $stream = $this->fileStream; | ||||||||||||||
| if ($offset !== $this->position && fseek($stream, $offset) !== 0) { | ||||||||||||||
| $this->position = -1; | ||||||||||||||
|
|
||||||||||||||
| throw new InvalidDatabaseException('The MaxMind DB file contains bad data'); | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| $value = fread($stream, $numberOfBytes); | ||||||||||||||
| if ($value === false || \strlen($value) !== $numberOfBytes) { | ||||||||||||||
| $this->position = -1; | ||||||||||||||
|
|
||||||||||||||
| throw new InvalidDatabaseException('The MaxMind DB file contains bad data'); | ||||||||||||||
| } | ||||||||||||||
| $this->position = $offset + $numberOfBytes; | ||||||||||||||
|
|
||||||||||||||
| return $value; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| private function verifySize(int $expected, int $actual): void | ||||||||||||||
| { | ||||||||||||||
| if ($expected !== $actual) { | ||||||||||||||
|
|
@@ -172,15 +315,48 @@ private function verifySize(int $expected, int $actual): void | |||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * Applies the per-lookup limits when entering a container. The depth limit | ||||||||||||||
| * stops cycles and over-deep data (checked here and at pointer follows, | ||||||||||||||
| * the only places depth grows). The value budget is charged per declared | ||||||||||||||
|
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 checks the container's own depth. Growth happens at the three The parenthetical also omits the consequence that matters: the depth guard never runs on the scalar path, because 🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
| * element up front, so an oversized declared size is rejected before the | ||||||||||||||
| * loop reads anything. A pointer element costs nothing more when it is | ||||||||||||||
| * followed: its slot is charged here, and a container it resolves to | ||||||||||||||
| * charges its own children each time it is decoded, which is what bounds | ||||||||||||||
| * a fan-out through shared targets. | ||||||||||||||
| */ | ||||||||||||||
| private function enterContainer( | ||||||||||||||
| int $size, | ||||||||||||||
| int $depth, | ||||||||||||||
| int $valuesPerEntry = 1 | ||||||||||||||
| ): void { | ||||||||||||||
| if ($depth >= self::MAX_DEPTH) { | ||||||||||||||
|
oschwald marked this conversation as resolved.
|
||||||||||||||
| throw new InvalidDatabaseException( | ||||||||||||||
| "The MaxMind DB file's data section exceeds the maximum depth" | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| // Compare with a division rather than multiplying the declared size, so | ||||||||||||||
| // an oversized declaration cannot overflow the integer on 32-bit builds | ||||||||||||||
| // before the budget check runs. | ||||||||||||||
| if ($size > intdiv($this->budget, $valuesPerEntry)) { | ||||||||||||||
|
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 overflow rationale in the comment above is not true. Two independent reasons:
The two forms are equivalent for positive integers, so 🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
| throw new InvalidDatabaseException( | ||||||||||||||
| "The MaxMind DB file's data section exceeds the maximum number of values" | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| $this->budget -= $size * $valuesPerEntry; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| /** | ||||||||||||||
| * @return array{0:array<mixed>, 1:int} | ||||||||||||||
| */ | ||||||||||||||
| private function decodeArray(int $size, int $offset): array | ||||||||||||||
| private function decodeArray(int $size, int $offset, int $depth): array | ||||||||||||||
| { | ||||||||||||||
| $this->enterContainer($size, $depth); | ||||||||||||||
|
|
||||||||||||||
| $array = []; | ||||||||||||||
|
|
||||||||||||||
| for ($i = 0; $i < $size; ++$i) { | ||||||||||||||
| [$value, $offset] = $this->decode($offset); | ||||||||||||||
| [$value, $offset] = $this->decodeWithBudget($offset, $depth + 1); | ||||||||||||||
| $array[] = $value; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -258,13 +434,16 @@ private function decodeInt32(string $bytes, int $size): int | |||||||||||||
| /** | ||||||||||||||
| * @return array{0:array<string, mixed>, 1:int} | ||||||||||||||
| */ | ||||||||||||||
| private function decodeMap(int $size, int $offset): array | ||||||||||||||
| private function decodeMap(int $size, int $offset, int $depth): array | ||||||||||||||
| { | ||||||||||||||
| // A map entry decodes a key and a value, so it costs two values. | ||||||||||||||
|
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. Third statement of the same fact. The value-charging rule is already spelled out at The strongest comment in the diff, for contrast, is at 🤖 Comment by Claude (Claude Code) on behalf of Will. |
||||||||||||||
| $this->enterContainer($size, $depth, 2); | ||||||||||||||
|
|
||||||||||||||
| $map = []; | ||||||||||||||
|
|
||||||||||||||
| for ($i = 0; $i < $size; ++$i) { | ||||||||||||||
| [$key, $offset] = $this->decode($offset); | ||||||||||||||
| [$value, $offset] = $this->decode($offset); | ||||||||||||||
| [$key, $offset] = $this->decodeWithBudget($offset, $depth + 1); | ||||||||||||||
| [$value, $offset] = $this->decodeWithBudget($offset, $depth + 1); | ||||||||||||||
| $map[$key] = $value; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
|
|
@@ -278,7 +457,7 @@ private function decodePointer(int $ctrlByte, int $offset): array | |||||||||||||
| { | ||||||||||||||
| $pointerSize = (($ctrlByte >> 3) & 0x3) + 1; | ||||||||||||||
|
|
||||||||||||||
| $buffer = Util::read($this->fileStream, $offset, $pointerSize); | ||||||||||||||
| $buffer = $this->read($offset, $pointerSize); | ||||||||||||||
| $offset += $pointerSize; | ||||||||||||||
|
|
||||||||||||||
| switch ($pointerSize) { | ||||||||||||||
|
|
@@ -404,7 +583,7 @@ private function sizeFromCtrlByte(int $ctrlByte, int $offset): array | |||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| $bytesToRead = $size - 28; | ||||||||||||||
| $bytes = Util::read($this->fileStream, $offset, $bytesToRead); | ||||||||||||||
| $bytes = $this->read($offset, $bytesToRead); | ||||||||||||||
|
|
||||||||||||||
| if ($size === 29) { | ||||||||||||||
| $size = 29 + \ord($bytes); | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,10 +18,8 @@ public static function read($stream, int $offset, int $numberOfBytes): string | |
| if (fseek($stream, $offset) === 0) { | ||
| $value = fread($stream, $numberOfBytes); | ||
|
|
||
| // We check that the number of bytes read is equal to the number | ||
| // asked for. We use ftell as getting the length of $value is | ||
| // much slower. | ||
| if ($value !== false && ftell($stream) - $offset === $numberOfBytes) { | ||
| // Check that the number of bytes read is the number asked for. | ||
| if ($value !== false && \strlen($value) === $numberOfBytes) { | ||
|
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 change also removed an accidental re-entrancy detector. The old Worth a comment here noting that nothing now detects a moved handle. 🤖 Comment by Claude (Claude Code) on behalf of Will. |
||
| return $value; | ||
| } | ||
| } | ||
|
|
||
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.
The 40% figure is conservative, if you want to claim more.
I benchmarked both branches on GeoIP2-City-Test, 40,000 lookups after a 2,000-lookup warmup, three alternating runs:
mainAbout 51% faster on this machine, against the 39% in the PR description's table. Different hardware, so the number is not directly comparable, but the claim holds comfortably.
🤖 Comment by Claude (Claude Code) on behalf of Will.