Skip to content

Commit 588276a

Browse files
committed
admin: tighten Forward decoding (Gemini security + Codex P1)
Two findings on the leader-side AdminForward handler: - Gemini security-medium: handleDelete unmarshalled the raw payload with no size cap, so a hostile follower could push a multi-MiB body and consume memory before the JSON parser noticed. Apply a 64 KiB cap (mirrors the HTTP path defaultBodyLimit) on both handleCreate and handleDelete; payloads past the cap return 413 payload_too_large without ever touching json.Unmarshal. - Codex P1: handleCreate decoded with plain json.Unmarshal, bypassing the strict checks the HTTP path runs through decodeCreateTableRequest (DisallowUnknownFields, dec.More() trailing-token rejection, slash-in-name validation, the rest of validateCreateTableRequest). Reuse decodeCreateTableRequest so a forwarded create cannot smuggle past validations a leader-direct create would have caught. handleDelete also gains DisallowUnknownFields + dec.More() so its payload contract matches the create path. Tests cover the new 413 paths, the unknown-field rejection, and the slash-in-name rejection on the forwarded create path.
1 parent d856cc2 commit 588276a

2 files changed

Lines changed: 107 additions & 4 deletions

File tree

internal/admin/forward_server.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package admin
22

33
import (
4+
"bytes"
45
"context"
56
"errors"
67
"log/slog"
@@ -10,6 +11,15 @@ import (
1011
"github.com/goccy/go-json"
1112
)
1213

14+
// adminForwardPayloadLimit caps the JSON payload the leader will
15+
// decode for any Forward operation. Mirrors defaultBodyLimit on the
16+
// HTTP path (64 KiB) so a single Forward call cannot consume more
17+
// memory than the same operation would over /admin/api/v1/dynamo/.
18+
// gRPC has its own 4 MiB max-message default, but that is way too
19+
// permissive for admin: a follower-forwarded request must obey the
20+
// same 64 KiB ceiling we promise on the public API surface.
21+
const adminForwardPayloadLimit = 64 << 10
22+
1323
// ForwardServer is the leader-side gRPC handler for the AdminForward
1424
// RPC (design Section 3.3). The follower's admin HTTP layer calls it
1525
// when the local node is not the Raft leader; this server then
@@ -122,9 +132,20 @@ func (s *ForwardServer) validatePrincipal(p *pb.AdminPrincipal) (AuthPrincipal,
122132
}
123133

124134
func (s *ForwardServer) handleCreate(ctx context.Context, principal AuthPrincipal, req *pb.AdminForwardRequest) (*pb.AdminForwardResponse, error) {
125-
var body CreateTableRequest
126-
if err := json.Unmarshal(req.GetPayload(), &body); err != nil {
127-
return rejectForward(http.StatusBadRequest, "invalid_body", "request body is not valid JSON")
135+
payload := req.GetPayload()
136+
if len(payload) > adminForwardPayloadLimit {
137+
return rejectForward(http.StatusRequestEntityTooLarge, "payload_too_large",
138+
"forwarded payload exceeds the 64 KiB admin limit")
139+
}
140+
// Reuse the HTTP handler's strict decoder so the forwarded
141+
// path enforces the same shape contract — DisallowUnknownFields,
142+
// trailing-token rejection, slash-in-name rejection, and the
143+
// rest of validateCreateTableRequest. Bypassing it here would
144+
// let a hostile follower (or a misbehaving SPA on the follower
145+
// side) sneak past validations the leader-direct path enforces.
146+
body, err := decodeCreateTableRequest(bytes.NewReader(payload))
147+
if err != nil {
148+
return rejectForward(http.StatusBadRequest, "invalid_body", err.Error())
128149
}
129150
summary, err := s.source.AdminCreateTable(ctx, principal, body)
130151
if err != nil {
@@ -145,10 +166,23 @@ func (s *ForwardServer) handleDelete(ctx context.Context, principal AuthPrincipa
145166
// proto stays operation-agnostic — there is no operation-specific
146167
// field in AdminForwardRequest, by design (adding one per op
147168
// would couple every new admin endpoint to the proto schema).
169+
payload := req.GetPayload()
170+
if len(payload) > adminForwardPayloadLimit {
171+
return rejectForward(http.StatusRequestEntityTooLarge, "payload_too_large",
172+
"forwarded payload exceeds the 64 KiB admin limit")
173+
}
174+
dec := json.NewDecoder(bytes.NewReader(payload))
175+
dec.DisallowUnknownFields()
148176
var body struct {
149177
Name string `json:"name"`
150178
}
151-
if err := json.Unmarshal(req.GetPayload(), &body); err != nil || body.Name == "" {
179+
if err := dec.Decode(&body); err != nil {
180+
return rejectForward(http.StatusBadRequest, "invalid_body", "delete payload is not valid JSON")
181+
}
182+
if dec.More() {
183+
return rejectForward(http.StatusBadRequest, "invalid_body", "delete payload has trailing data")
184+
}
185+
if body.Name == "" {
152186
return rejectForward(http.StatusBadRequest, "invalid_body", "delete payload missing name")
153187
}
154188
if err := s.source.AdminDeleteTable(ctx, principal, body.Name); err != nil {

internal/admin/forward_server_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,75 @@ func TestForwardServer_UnknownOperationRejected(t *testing.T) {
163163
require.Contains(t, string(resp.GetPayload()), "unknown admin operation")
164164
}
165165

166+
// TestForwardServer_CreateTable_PayloadTooLargeReturns413 exercises
167+
// the size cap added in response to the Gemini security-medium
168+
// finding. The leader must refuse to decode payloads bigger than
169+
// the HTTP path's 64 KiB limit; gRPC's own 4 MiB default is too
170+
// permissive for the admin surface.
171+
func TestForwardServer_CreateTable_PayloadTooLargeReturns413(t *testing.T) {
172+
srv := newForwardServerForTest(&stubTablesSource{tables: map[string]*DynamoTableSummary{}}, fullPrincipalRoleStore())
173+
oversize := make([]byte, adminForwardPayloadLimit+1)
174+
for i := range oversize {
175+
oversize[i] = 'x'
176+
}
177+
resp, err := srv.Forward(context.Background(), &pb.AdminForwardRequest{
178+
Principal: &pb.AdminPrincipal{AccessKey: "AKIA_FULL", Role: "full"},
179+
Operation: pb.AdminOperation_ADMIN_OP_CREATE_TABLE,
180+
Payload: oversize,
181+
})
182+
require.NoError(t, err)
183+
require.Equal(t, int32(http.StatusRequestEntityTooLarge), resp.GetStatusCode())
184+
require.Contains(t, string(resp.GetPayload()), "payload_too_large")
185+
}
186+
187+
// TestForwardServer_CreateTable_RejectsUnknownFields confirms the
188+
// decode path now reuses decodeCreateTableRequest's
189+
// DisallowUnknownFields setting (Codex P1). Without this, a
190+
// follower could smuggle silently-ignored fields the leader-direct
191+
// HTTP path would have rejected.
192+
func TestForwardServer_CreateTable_RejectsUnknownFields(t *testing.T) {
193+
srv := newForwardServerForTest(&stubTablesSource{tables: map[string]*DynamoTableSummary{}}, fullPrincipalRoleStore())
194+
resp, err := srv.Forward(context.Background(), &pb.AdminForwardRequest{
195+
Principal: &pb.AdminPrincipal{AccessKey: "AKIA_FULL", Role: "full"},
196+
Operation: pb.AdminOperation_ADMIN_OP_CREATE_TABLE,
197+
Payload: []byte(`{"table_name":"u","partition_key":{"name":"id","type":"S"},"unknown_field":1}`),
198+
})
199+
require.NoError(t, err)
200+
require.Equal(t, int32(http.StatusBadRequest), resp.GetStatusCode())
201+
require.Contains(t, string(resp.GetPayload()), "invalid_body")
202+
}
203+
204+
// TestForwardServer_CreateTable_RejectsSlashInName confirms the
205+
// strict validation pulled in via decodeCreateTableRequest also
206+
// catches the slash-rejection rule the HTTP path enforces.
207+
func TestForwardServer_CreateTable_RejectsSlashInName(t *testing.T) {
208+
srv := newForwardServerForTest(&stubTablesSource{tables: map[string]*DynamoTableSummary{}}, fullPrincipalRoleStore())
209+
resp, err := srv.Forward(context.Background(), &pb.AdminForwardRequest{
210+
Principal: &pb.AdminPrincipal{AccessKey: "AKIA_FULL", Role: "full"},
211+
Operation: pb.AdminOperation_ADMIN_OP_CREATE_TABLE,
212+
Payload: []byte(`{"table_name":"foo/bar","partition_key":{"name":"id","type":"S"}}`),
213+
})
214+
require.NoError(t, err)
215+
require.Equal(t, int32(http.StatusBadRequest), resp.GetStatusCode())
216+
require.Contains(t, string(resp.GetPayload()), "must not contain")
217+
}
218+
219+
// TestForwardServer_DeleteTable_PayloadTooLargeReturns413 mirrors
220+
// the create-side cap: an oversized delete payload must be refused
221+
// before the JSON decoder runs, regardless of how the gRPC layer
222+
// configures its own message limits.
223+
func TestForwardServer_DeleteTable_PayloadTooLargeReturns413(t *testing.T) {
224+
srv := newForwardServerForTest(&stubTablesSource{tables: map[string]*DynamoTableSummary{}}, fullPrincipalRoleStore())
225+
oversize := make([]byte, adminForwardPayloadLimit+1)
226+
resp, err := srv.Forward(context.Background(), &pb.AdminForwardRequest{
227+
Principal: &pb.AdminPrincipal{AccessKey: "AKIA_FULL", Role: "full"},
228+
Operation: pb.AdminOperation_ADMIN_OP_DELETE_TABLE,
229+
Payload: oversize,
230+
})
231+
require.NoError(t, err)
232+
require.Equal(t, int32(http.StatusRequestEntityTooLarge), resp.GetStatusCode())
233+
}
234+
166235
func TestForwardServer_CreateTable_GenericErrorReturns500(t *testing.T) {
167236
// A non-sentinel error from the source must surface as 500 with
168237
// a sanitised message. The leader logs the raw error; nothing

0 commit comments

Comments
 (0)