From 7bc56ed79bbf1b775c546e42692f18ddc482a18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Efe=20G=C3=B6kdemir?= Date: Sun, 1 Nov 2026 21:26:01 +0300 Subject: [PATCH] fix(write): enforce JSON size limit after trailing whitespace --- docs/releases/unreleased.md | 4 +++ internal/client/msgpackenc.go | 5 ++++ internal/client/msgpackenc_test.go | 31 +++++++++++++++++++++++ internal/commands/write_test.go | 40 ++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+) create mode 100644 internal/commands/write_test.go diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index b75bbe1..6851a9f 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -5,3 +5,7 @@ - All successfully read license states exit 0. Configuration, request, decoding, and output failures exit nonzero. No token or database header is sent to `/health`, and no authentication verification is performed. Normal connection selection, timeout, TLS verification, and identity opt-out settings apply. License installation and key/file configuration remain server responsibilities. Existing `ping -o json` license pass-through is unchanged. Closes [arcli#34](https://github.com/Basekick-Labs/arcli/issues/34). + +### Fixed + +- Enforce the 64 MiB JSON write input limit when a document ends with whitespace. Oversize input is rejected before upload, including when the bounded read would hide further trailing content. diff --git a/internal/client/msgpackenc.go b/internal/client/msgpackenc.go index 7595fd3..ae817b1 100644 --- a/internal/client/msgpackenc.go +++ b/internal/client/msgpackenc.go @@ -172,6 +172,11 @@ func JSONToMsgPack(r io.Reader) ([]byte, MsgPackDocSummary, error) { if _, err := dec.Token(); err != io.EOF { return nil, sum, fmt.Errorf("invalid JSON: trailing content after the document") } + // LimitReader also reports EOF after consuming its bound. Count + // trailing whitespace so that EOF cannot hide oversize input. + if cr.n > JSONDocMaxBytes { + return nil, sum, tooBig() + } converted, err := convertTop(root, &sum) if err != nil { return nil, sum, err diff --git a/internal/client/msgpackenc_test.go b/internal/client/msgpackenc_test.go index a4c2bd7..d52bbd4 100644 --- a/internal/client/msgpackenc_test.go +++ b/internal/client/msgpackenc_test.go @@ -3,6 +3,7 @@ package client import ( "bytes" "encoding/hex" + "io" "strings" "testing" ) @@ -91,6 +92,36 @@ func TestJSONToMsgPack_RowBatchAndTags(t *testing.T) { } } +func TestJSONToMsgPack_TrailingWhitespaceSizeLimit(t *testing.T) { + const document = `{"m":"cpu","fields":{"x":1}}` + padding := strings.Repeat(" ", JSONDocMaxBytes-len(document)) + for _, tc := range []struct { + name, suffix string + wantError bool + }{ + {"at limit", "", false}, + {"one byte over", " ", true}, + {"trailing document beyond limit", " {}", true}, + } { + t.Run(tc.name, func(t *testing.T) { + in := io.MultiReader(strings.NewReader(document), strings.NewReader(padding), strings.NewReader(tc.suffix)) + out, sum, err := JSONToMsgPack(in) + if tc.wantError { + if err == nil || !strings.Contains(err.Error(), "JSON document exceeds") { + t.Fatalf("error = %v, want size-limit error", err) + } + if out != nil || sum != (MsgPackDocSummary{}) { + t.Errorf("oversize input produced output: %x, summary: %+v", out, sum) + } + return + } + if err != nil || len(out) == 0 || sum.Items != 1 || sum.Rows != 1 { + t.Errorf("input at limit: output=%x summary=%+v error=%v", out, sum, err) + } + }) + } +} + func TestJSONToMsgPack_Rejections(t *testing.T) { cases := map[string]string{ `{"columns":{"x":[1]}}`: `"m" (measurement) must be a non-empty string`, diff --git a/internal/commands/write_test.go b/internal/commands/write_test.go new file mode 100644 index 0000000..992413e --- /dev/null +++ b/internal/commands/write_test.go @@ -0,0 +1,40 @@ +package commands + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/basekick-labs/arcli/internal/client" +) + +func TestWriteJSON_RejectsOversizeTrailingWhitespaceBeforeUpload(t *testing.T) { + var requests atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + writeTestConfig(t, srv.URL, "tok") + + const document = `{"m":"cpu","fields":{"x":1}}` + cmd := newWriteCmd() + cmd.SetIn(io.MultiReader( + strings.NewReader(document), + strings.NewReader(strings.Repeat(" ", client.JSONDocMaxBytes-len(document)+1)), + )) + out, _, err := execCmd(t, cmd, "--format", "json") + if err == nil || !strings.Contains(err.Error(), "JSON document exceeds") { + t.Errorf("error = %v, want size-limit error", err) + } + if out != "" { + t.Errorf("stdout = %q, want no success output", out) + } + if got := requests.Load(); got != 0 { + t.Errorf("sent %d HTTP requests for rejected input", got) + } +}