Skip to content

Reject a Range first-byte-pos that overflows ssize_t - #2580

Merged
yhirose merged 2 commits into
yhirose:masterfrom
youdie006:fix-range-first-pos-overflow
Sep 11, 2026
Merged

Reject a Range first-byte-pos that overflows ssize_t#2580
yhirose merged 2 commits into
yhirose:masterfrom
youdie006:fix-range-first-pos-overflow

Conversation

@youdie006

Copy link
Copy Markdown
Contributor

The bug

detail::parse_range_header uses -1 as the sentinel for "this range has no
first-byte-pos" and only overwrites it when the parse succeeds
(httplib.h:9049-9056):

      ssize_t first = -1;
      if (!lhs.empty()) {
        ssize_t v;
        auto res = detail::from_chars(lhs.data(), lhs.data() + lhs.size(), v);
        if (res.ec == std::errc{}) { first = v; }
      }

When from_chars returns std::errc::result_out_of_range the assignment is
skipped, first stays -1, and the (first == -1 && last == -1) guard a few
lines down does not fire because last was parsed fine. The range reaches
range_error as (-1, 100), which is exactly what bytes=-100 produces, so
the suffix-range branch at httplib.h:9745-9748 rewrites it to the last 100
bytes and the server answers 206 with real content.

I built a small probe against a 1000-byte body (parse, then range_error, then
the emitted Content-Range):

                                        parse  raw       normalized  Content-Range
bytes=100-199                             1    (100,199) (100,199)   bytes 100-199/1000
bytes=-100                                1    (-1,100)  (900,999)   bytes 900-999/1000
bytes=9223372036854775808-100             1    (-1,100)  (900,999)   bytes 900-999/1000

The overflowing request is served byte-for-byte identically to bytes=-100.

This is a regression, not a missing check

Issue #705 ("[oss-fuzz] issue-26453 Invalid value of Range header") was the same
input class. Back then std::stoll threw std::out_of_range, and the fix in
8f8761ec516db19b22657c872f696c2e667123f8 wrapped the function body so the
throw unwound past every per-range branch:

inline bool parse_range_header(const std::string &s, Ranges &ranges) {
  try {
    ...
          ssize_t first = -1;
          if (!cm.str(1).empty()) {
            first = static_cast<ssize_t>(std::stoll(cm.str(1)));
          }
    ...
  } catch (...) { return false; }
}

false meant RangeNotSatisfiable_416 at httplib.h:14340-14342. That
catch (...) { return false; } is still in the file today, but from_chars
reports through an error code instead of throwing, so nothing reaches it and the
input now falls through into a suffix range.

The odd one out

Your own get_header_value_u64 at httplib.h:3430-3443 already handles this
at its from_chars call site, and its comment says why:

      // Parse at size_t width so an out-of-range Content-Length is reported
      // rather than silently saturated/truncated ...
      if (r.ec == std::errc::result_out_of_range) {
        is_invalid_value = true;
        return (std::numeric_limits<size_t>::max)();
      }

So does parse_port at httplib.h:831-837 (if (r.ec != std::errc{} || ...) return false;).
parse_range_header was the one remaining from_chars call site that dropped
the error. Given #2494 was settled with "I decided to use detail::from_chars
for consistency", this makes that call site consistent too.

The fix

Treat a failed first-byte-pos parse the same way the function already treats an
invalid range, instead of letting -1 survive.

The last-byte-pos side is deliberately not changed. There -1 is a real
value, not a failure: RFC 9110 14.1.2 says a last-byte-pos greater than the
content length means the remainder of the representation, which is what the
comment at httplib.h:9770-9781 describes and what range_error implements.
bytes=0-99999999999999999999 therefore stays accepted as (0, -1).

Tests

Two cases appended to TEST(ParseHeaderValueTest, Range) -- the rejection, and
a pin on the last-byte-pos behaviour that must not change.

Red/green plus mutation in both directions. I could not link the full
test/test.cc here because libcurl headers are not available in this
environment, so I extracted the ParseHeaderValueTest.Range body verbatim into
a standalone TU compiled against the vendored gtest in test/gtest/. httplib.h
was swapped by file copy and its md5 printed on every run, with a touch before
each build:

run httplib.h md5 exit failing assertions
pristine + new tests (RED) 43417965907311f276e55d910a73052d 1 lines 87, 89
with fix (GREEN) 52fee63bbe19df497ca7000a42af3c4f 0 none
MUT-A, guard reverted 43417965907311f276e55d910a73052d 1 lines 87, 89
MUT-B, same guard also applied to last-byte-pos b27e50a3f674824be9a5606b97e463a4 1 lines 98, 99

The two failing sets are disjoint, so the second test genuinely pins the
RFC 9110 remainder behaviour rather than riding along with the first.

Other gates run locally:

  • python3 split.py regenerates cleanly; the guard lands at out/httplib.cc:4405.
  • g++ -std=c++11 -fsyntax-only -Wall -Wextra -Wtype-limits -Wshadow on
    test/include_httplib.cc: 0 warnings, same as the baseline.
  • The harness rebuilt with -fsanitize=address,undefined: passes.
  • cd test && make style_check with clang-format 23.1.0 (the version pinned in
    .github/workflows/test.yaml): "All files are properly formatted."

Notes

test/fuzzing/header_parser_fuzzer.cc:17-19 does call parse_range_header, but
it discards both the return value and the ranges output, so a silent change of
parse result is invisible to it -- which is why this survived the fuzzer.

On 32-bit builds ssize_t is 32-bit and the trigger drops to
bytes=2147483648-2147483748; the repo runs .github/workflows/test-32bit.yml
and has hit 32-bit range issues before (#1795, #2398).


Disclosure: this fix was found and prepared with AI assistance (Claude). The
probe, the red/green runs and every gate listed above were executed, not
inferred; the md5s and outputs above are from those runs.

youdie006 and others added 2 commits September 9, 2026 16:34
parse_range_header initializes first to the -1 sentinel that means "no
first-byte-pos" and only overwrites it when detail::from_chars succeeds.
On std::errc::result_out_of_range the assignment is skipped and -1
survives, so "bytes=9223372036854775808-100" is parsed as the suffix
range "bytes=-100" and range_error serves the last 100 bytes instead of
returning 416.

Before the parser was rewritten onto detail::from_chars, std::stoll threw
std::out_of_range on the same input, the catch arm added in 8f8761e for
issue yhirose#705 returned false, and the request was answered with 416. The
catch arm is still there but from_chars reports through an error code, so
nothing reaches it any more.

get_header_value_u64 and parse_port already reject an out-of-range value
at their from_chars call sites; this was the remaining one that dropped
the error.

The last-byte-pos side is deliberately unchanged: -1 there is the
documented RFC 9110 14.1.2 "remainder of the representation" value, so an
oversized last-byte-pos stays accepted.
Parse the first-byte-pos straight into first, since a failed parse now
returns before first is read, and fold the overflow test into the
existing batch of rejected ranges. Also note on the last-byte-pos side
why an overflow there deliberately keeps -1.

Claude-Session: https://claude.ai/code/session_01JYPWKpbp4a881EdpEf2xSi
@yhirose
yhirose merged commit 8d25b6a into yhirose:master Sep 11, 2026
@yhirose

yhirose commented Sep 11, 2026

Copy link
Copy Markdown
Owner

@youdie006 thank you for your contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants