From 23f87c22eb78479e4c7bb8abbc2bcbaa6eb4bb76 Mon Sep 17 00:00:00 2001 From: Dave Crighton Date: Fri, 27 Feb 2026 12:29:36 +0000 Subject: [PATCH 1/2] Protect operations database from non-string data in error messages from reverts Signed-off-by: Dave Crighton --- internal/operations/operation_updater.go | 13 +- internal/operations/operation_updater_test.go | 113 ++++++++++++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/internal/operations/operation_updater.go b/internal/operations/operation_updater.go index ed87f67696..43d2009118 100644 --- a/internal/operations/operation_updater.go +++ b/internal/operations/operation_updater.go @@ -18,8 +18,11 @@ package operations import ( "context" + "encoding/hex" "fmt" + "strings" "time" + "unicode/utf8" "github.com/hyperledger/firefly-common/pkg/config" "github.com/hyperledger/firefly-common/pkg/ffapi" @@ -422,7 +425,15 @@ func (ou *operationUpdater) resolveOperation(ctx context.Context, ns string, id update = update.Set("status", status) } if errorMsg != nil { - update = update.Set("error", *errorMsg) + // PostgreSQL text columns reject null bytes and invalid UTF-8 sequences. + // Null bytes (0x00) are valid UTF-8 but rejected by PostgreSQL, so check both. + if !utf8.ValidString(*errorMsg) || strings.ContainsRune(*errorMsg, 0) { + hexString := hex.EncodeToString([]byte(*errorMsg)) + log.L(ctx).Warnf("Error message contains invalid UTF-8 or null bytes - encoding as hex: %s", hexString) + update = update.Set("error", hexString) + } else { + update = update.Set("error", *errorMsg) + } } if output != nil { update = update.Set("output", output) diff --git a/internal/operations/operation_updater_test.go b/internal/operations/operation_updater_test.go index 6a4fd97c57..4f515928e1 100644 --- a/internal/operations/operation_updater_test.go +++ b/internal/operations/operation_updater_test.go @@ -541,6 +541,119 @@ func TestDoUpdateVerifyBatchManifestFail(t *testing.T) { mdi.AssertExpectations(t) } +func TestResolveOperationValidUTF8ErrorPassesThrough(t *testing.T) { + ou := newTestOperationUpdaterNoConcurrency(t) + defer ou.close() + + opID1 := fftypes.NewUUID() + mdi := ou.database.(*databasemocks.Plugin) + mdi.On("GetOperations", mock.Anything, mock.Anything, mock.Anything).Return([]*core.Operation{ + {ID: opID1, Namespace: "ns1", Type: core.OpTypeBlockchainInvoke}, + }, nil, nil) + mdi.On("UpdateOperation", mock.Anything, "ns1", opID1, mock.Anything, mock.MatchedBy(updateMatcher([][]string{ + {"status", "Failed"}, + {"error", "FF23021: EVM reverted: some normal error message"}, + }))).Return(true, nil) + + ou.initQueues() + + err := ou.doBatchUpdate(ou.ctx, []*core.OperationUpdate{ + {NamespacedOpID: "ns1:" + opID1.String(), Status: core.OpStatusFailed, ErrorMessage: "FF23021: EVM reverted: some normal error message"}, + }) + assert.NoError(t, err) + + mdi.AssertExpectations(t) +} + +func TestResolveOperationInvalidUTF8ErrorHexEncoded(t *testing.T) { + ou := newTestOperationUpdaterNoConcurrency(t) + defer ou.close() + + opID1 := fftypes.NewUUID() + + // Simulate the actual revert scenario: readable text with embedded ABI-encoded Error(string) + // selector bytes (0x08, 0xc3, 0x79, 0xa0) and null byte padding, which is invalid UTF-8 + invalidMsg := "[OCPE]404/98 - \x08\xc3\x79\xa0\x00\x00\x00[TMM]404/16e" + expectedHex := "5b4f4350455d3430342f3938202d2008c379a00000005b544d4d5d3430342f313665" + + mdi := ou.database.(*databasemocks.Plugin) + mdi.On("GetOperations", mock.Anything, mock.Anything, mock.Anything).Return([]*core.Operation{ + {ID: opID1, Namespace: "ns1", Type: core.OpTypeBlockchainInvoke}, + }, nil, nil) + mdi.On("UpdateOperation", mock.Anything, "ns1", opID1, mock.Anything, mock.MatchedBy(updateMatcher([][]string{ + {"status", "Failed"}, + {"error", expectedHex}, + }))).Return(true, nil) + + ou.initQueues() + + err := ou.doBatchUpdate(ou.ctx, []*core.OperationUpdate{ + {NamespacedOpID: "ns1:" + opID1.String(), Status: core.OpStatusFailed, ErrorMessage: invalidMsg}, + }) + assert.NoError(t, err) + + mdi.AssertExpectations(t) +} + +func TestResolveOperationNullBytesOnlyInvalidUTF8(t *testing.T) { + ou := newTestOperationUpdaterNoConcurrency(t) + defer ou.close() + + opID1 := fftypes.NewUUID() + + // Null bytes mixed with non-continuation bytes that break UTF-8 validity + invalidMsg := "error\x00with\x80null" + expectedHex := "6572726f720077697468806e756c6c" + + mdi := ou.database.(*databasemocks.Plugin) + mdi.On("GetOperations", mock.Anything, mock.Anything, mock.Anything).Return([]*core.Operation{ + {ID: opID1, Namespace: "ns1", Type: core.OpTypeBlockchainInvoke}, + }, nil, nil) + mdi.On("UpdateOperation", mock.Anything, "ns1", opID1, mock.Anything, mock.MatchedBy(updateMatcher([][]string{ + {"status", "Failed"}, + {"error", expectedHex}, + }))).Return(true, nil) + + ou.initQueues() + + err := ou.doBatchUpdate(ou.ctx, []*core.OperationUpdate{ + {NamespacedOpID: "ns1:" + opID1.String(), Status: core.OpStatusFailed, ErrorMessage: invalidMsg}, + }) + assert.NoError(t, err) + + mdi.AssertExpectations(t) +} + +func TestResolveOperationNullBytesInValidUTF8HexEncoded(t *testing.T) { + ou := newTestOperationUpdaterNoConcurrency(t) + defer ou.close() + + opID1 := fftypes.NewUUID() + + // Pure null bytes embedded in otherwise valid UTF-8 text. + // utf8.ValidString returns true for this, but PostgreSQL rejects 0x00 in text columns. + invalidMsg := "hello\x00world" + expectedHex := "68656c6c6f00776f726c64" + + mdi := ou.database.(*databasemocks.Plugin) + mdi.On("GetOperations", mock.Anything, mock.Anything, mock.Anything).Return([]*core.Operation{ + {ID: opID1, Namespace: "ns1", Type: core.OpTypeBlockchainInvoke}, + }, nil, nil) + mdi.On("UpdateOperation", mock.Anything, "ns1", opID1, mock.Anything, mock.MatchedBy(updateMatcher([][]string{ + {"status", "Failed"}, + {"error", expectedHex}, + }))).Return(true, nil) + + ou.initQueues() + + err := ou.doBatchUpdate(ou.ctx, []*core.OperationUpdate{ + {NamespacedOpID: "ns1:" + opID1.String(), Status: core.OpStatusFailed, ErrorMessage: invalidMsg}, + }) + assert.NoError(t, err) + + mdi.AssertExpectations(t) +} + func TestDoUpdateVerifyBlobManifestFail(t *testing.T) { ou := newTestOperationUpdaterNoConcurrency(t) defer ou.close() From fe2e153468bf2f7b25829c12fbb9f377583d30b0 Mon Sep 17 00:00:00 2001 From: Dave Crighton Date: Mon, 2 Mar 2026 11:20:00 +0000 Subject: [PATCH 2/2] Pull up prereqs Signed-off-by: Dave Crighton --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index df73ed7f3d..b4624fc959 100644 --- a/Dockerfile +++ b/Dockerfile @@ -76,7 +76,7 @@ ARG UI_TAG ARG UI_RELEASE RUN apk add --update --no-cache \ sqlite=3.48.0-r4 \ - postgresql16-client=16.11-r0 \ + postgresql16-client=16.12-r0 \ curl=8.14.1-r2 \ jq=1.7.1-r0 WORKDIR /firefly