Skip to content

Resend of message with repeating group emits two 10= checksum tags (parseGroup leaks trailer into bodyBytes) #761

@YoussefOuirini

Description

@YoussefOuirini

Summary

When quickfix-go parses a stored outbound message whose body contains a repeating group and a DataDictionary is configured, the parser leaks the original 10= trailer into Message.bodyBytes. On resend, buildWithBodyBytes writes that contaminated bodyBytes verbatim and appends a freshly computed 10= trailer on top — producing a malformed FIX message with two 10= tags. The counterparty rejects with Malformed message: Invalid checksum X (expected Y) and drops the session.

Reproduces on v0.9.10 (latest).

Trigger

All three conditions must hold:

  1. DataDictionary is configured on the session (without it, parseGroup is never reached).
  2. The outbound message contains a repeating group (e.g. NoPartyIDs=453 in a NewOrderSingle).
  3. The counterparty sends a ResendRequest covering that message.

Root cause

message.go::parseGroup (line 308) handles repeating-group fields. Its inner loop updates mp.trailerBytes = mp.rawBytes after consuming each field (line 317). When it consumes the trailer field (10=NNN), rawBytes is now empty, so trailerBytes = "".

The safeguard at message.go:283-286:

if len(mp.msg.bodyBytes) > len(mp.trailerBytes) {
    mp.msg.bodyBytes = mp.msg.bodyBytes[:len(mp.msg.bodyBytes)-len(mp.trailerBytes)]
}

then strips zero bytes from bodyBytes, leaving the trailer embedded.

On resend, in_session.go::resendMessages calls msg.buildWithBodyBytes(msg.bodyBytes):

m.Header.write(&b)
b.Write(bodyBytes)        // contains original 10=NNN
m.Trailer.write(&b)       // writes fresh 10=NNN

The non-group path (default body case at line 258-262) is unaffected because the main loop sets trailerBytes to rawBytes after the last body field — i.e. to the buffer that still includes the upcoming trailer. The strip math is then correct.

Minimal repro

package main

import (
    "bytes"
    "fmt"
    "os"
    "reflect"
    "unsafe"

    "github.com/quickfixgo/enum"
    "github.com/quickfixgo/field"
    "github.com/quickfixgo/quickfix"
    "github.com/quickfixgo/quickfix/datadictionary"
    "github.com/quickfixgo/tag"
)

func main() {
    msg := quickfix.NewMessage()
    msg.Header.SetString(tag.BeginString, "FIX.4.4")
    msg.Header.SetString(tag.MsgType, "D")
    msg.Header.SetString(tag.SenderCompID, "S")
    msg.Header.SetString(tag.TargetCompID, "T")
    msg.Header.SetInt(tag.MsgSeqNum, 1)
    msg.Header.SetString(tag.SendingTime, "20260410-12:00:00")
    msg.Body.SetString(tag.ClOrdID, "X")
    msg.Body.SetField(tag.Side, field.NewSide(enum.Side_BUY))
    msg.Body.SetField(tag.OrdType, field.NewOrdType(enum.OrdType_LIMIT))
    msg.Body.SetString(tag.TransactTime, "20260410-12:00:00")

    parties := quickfix.NewRepeatingGroup(tag.NoPartyIDs, quickfix.GroupTemplate{
        quickfix.GroupElement(tag.PartyID),
        quickfix.GroupElement(tag.PartyIDSource),
        quickfix.GroupElement(tag.PartyRole),
    })
    p := parties.Add()
    p.SetString(tag.PartyID, "A")
    p.SetString(tag.PartyIDSource, "D")
    p.SetInt(tag.PartyRole, 1)
    msg.Body.SetGroup(parties)
    raw := []byte(msg.String())

    dictBytes, _ := os.ReadFile("FIX44.xml")
    dict, _ := datadictionary.ParseSrc(bytes.NewBuffer(dictBytes))

    parsed := quickfix.NewMessage()
    quickfix.ParseMessageWithDataDictionary(parsed, bytes.NewBuffer(raw), nil, dict)

    v := reflect.ValueOf(parsed).Elem().FieldByName("bodyBytes")
    bb := *(*[]byte)(unsafe.Pointer(v.UnsafeAddr()))

    fmt.Printf("bodyBytes ends with: %q\n", string(bb[len(bb)-10:]))
    fmt.Printf("10= tag count in bodyBytes: %d (expected 0)\n",
        bytes.Count(bb, []byte("\x0110=")))
}

Output on v0.9.10:

bodyBytes ends with: "452=1\x0110=132\x01"
10= tag count in bodyBytes: 1 (expected 0)

Real-world wire output during resend includes both trailers:
...447=D 452=1 10=129 10=152 → counterparty rejects.

Suggested fix

In parseGroup (line 308), save the pre-extract rawBytes and restore trailerBytes to that snapshot when a trailer field is detected:

for {
    mp.fieldIndex++
    mp.parsedFieldBytes = &mp.msg.fields[mp.fieldIndex]
    preExtract := mp.rawBytes
    mp.rawBytes, _ = extractField(mp.parsedFieldBytes, mp.rawBytes)
    mp.trailerBytes = mp.rawBytes

    if isGroupMember(...) {
        ...
    } else if isHeaderField(...) {
        ...
    } else if isTrailerField(...) {
        mp.msg.Body.add(dm)
        mp.msg.Trailer.add(...)
        mp.foundTrailer = true
        mp.trailerBytes = preExtract  // include trailer bytes
        break
    }
    ...
}

The main parser's safeguard at line 283-286 then strips the trailer correctly.

Related

Workaround for affected users

Strip the trailing \x0110=NNN\x01 from Message.bodyBytes in Application.ToApp when PossDupFlag=true, via reflection. Requires unsafe.Pointer because bodyBytes is unexported.

Version

github.com/quickfixgo/quickfix v0.9.10

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions