@@ -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
208234func 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-
281300func 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
285304func mustForwardJSON (status int , body any ) * pb.AdminForwardResponse {
0 commit comments