Skip to content

Commit bebbccb

Browse files
committed
decodebudget: split files by encoding
budget.go holds the Proto API and the tally shared by both walks, wire.go the binary walk, protojson.go the protojson walk, and json.go the descriptor-free JSON token budget. Tests follow the same split. No behavior change.
1 parent 0fd0ae4 commit bebbccb

9 files changed

Lines changed: 535 additions & 452 deletions

File tree

‎utils/decodebudget/budget.go‎

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package decodebudget bounds untrusted input before decoding. Proto checks
16+
// protobuf and protojson input by field count, nesting depth and estimated
17+
// decoded allocation; JSON checks plain JSON by token count and depth. It
18+
// depends only on the protobuf runtime so callers need not import utils.
19+
package decodebudget
20+
21+
import (
22+
"errors"
23+
"reflect"
24+
25+
"google.golang.org/protobuf/proto"
26+
"google.golang.org/protobuf/reflect/protoreflect"
27+
)
28+
29+
// ErrMessageComplexity indicates that a message exceeds its structural budget.
30+
var ErrMessageComplexity = errors.New("message exceeds structural complexity limit")
31+
32+
// Proto limits allocation-driving protobuf structure before decoding.
33+
// Callers must separately limit the serialized byte size. MaxFields and
34+
// MaxDepth must be positive; the zero value does not disable validation.
35+
type Proto struct {
36+
// MaxFields counts every wire-field occurrence and each packed scalar value,
37+
// including occurrences of singular and unknown fields.
38+
MaxFields int
39+
// MaxDepth counts nested messages, including the root message at depth one.
40+
MaxDepth int
41+
// MaxDecodedBytes bounds the estimated bytes the decoder allocates: the Go
42+
// struct of each message, copied strings and bytes, oneof wrappers, and
43+
// slice and map growth. Zero leaves it unchecked.
44+
MaxDecodedBytes int
45+
}
46+
47+
// Validate checks data against the message descriptor without constructing the
48+
// decoded message. Known extensions use protoregistry.GlobalTypes, matching the
49+
// default protobuf decoder. Legacy protobuf groups are deliberately unsupported.
50+
func (b Proto) Validate(data []byte, desc protoreflect.MessageDescriptor) error {
51+
_, err := b.Estimate(data, desc)
52+
return err
53+
}
54+
55+
// Estimate validates data like Validate and returns the estimated bytes the
56+
// binary decoder allocates for it, including the root message.
57+
func (b Proto) Estimate(data []byte, desc protoreflect.MessageDescriptor) (int, error) {
58+
t, err := b.newTally(desc)
59+
if err != nil {
60+
return 0, err
61+
}
62+
if err := t.alloc(protoStructSize(desc)); err != nil {
63+
return t.total(), err
64+
}
65+
err = (&wireWalk{t}).message(data, desc, 1)
66+
return t.total(), err
67+
}
68+
69+
func (b Proto) newTally(desc protoreflect.MessageDescriptor) (*tally, error) {
70+
if b.MaxFields <= 0 || b.MaxDepth <= 0 || b.MaxDecodedBytes < 0 {
71+
return nil, errors.New("protobuf budget limits must be positive")
72+
}
73+
if desc == nil {
74+
return nil, errors.New("protobuf descriptor must not be nil")
75+
}
76+
return &tally{budget: b}, nil
77+
}
78+
79+
// tally is the running field count and allocation estimate shared by the wire
80+
// and protojson walks.
81+
type tally struct {
82+
budget Proto
83+
fields int
84+
bytes int
85+
}
86+
87+
func (t *tally) count() error {
88+
t.fields++
89+
if t.fields > t.budget.MaxFields {
90+
return ErrMessageComplexity
91+
}
92+
return nil
93+
}
94+
95+
func (t *tally) total() int {
96+
return withMargin(t.bytes)
97+
}
98+
99+
func (t *tally) alloc(n int) error {
100+
t.bytes += n
101+
if t.budget.MaxDecodedBytes > 0 && t.total() > t.budget.MaxDecodedBytes {
102+
return ErrMessageComplexity
103+
}
104+
return nil
105+
}
106+
107+
// Unmarshal validates the budget before decoding into a non-nil mutable message.
108+
// It discards unknown fields, uses MaxDepth as the decoder recursion limit, and
109+
// retains the binary decoder's required-field checks. Preflight rejection leaves
110+
// the destination unchanged; other decode errors follow normal protobuf semantics.
111+
func (b Proto) Unmarshal(data []byte, msg proto.Message) error {
112+
if msg == nil || (reflect.ValueOf(msg).Kind() == reflect.Pointer && reflect.ValueOf(msg).IsNil()) {
113+
return errors.New("protobuf message must be non-nil and mutable")
114+
}
115+
reflected := msg.ProtoReflect()
116+
if !reflected.IsValid() {
117+
return errors.New("protobuf message must be non-nil and mutable")
118+
}
119+
if err := b.Validate(data, reflected.Descriptor()); err != nil {
120+
return err
121+
}
122+
return (proto.UnmarshalOptions{DiscardUnknown: true, RecursionLimit: b.MaxDepth}).Unmarshal(data, msg)
123+
}

‎utils/decodebudget/budget_test.go‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package decodebudget
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
"google.golang.org/protobuf/proto"
9+
10+
"github.com/livekit/protocol/livekit"
11+
"github.com/livekit/protocol/utils/protojson"
12+
)
13+
14+
func TestProtoDecodedBytes(t *testing.T) {
15+
input := &livekit.ParticipantInfo{Identity: strings.Repeat("x", 1000), Attributes: map[string]string{"a": "one"}}
16+
data, err := proto.Marshal(input)
17+
require.NoError(t, err)
18+
desc := input.ProtoReflect().Descriptor()
19+
20+
unlimited := Proto{MaxFields: 100, MaxDepth: 4}
21+
estimate, err := unlimited.Estimate(data, desc)
22+
require.NoError(t, err)
23+
require.Greater(t, estimate, 1000)
24+
25+
require.NoError(t, (Proto{MaxFields: 100, MaxDepth: 4, MaxDecodedBytes: estimate}).Validate(data, desc))
26+
require.ErrorIs(t, (Proto{MaxFields: 100, MaxDepth: 4, MaxDecodedBytes: estimate - 1}).Validate(data, desc), ErrMessageComplexity)
27+
require.Error(t, (Proto{MaxFields: 100, MaxDepth: 4, MaxDecodedBytes: -1}).Validate(data, desc))
28+
29+
jsonData, err := protojson.Marshal(input)
30+
require.NoError(t, err)
31+
jsonEstimate, err := unlimited.EstimateJSON(jsonData, desc)
32+
require.NoError(t, err)
33+
require.ErrorIs(t, (Proto{MaxFields: 100, MaxDepth: 4, MaxDecodedBytes: jsonEstimate - 1}).ValidateJSON(jsonData, desc), ErrMessageComplexity)
34+
}

‎utils/decodebudget/cost_test.go‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package decodebudget
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestAppendAllocBytes(t *testing.T) {
10+
require.Equal(t, 0, appendAllocBytes(0, 8))
11+
// 8, 16, 32 and 64 byte arrays for caps 1, 2, 4 and 8.
12+
require.Equal(t, 8+16+32+64, appendAllocBytes(5, 8))
13+
}

0 commit comments

Comments
 (0)