Skip to content

Bound every registry read to its buffer rather than a null terminator - #2732

Merged
stephenberry merged 5 commits into
mainfrom
bound-registry-reads
Jul 31, 2026
Merged

Bound every registry read to its buffer rather than a null terminator#2732
stephenberry merged 5 commits into
mainfrom
bound-registry-reads

Conversation

@stephenberry

@stephenberry stephenberry commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Supersedes #2729, whose diagnosis and REPE test this builds on (thanks @uwezkhan).

The registry parses bytes it does not own — a wire span given to registry::call(std::span<const char>, std::string&), a raw pointer and length given to repe::plugin_call across an FFI boundary, a std::string_view given to the JSON RPC call. None of them carry the '\0' a null_terminated read relies on when it drops its end checks, and no caller can reach the option to turn it off. Three reads over the caller's buffer ran past its end, each on a well formed request:

entry point input ASAN
registry::call(span, string&) body 7, buffer ends there READ of size 1, 0 bytes after a 55 byte region (repe.hpp read_params)
registry<opts{}, JSONRPC>::call(sv) [7 READ of size 1, 0 bytes after a 52 byte region (registry.hpp:471, batch read_json)
registry<opts{}, JSONRPC>::call(sv) truncated object READ of size 1, 0 bytes after a 52 byte region (registry.hpp:496, validate_json)

#2729 bounds the first. This bounds all three by making it a property of the registry rather than of one call site: the registry reads with null_terminated off regardless of the user's options. Two of those reads went through glz::read_json / glz::validate_json, which hardcode default options, so they move onto the registry's options. get_as_json / get_sv_json took an Opts parameter and then discarded it in favor of defaults; fixed here too.

Because that bound is what keeps the registry inside the caller's buffer, an options struct that fixes null_terminated (static constexpr bool null_terminated = true, the shape opts_csv uses and the "create your own options struct" note invites) must not quietly opt out of it. The requires-expression that turns the option off simply fails for such a type, so a static_assert turns that into a compile error rather than a silent out of bounds read.

Two defects the change surfaced

A completed read could report zero bytes consumed. repe::read_params returned bytes consumed and all six callers read 0 as failure. Without a terminator, a variant alternative that resolves at the end of the buffer rewinds its iterator, so the read finishes with the value applied and nothing consumed. The registry took that for an error and returned without writing anything — a non-notify request got no response at all:

struct api { std::variant<std::string, amount> measure{}; };  // amount deduced by shape
// REPE body "42.5", buffer ends there  ->  response buffer empty, value applied

read_params returns bool now; the count had no other use. This bites any fix that turns null_terminated off, including #2729 as written.

Input holding no value read as a completed value. The reader runs out of input looking for a value and reports end_reached, which at the top level otherwise means "the value ended with the buffer", so glz::read cleared it and returned success with the destination untouched — glz::read<{.null_terminated = false}>(v, " ") does this on main, and so does glz::read<{.null_terminated = false, .comments = true}>(v, "// hi\n"). Through the registry that would answer a malformed request with a success response, and would have turned a REST PUT with a blank body from 400 into 204. A buffer holding nothing but whitespace and comments is now no_read_input, the code an empty buffer already reports.

finalize_top_level_read draws that line, in one place for glz::read and both read_params overloads. It answers "did this hold a value?" by replaying skip_ws over the consumed span rather than by holding a second opinion about which bytes count as whitespace — and since the span ends where the buffer ends, that replay is an exact re-run of the parse's own leading skip_ws under the same options. Two constraints shape where it sits:

  • It runs after the parse, not before. Readers that take the buffer verbatim (glz::text, glz::text_view, glz::raw_json) capture bytes straight off the iterator, so nothing may consume input ahead of them; they complete without error and never reach the check.
  • It keeps the it == end guard. A variant alternative resolved by shape rewinds its iterator once it settles, so a successful read can end with end_reached, depth 0, and a consumed span that is nothing but the whitespace ahead of the value it applied. Without the guard that read would be rejected.

read_streaming is bounded too, so every top-level entry point is. It forces null_terminated off, so it had the same defect. Refills looked like an obstacle — they relocate the buffer — but consume_and_refill is only ever reached inside array and object element loops, so nothing refills while the top level skips leading whitespace and a stream that held no value never leaves its first window. The span origin comes from the streaming state rather than from an iterator captured before the parse.

That last property cuts both ways, so the streaming check is narrower than the buffered one: it runs behind at_eof, because only an exhausted stream can be said to have held no value. Leading whitespace wider than the window also stops the parse short — of a value that is really there — and that case keeps the behavior it has always had rather than being reported as an absent value. Both halves are pinned by tests.

Cost

Measured, -O2 -DNDEBUG, interleaved runs:

before after
registry::call span, 120 byte object body 201 ns 219 ns (+8.7%)
registry::call span, int body 72 ns 76 ns (+5%)
glz::read into a std::string body, parse only 136 ns 161 ns (+18%)

The REPE figures match #2729 — the extra coverage is free there. The REST path is the one that pays for a bound it does not strictly need today, since request::body is a std::string. I would not exempt it: "which registry buffers happen to be std::string" is exactly the invariant that rotted here, since state_view's body used to be one and is now a wire span.

Nothing outside a non-null-terminated JSON read pays anything. The whole check is behind if constexpr (Opts.format == JSON && !check_null_terminated(Opts)), so the default read path compiles it away entirely.

Tests

New tests in registry_view_test (REPE spans over exactly sized buffers: scalar, object, truncated, whitespace-only, comment-only, both variant shapes, and the plugin_call FFI entry point), jsonrpc_registry_test (exactly sized requests: valid, truncated batch, truncated object, bare scalar), json_test (whitespace-only, comment-only and malformed-comment input under null_terminated = false, which also runs in the GLZ_NULL_TERMINATED=false build of that file), and istream_buffer_test (streams holding no value, a value read across refills, and leading whitespace wider than the window). Both networking test files trip ASAN against main as written and pass here. Full suite is 108/108 under -fsanitize=address,undefined -fno-sanitize-recover=all.

Follow-up, not in this PR

  • There is no REPE or JSON RPC fuzz target. A harness feeding registry.call from exactly sized buffers would have found all three of these and would cover the whole wire-facing surface.
  • read_jsonc on a streaming buffer reads out of bounds. It has no is_input_streaming overload, unlike read_json, so it binds to the buffered glz::read with null_terminated = true over a window that carries no terminator and cannot be padded (istream_buffer is not resizable). ASAN reports a heap-buffer-overflow one byte past the window. This is the same defect class as this PR reached through a different door, and the missing-overload shape applies to the other per-format helpers as well, so it wants a systematic fix rather than a one-off. Left out of this PR to keep the change focused, but it is more severe than anything fixed here.
  • read_streaming ignores validate_trailing_whitespace; that block lives only in glz::read.
  • Leading whitespace wider than the streaming window stops the parse short of a value that is really there. Bounding it needs a refill during the top-level whitespace skip, which no parser does today.
  • An unterminated block comment reports no_read_input. skip_comment runs off the end without flagging it, so that is the honest answer available here, and it beats the silent success main returns. A real fix belongs in skip_comment and would change the null-terminated path too.

The registry parses bytes it does not own: a wire span handed to
registry::call(std::span<const char>, std::string&), a raw pointer and length
handed to repe::plugin_call across an FFI boundary, a std::string_view handed to
the JSON RPC call. None of those carry the '\0' sentinel a null_terminated read
relies on when it drops its end checks, yet the registry read with that option
on, because it defaults on and no caller can reach it. Three reads over the
caller's buffer ran past its end. Each one is a heap-buffer-overflow on a
well formed request, not a malformed one:

    registry.call(span_over_exact_sized_buffer{"/value" body "7"}, response)
      -> READ of size 1, 0 bytes after a 55 byte region   (repe read_params)

    registry<opts{}, JSONRPC>.call("[7")
      -> READ of size 1, 0 bytes after a 52 byte region   (batch read_json)

    registry<opts{}, JSONRPC>.call(truncated_object)
      -> READ of size 1, 0 bytes after a 52 byte region   (validate_json)

The registry now reads with null_terminated off no matter what the user asked
for, since the buffer shape is known here and not at the call sites that hand it
bytes. Two of the three reads above reached glz::read_json and glz::validate_json
directly, which hardcode default options, so those move onto the registry's
options; get_as_json and get_sv_json took an Opts parameter and then discarded it
in favor of defaults, which is fixed with them.

Reading unterminated input trades the sentinel scan for per-byte end checks,
which costs about 9% end to end on a 120 byte REPE object body and 18% on a
parse in isolation. That is the price of the bound.

Two defects surfaced with the change and are fixed with it.

repe::read_params returned bytes consumed and every caller read 0 as failure.
Without a terminator a completed read can consume nothing: a variant alternative
that resolves at the end of the buffer rewinds its iterator, so the read reports
zero bytes with the value applied. The registry took that for an error and
returned without writing anything, so a non-notify request got no response at all
and its client waited forever:

    struct api { std::variant<std::string, amount> measure{}; };
    // body "42.5", buffer ends there -> response buffer empty, value applied

read_params returns bool now. The count was never used for anything else.

A buffer holding only whitespace was read as a completed value. Without a
terminator the reader runs out of input looking for a value and reports
end_reached, which at the top level otherwise means the value ended with the
buffer, so glz::read cleared it and returned success with the destination
untouched. glz::read<{.null_terminated = false}>(v, "   ") did this before this
change; through the registry it would have answered a malformed request with a
success response. Whitespace with no value in it is now no_read_input, the same
code an empty buffer reports.

The out of bounds read in the REPE zero-copy path and the test that covers it are
from #2729 by @uwezkhan, which this supersedes.
Three follow-ups to the registry read bound.

The whitespace check missed comments. `consumed_only_whitespace` tested for
` \t\n\r`, so with `.comments = true` a comment-only buffer still reported
success with the destination untouched -- the same defect, one option away,
and reachable through a registry now that the registry always reads without a
terminator. The check is now `finalize_top_level_read`, which replays `skip_ws`
over the consumed span instead of holding a second opinion about which bytes
count as whitespace. Since the span ends where the buffer ends, that replay is
an exact re-run of the parse's own leading `skip_ws`. It stays after the parse
so readers that take the buffer verbatim (`glz::text`, `glz::raw_json`) still
see every byte the caller passed; they complete without error and never reach
it. The three copies of the condition collapse into that one helper.

The registry bound could be silently skipped. `registry_read_opts` turns
`null_terminated` off through a requires-expression, which an options struct
that fixes the member (`static constexpr bool null_terminated = true`, the
shape `opts_csv` uses) fails -- leaving the registry reading past the caller's
buffer with no diagnostic. A `static_assert` makes it a compile error.

The JSON RPC syntax check ran under the wrong options. It hardcoded the
defaults `validate_json` is fixed to, so a registry configured with comments or
relaxed key handling validated by different rules than it read by, and that
comparison decides -32700 vs -32600. It now extends the registry's options.

Also parse with `is_padded_off<Opts>()` in `read_params`, mirroring `glz::read`,
so a path that bypasses `glz::read` does not inherit the padding assumption from
whatever the caller passed.

`read_streaming` has the same absent-value gap and forces `null_terminated`
off, but refills relocate and consume the buffer, so the parsed bytes are no
longer addressable as a span by the time the read returns. Its behavior is
unchanged and the reason is now written down at the call site.
`read_streaming` was the one top-level entry point left unbounded. It forces
`null_terminated` off, so a stream holding nothing but whitespace or comments
ended in the same `end_reached` a value ending with the buffer does, and
`finalize_read_context` cleared it -- returning success over an untouched
destination.

The reason given for leaving it alone was wrong. Refills were said to make the
parsed bytes unaddressable, but `consume_and_refill` is only ever called inside
array and object element loops; nothing refills while the top level skips
leading whitespace. A stream that held no value never leaves its first window,
so the span is exactly what the parse walked. The origin comes from the
streaming state rather than an iterator captured before the parse, since a
refill relocates the buffer.

Only an exhausted stream can be said to have held no value, so the check runs
after `consume_buffer` and behind `at_eof`, which also requires the window to be
drained. A stream that still has data ran out of window instead: whitespace
wider than the window stops the parse short of a value that is really there.
That is a separate limitation of streaming, and it keeps the behavior it has
always had rather than being reported as an absent value. Both halves are
pinned by tests.

`finalize_top_level_read` now requires `start < it` rather than a non-empty
span. A streaming origin can also overshoot on an error path, which `!=` did
not catch. The guard is a no-op for buffered reads, where an empty buffer is
rejected before the helper.
Same content, half the lines. The two conditions that are load-bearing rather
than defensive are now a list instead of prose, and the sentence noting that
only a non-null-terminated JSON read reaches any of this is dropped -- the
if constexpr directly above says so.
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.

1 participant