From 82aefe1988c2436eb3c4c3a9607a5bc9402e4aef Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 11 Oct 2026 19:20:31 +0200 Subject: [PATCH] Refuse a credential in a log entry and take a full 256 KiB value with its expect header (ADR 0297 review) A log keeps what it is given for good, so the state guard against secrets applies to it too; the stream holds 260 KiB a message so the headers an append carries never push a 256 KiB value over. --- merge-check.sh | 2 +- node-tools/internal/bus/log.go | 8 +++++++- node-tools/internal/meshtest/meshtest.go | 4 ++-- node-tools/internal/runtime/log_test.go | 22 ++++++++++++++++++++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/merge-check.sh b/merge-check.sh index 697ff85..8ce0dfe 100644 --- a/merge-check.sh +++ b/merge-check.sh @@ -37,7 +37,7 @@ if ! command -v node >/dev/null 2>&1 || [ ! -d node_modules/@novox/mesh-sdk ]; t # environment holds, what a runtime on a module's own account serves (novox/hq ADR 0259), and that a write # by compare-and-set loses no update under contention (novox/hq research 042), and a module's log verbs # (novox/hq ADR 0297). - runtimeless='^(TestALogAnswersEachVerbOnTheBus|TestALogPageIsBoundedByItsBytes|TestAnAppendAtAStaleExpectIsRefusedAsALostUpdate|TestALogNotIssuedIsRefusedWithTheReason|TestWhatALogCannotHoldIsRefusedWithTheReason|TestARuntimeOnAModulesOwnAccountServesThatModuleAlone|TestNoBusWordReachesABundle|TestAToolCallNeverReachesASeatsEventAcceptOrProof|TestABundlesSeatTrafficGoesThroughItsModulesBus|TestALostCompareAndSetCarriesItsCode|TestTwoWritersIncrementingOneCounterOnTheBusLoseNoUpdate|TestAStaleWriteIsRefusedAndWritesNothing|TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller|TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone)$' + runtimeless='^(TestALogAnswersEachVerbOnTheBus|TestALogPageIsBoundedByItsBytes|TestAnAppendAtAStaleExpectIsRefusedAsALostUpdate|TestALogNotIssuedIsRefusedWithTheReason|TestWhatALogCannotHoldIsRefusedWithTheReason|TestALogRefusesAValueNamingACredential|TestARuntimeOnAModulesOwnAccountServesThatModuleAlone|TestNoBusWordReachesABundle|TestAToolCallNeverReachesASeatsEventAcceptOrProof|TestABundlesSeatTrafficGoesThroughItsModulesBus|TestALostCompareAndSetCarriesItsCode|TestTwoWritersIncrementingOneCounterOnTheBusLoseNoUpdate|TestAStaleWriteIsRefusedAndWritesNothing|TestACallOnTheCallerNamedSubjectReachesTheModuleWithItsCaller|TestTheSubjectThatNamesNoCallerStillWorksAndNamesNone)$' fi if command -v gcc >/dev/null 2>&1; then CGO_ENABLED=1 go test -race -count=1 $packages diff --git a/node-tools/internal/bus/log.go b/node-tools/internal/bus/log.go index 5cd1062..6ac1e0e 100644 --- a/node-tools/internal/bus/log.go +++ b/node-tools/internal/bus/log.go @@ -57,7 +57,8 @@ type LogInfo struct { } const ( - // LogValueMax is the most one entry's value may hold (ADR 0297 §3), the stream's own MaxMsgSize. + // LogValueMax is the most one entry's value may hold (ADR 0297 §3); the stream's MaxMsgSize is 4 KiB + // more, for the headers an append carries. LogValueMax = 256 * 1024 // LogKeyMax is the longest key. LogKeyMax = 255 @@ -156,6 +157,11 @@ func (c *Conn) LogAppend(module, name, key string, value json.RawMessage, expect return 0, fmt.Errorf("a log entry's value is at most %d bytes (256 KiB), and this one is %d (novox/hq ADR 0297)", LogValueMax, len(value)) } + if field := credentialField(value); field != "" { + return 0, fmt.Errorf("%s's %s.%s carries a field %q, which names a credential: no secret is kept in a log, "+ + "which keeps it for good. Name the secret and fetch it on request/reply (novox/hq ADR 0201 §13, ADR 0297)", + module, name, key, field) + } msg := nats.NewMsg(l.Subject + "." + key) msg.Data = value if expect != nil { diff --git a/node-tools/internal/meshtest/meshtest.go b/node-tools/internal/meshtest/meshtest.go index b69195a..6dd6718 100644 --- a/node-tools/internal/meshtest/meshtest.go +++ b/node-tools/internal/meshtest/meshtest.go @@ -234,7 +234,7 @@ func (m *Mesh) Bucket(t *testing.T, name string) nats.KeyValue { // Log makes a module's log afresh as the controller does from the catalogue (novox/hq ADR 0297): a file // stream named LOG__ on mesh.log...>, limits retention, no age, no cap per -// key, the declared cap, 256 KiB an entry, refusing new entries when full, direct get, no delete or +// key, the declared cap, 260 KiB a message (a 256 KiB value and its headers), refusing new entries when full, direct get, no delete or // purge. It answers the membership entry the controller issues for it. func (m *Mesh) Log(t *testing.T, module, name string, maxBytes int64) bus.LogIssued { t.Helper() @@ -243,7 +243,7 @@ func (m *Mesh) Log(t *testing.T, module, name string, maxBytes int64) bus.LogIss _ = m.js.DeleteStream(stream) if _, err := m.js.AddStream(&nats.StreamConfig{Name: stream, Subjects: []string{subject + ".>"}, Storage: nats.FileStorage, Retention: nats.LimitsPolicy, MaxAge: 0, MaxMsgsPerSubject: -1, - MaxBytes: maxBytes, MaxMsgSize: 256 * 1024, Discard: nats.DiscardNew, AllowDirect: true, + MaxBytes: maxBytes, MaxMsgSize: 260 * 1024, Discard: nats.DiscardNew, AllowDirect: true, DenyDelete: true, DenyPurge: true, Replicas: 1, Description: module + "'s log " + name + ", kept as its record (novox/hq ADR 0297)"}); err != nil { t.Fatal(err) diff --git a/node-tools/internal/runtime/log_test.go b/node-tools/internal/runtime/log_test.go index 45ec9eb..f664783 100644 --- a/node-tools/internal/runtime/log_test.go +++ b/node-tools/internal/runtime/log_test.go @@ -313,6 +313,24 @@ func TestWhatALogCannotHoldIsRefusedWithTheReason(t *testing.T) { if info := infoOf(t, b); info["entries"] != 0 { t.Fatalf("a refused append wrote: %v", info) } - // The largest value allowed is taken. - appendOf(t, b, `{"log":"changes","key":"1","value":"`+strings.Repeat("x", 256*1024-2)+`"}`) + // The largest value allowed is taken, also with the header an expect carries. + largest := `"` + strings.Repeat("x", 256*1024-2) + `"` + appendOf(t, b, `{"log":"changes","key":"1","value":`+largest+`,"expect":0}`) + appendOf(t, b, `{"log":"changes","key":"2","value":`+largest+`}`) +} + +// A value naming a credential anywhere in it is refused, as mesh/state.put refuses one (novox/hq ADR +// 0201 §13): a log keeps what it is given for good. A value that only counts tokens is taken. +func TestALogRefusesAValueNamingACredential(t *testing.T) { + b, _, _ := logBuses(t, 1<<20) + for _, value := range []string{`{"op":"set","api_key":"s"}`, `{"op":"note","with":{"headers":{"Authorization":"Bearer s"}}}`} { + _, err := logAsk(b, "append", `{"log":"changes","key":"1","value":`+value+`}`) + if err == nil || !strings.Contains(err.Error(), "names a credential") { + t.Errorf("append of %s: %v", value, err) + } + } + if info := infoOf(t, b); info["entries"] != 0 { + t.Fatalf("a refused append wrote: %v", info) + } + appendOf(t, b, `{"log":"changes","key":"1","value":{"op":"note","maxTokens":4096}}`) }