Skip to content

Commit e6e3596

Browse files
committed
admin: dedupe errorBody + sanitise forwarded_from (Claude review)
Two carry-overs from Claude's review on PR #635: - forward_server.go defined errorBody{Error, Message} which was byte-for-byte identical to errorResponse in router.go (same package, same JSON tags). Drop errorBody and use the existing errorResponse type — no functional change, eliminates drift. - ForwardedFrom from the gRPC request was written into slog's LogAttrs verbatim. With JSON output the encoder escapes \n/\r for us, but a text-format handler would let a malicious follower-supplied node id split one audit line into two — defeating log-aggregation parsing or spoofing a synthetic log entry. Sanitise once at the RPC entry point in Forward() and thread the cleaned string through handleCreate / handleDelete / the warning-log paths. Test: TestForwardServer_SanitisesForwardedFromInLog uses a real slog text-format handler (where JSON's auto-escape doesn't help) and confirms a forwarded_from value containing "\n" comes out as spaces in the log line.
1 parent b5dc48d commit e6e3596

2 files changed

Lines changed: 70 additions & 23 deletions

File tree

internal/admin/forward_server.go

Lines changed: 42 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,24 @@ func (s *ForwardServer) Forward(ctx context.Context, req *pb.AdminForwardRequest
6262
if req == nil || req.GetPrincipal() == nil {
6363
return rejectForward(http.StatusBadRequest, "invalid_request", "missing principal")
6464
}
65+
// Sanitise forwarded_from before it ever reaches a slog
66+
// handler. With JSON output the encoder escapes newlines on
67+
// our behalf, but with a text-format handler an attacker who
68+
// controlled the follower side could embed `\n` in the value
69+
// and split a single audit line into two — defeating
70+
// log-aggregation parsing or spoofing a synthetic entry.
71+
// Replacing CR/LF with spaces at the entry point keeps every
72+
// downstream call site on the leader trivially safe (Claude
73+
// review on PR #635).
74+
forwardedFrom := sanitiseForwardedFrom(req.GetForwardedFrom())
6575
principal, ok := s.validatePrincipal(req.GetPrincipal())
6676
if !ok {
6777
// Don't leak why the principal failed — the follower may
6878
// have a different view of the cluster's role config and
6979
// we want operators to investigate from the audit log on
7080
// the leader, not the follower's response body.
7181
s.logger.LogAttrs(ctx, slog.LevelWarn, "admin_forward_principal_rejected",
72-
slog.String("forwarded_from", req.GetForwardedFrom()),
82+
slog.String("forwarded_from", forwardedFrom),
7383
slog.String("claimed_access_key", req.GetPrincipal().GetAccessKey()),
7484
slog.String("claimed_role", req.GetPrincipal().GetRole()),
7585
)
@@ -78,9 +88,9 @@ func (s *ForwardServer) Forward(ctx context.Context, req *pb.AdminForwardRequest
7888
}
7989
switch req.GetOperation() {
8090
case pb.AdminOperation_ADMIN_OP_CREATE_TABLE:
81-
return s.handleCreate(ctx, principal, req)
91+
return s.handleCreate(ctx, principal, forwardedFrom, req)
8292
case pb.AdminOperation_ADMIN_OP_DELETE_TABLE:
83-
return s.handleDelete(ctx, principal, req)
93+
return s.handleDelete(ctx, principal, forwardedFrom, req)
8494
case pb.AdminOperation_ADMIN_OP_UNSPECIFIED:
8595
return rejectForward(http.StatusBadRequest, "invalid_request", "unknown admin operation")
8696
default:
@@ -108,7 +118,7 @@ func (s *ForwardServer) validatePrincipal(p *pb.AdminPrincipal) (AuthPrincipal,
108118
return AuthPrincipal{AccessKey: accessKey, Role: role}, true
109119
}
110120

111-
func (s *ForwardServer) handleCreate(ctx context.Context, principal AuthPrincipal, req *pb.AdminForwardRequest) (*pb.AdminForwardResponse, error) {
121+
func (s *ForwardServer) handleCreate(ctx context.Context, principal AuthPrincipal, forwardedFrom string, req *pb.AdminForwardRequest) (*pb.AdminForwardResponse, error) {
112122
payload := req.GetPayload()
113123
if len(payload) > adminForwardPayloadLimit {
114124
return rejectForward(http.StatusRequestEntityTooLarge, "payload_too_large",
@@ -126,20 +136,20 @@ func (s *ForwardServer) handleCreate(ctx context.Context, principal AuthPrincipa
126136
}
127137
summary, err := s.source.AdminCreateTable(ctx, principal, body)
128138
if err != nil {
129-
s.logUnexpectedSourceError(ctx, "create_table", body.TableName, req.GetForwardedFrom(), err)
139+
s.logUnexpectedSourceError(ctx, "create_table", body.TableName, forwardedFrom, err)
130140
return forwardErrorResponse("create", err), nil
131141
}
132142
s.logger.LogAttrs(ctx, slog.LevelInfo, "admin_audit",
133143
slog.String("actor", principal.AccessKey),
134144
slog.String("role", string(principal.Role)),
135-
slog.String("forwarded_from", req.GetForwardedFrom()),
145+
slog.String("forwarded_from", forwardedFrom),
136146
slog.String("operation", "create_table"),
137147
slog.String("table", body.TableName),
138148
)
139149
return jsonForwardResponse(http.StatusCreated, summary)
140150
}
141151

142-
func (s *ForwardServer) handleDelete(ctx context.Context, principal AuthPrincipal, req *pb.AdminForwardRequest) (*pb.AdminForwardResponse, error) {
152+
func (s *ForwardServer) handleDelete(ctx context.Context, principal AuthPrincipal, forwardedFrom string, req *pb.AdminForwardRequest) (*pb.AdminForwardResponse, error) {
143153
// Delete carries the table name in the payload as JSON so the
144154
// proto stays operation-agnostic — there is no operation-specific
145155
// field in AdminForwardRequest, by design (adding one per op
@@ -180,19 +190,35 @@ func (s *ForwardServer) handleDelete(ctx context.Context, principal AuthPrincipa
180190
return rejectForward(http.StatusBadRequest, "invalid_body", "delete payload name must not contain '/'")
181191
}
182192
if err := s.source.AdminDeleteTable(ctx, principal, body.Name); err != nil {
183-
s.logUnexpectedSourceError(ctx, "delete_table", body.Name, req.GetForwardedFrom(), err)
193+
s.logUnexpectedSourceError(ctx, "delete_table", body.Name, forwardedFrom, err)
184194
return forwardErrorResponse("delete", err), nil
185195
}
186196
s.logger.LogAttrs(ctx, slog.LevelInfo, "admin_audit",
187197
slog.String("actor", principal.AccessKey),
188198
slog.String("role", string(principal.Role)),
189-
slog.String("forwarded_from", req.GetForwardedFrom()),
199+
slog.String("forwarded_from", forwardedFrom),
190200
slog.String("operation", "delete_table"),
191201
slog.String("table", body.Name),
192202
)
193203
return &pb.AdminForwardResponse{StatusCode: http.StatusNoContent}, nil
194204
}
195205

206+
// sanitiseForwardedFrom strips CR/LF from a follower-supplied
207+
// node id so a malicious value cannot split a single audit log
208+
// line into two when slog is using a text-format handler. JSON
209+
// handlers escape these characters automatically; this is a
210+
// defence-in-depth pass for handler-format-agnostic safety.
211+
// Other control characters are deliberately preserved — only the
212+
// line-splitting characters matter for log spoofing.
213+
func sanitiseForwardedFrom(s string) string {
214+
return strings.Map(func(r rune) rune {
215+
if r == '\n' || r == '\r' {
216+
return ' '
217+
}
218+
return r
219+
}, s)
220+
}
221+
196222
// forwardErrorResponse re-encodes a TablesSource error in the
197223
// structured shape the follower's handler can re-emit verbatim. This
198224
// is the leader-side counterpart of writeTablesError: every status /
@@ -208,7 +234,7 @@ func (s *ForwardServer) handleDelete(ctx context.Context, principal AuthPrincipa
208234
func forwardErrorResponse(op string, err error) *pb.AdminForwardResponse {
209235
switch {
210236
case errors.Is(err, ErrTablesForbidden):
211-
return mustForwardJSON(http.StatusForbidden, errorBody{Error: "forbidden", Message: "this endpoint requires a full-access role"})
237+
return mustForwardJSON(http.StatusForbidden, errorResponse{Error: "forbidden", Message: "this endpoint requires a full-access role"})
212238
case errors.Is(err, ErrTablesNotLeader):
213239
// Should never happen on the leader path — the leader
214240
// just verified itself — but if a leadership transfer
@@ -218,19 +244,19 @@ func forwardErrorResponse(op string, err error) *pb.AdminForwardResponse {
218244
// header the leader-direct path emits (Codex P2 on
219245
// PR #635 — without this the forwarded 503 would lose
220246
// its retry timing).
221-
resp := mustForwardJSON(http.StatusServiceUnavailable, errorBody{Error: "leader_unavailable", Message: "leader stepped down mid-request"})
247+
resp := mustForwardJSON(http.StatusServiceUnavailable, errorResponse{Error: "leader_unavailable", Message: "leader stepped down mid-request"})
222248
resp.RetryAfterSeconds = 1
223249
return resp
224250
case errors.Is(err, ErrTablesNotFound):
225-
return mustForwardJSON(http.StatusNotFound, errorBody{Error: "not_found", Message: "table does not exist"})
251+
return mustForwardJSON(http.StatusNotFound, errorResponse{Error: "not_found", Message: "table does not exist"})
226252
case errors.Is(err, ErrTablesAlreadyExists):
227-
return mustForwardJSON(http.StatusConflict, errorBody{Error: "already_exists", Message: "table already exists"})
253+
return mustForwardJSON(http.StatusConflict, errorResponse{Error: "already_exists", Message: "table already exists"})
228254
}
229255
var verr *ValidationError
230256
if errors.As(err, &verr) {
231-
return mustForwardJSON(http.StatusBadRequest, errorBody{Error: "invalid_request", Message: verr.Error()})
257+
return mustForwardJSON(http.StatusBadRequest, errorResponse{Error: "invalid_request", Message: verr.Error()})
232258
}
233-
return mustForwardJSON(http.StatusInternalServerError, errorBody{
259+
return mustForwardJSON(http.StatusInternalServerError, errorResponse{
234260
Error: "dynamo_" + op + "_failed",
235261
Message: "failed to " + op + " table; see leader logs",
236262
})
@@ -271,15 +297,8 @@ func isStructuredSourceError(err error) bool {
271297
return errors.As(err, &verr)
272298
}
273299

274-
// errorBody is the shared JSON shape for both the HTTP handler's
275-
// writeJSONError and the forward server's encoded responses.
276-
type errorBody struct {
277-
Error string `json:"error"`
278-
Message string `json:"message,omitempty"`
279-
}
280-
281300
func rejectForward(status int, code, msg string) (*pb.AdminForwardResponse, error) {
282-
return mustForwardJSON(status, errorBody{Error: code, Message: msg}), nil
301+
return mustForwardJSON(status, errorResponse{Error: code, Message: msg}), nil
283302
}
284303

285304
func mustForwardJSON(status int, body any) *pb.AdminForwardResponse {

internal/admin/forward_server_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,34 @@ func TestForwardServer_DeleteTable_LeaderSteppedDownReturns503(t *testing.T) {
360360
require.Contains(t, string(resp.GetPayload()), "leader_unavailable")
361361
}
362362

363+
// TestForwardServer_SanitisesForwardedFromInLog confirms the
364+
// CR/LF stripping pass at the RPC entry point: a malicious
365+
// follower-supplied node id with embedded newlines must not be
366+
// able to split a single audit/error line into two when slog is
367+
// using a text-format handler. Claude review on PR #635.
368+
func TestForwardServer_SanitisesForwardedFromInLog(t *testing.T) {
369+
var buf bytes.Buffer
370+
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
371+
src := &stubTablesSource{tables: map[string]*DynamoTableSummary{}}
372+
srv := NewForwardServer(src, fullPrincipalRoleStore(), logger)
373+
374+
resp, err := srv.Forward(context.Background(), &pb.AdminForwardRequest{
375+
Principal: &pb.AdminPrincipal{AccessKey: "AKIA_FULL", Role: "full"},
376+
Operation: pb.AdminOperation_ADMIN_OP_CREATE_TABLE,
377+
Payload: mustJSON(t, CreateTableRequest{TableName: "users", PartitionKey: CreateTableAttribute{Name: "id", Type: "S"}}),
378+
ForwardedFrom: "follower\nfake_actor=evil\nrole=full",
379+
})
380+
require.NoError(t, err)
381+
require.Equal(t, int32(http.StatusCreated), resp.GetStatusCode())
382+
logged := buf.String()
383+
// Sanitised value (newlines replaced with spaces) must be
384+
// present.
385+
require.Contains(t, logged, "follower fake_actor=evil role=full")
386+
// Raw newline-bearing value must NOT be in the log — that
387+
// would mean the sanitisation did not run.
388+
require.NotContains(t, logged, "follower\nfake_actor=evil")
389+
}
390+
363391
// TestForwardServer_LogsUnexpectedSourceError confirms the leader
364392
// emits an error log line for non-sentinel source failures so
365393
// operators can investigate forwarded write 500s without

0 commit comments

Comments
 (0)