clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

fix(protocol): reject null filter fields and list entries

Dami committed 2a2536a
Reject nonobject filter roots and null values instead of treating them as unrestricted filters or zero-value constraints. Add regression cases and fuzz coverage for event and filter parsing.
commit
2a2536aeeabc6e926c74762eedeea540fbda5935
parent
c91c272509779699fc2738c1bec749807d856f12
author
Dami <dami.etoile@gmail.com>
2 files changed, +94 -0
Mprotocol/nostr/filter.go39+++++++++++++++++++++
Aprotocol/nostr/fuzz_test.go55++++++++++++++++++++++++++++++
M protocol/nostr/filter.go +39 -0
@@ -26,6 +26,10 @@ type Filter struct {
2626 // ParseFilter decodes supported Nostr filter fields, including #tag keys, and2727 // checks their JSON types.2828 func ParseFilter(raw []byte) (Filter, error) {29+ trimmed := strings.TrimSpace(string(raw))30+ if len(trimmed) == 0 || trimmed[0] != '{' {31+ return Filter{}, errorsFilter("filter must be an object")32+ }2933 var values map[string]json.RawMessage3034 if err := json.Unmarshal(raw, &values); err != nil {3135 return Filter{}, errorsFilter("filter must be an object")
@@ -37,6 +41,9 @@ func ParseFilter(raw []byte) (Filter, error) {
3741 if !isJSONArray(value) {3842 return Filter{}, errorsFilter(key + " must be a list of strings")3943 }44+ if hasJSONNullElement(value) {45+ return Filter{}, errorsFilter(key + " must be a list of strings")46+ }4047 var out []string4148 if err := json.Unmarshal(value, &out); err != nil {4249 return Filter{}, errorsFilter(key + " must be a list of strings")
@@ -50,10 +57,16 @@ func ParseFilter(raw []byte) (Filter, error) {
5057 if !isJSONArray(value) {5158 return Filter{}, errorsFilter("kinds must be a list of integers")5259 }60+ if hasJSONNullElement(value) {61+ return Filter{}, errorsFilter("kinds must be a list of integers")62+ }5363 if err := json.Unmarshal(value, &f.Kinds); err != nil {5464 return Filter{}, errorsFilter("kinds must be a list of integers")5565 }5666 case key == "since", key == "until":67+ if isJSONNull(value) {68+ return Filter{}, errorsFilter(key + " must be an integer")69+ }5770 var n int645871 if err := json.Unmarshal(value, &n); err != nil {5972 return Filter{}, errorsFilter(key + " must be an integer")
@@ -64,12 +77,18 @@ func ParseFilter(raw []byte) (Filter, error) {
6477 f.Until = &n6578 }6679 case key == "limit":80+ if isJSONNull(value) {81+ return Filter{}, errorsFilter("limit must be an integer")82+ }6783 var n int6884 if err := json.Unmarshal(value, &n); err != nil {6985 return Filter{}, errorsFilter("limit must be an integer")7086 }7187 f.Limit = &n7288 case key == "search":89+ if isJSONNull(value) {90+ return Filter{}, errorsFilter("search must be a string")91+ }7392 if err := json.Unmarshal(value, &f.Search); err != nil {7493 return Filter{}, errorsFilter("search must be a string")7594 }
@@ -77,6 +96,9 @@ func ParseFilter(raw []byte) (Filter, error) {
7796 if !isJSONArray(value) {7897 return Filter{}, errorsFilter(key + " must be a list of strings")7998 }99+ if hasJSONNullElement(value) {100+ return Filter{}, errorsFilter(key + " must be a list of strings")101+ }80102 var out []string81103 if err := json.Unmarshal(value, &out); err != nil {82104 return Filter{}, errorsFilter(key + " must be a list of strings")
@@ -87,6 +109,23 @@ func ParseFilter(raw []byte) (Filter, error) {
87109 return f, nil88110 }89111 112+func isJSONNull(value json.RawMessage) bool {113+ return strings.TrimSpace(string(value)) == "null"114+}115+116+func hasJSONNullElement(value json.RawMessage) bool {117+ var values []json.RawMessage118+ if err := json.Unmarshal(value, &values); err != nil {119+ return false120+ }121+ for _, item := range values {122+ if isJSONNull(item) {123+ return true124+ }125+ }126+ return false127+}128+90129 // MarshalJSON encodes tag constraints with their Nostr #name wire keys.91130 func (f Filter) MarshalJSON() ([]byte, error) {92131 values := make(map[string]json.RawMessage, 7+len(f.Tags))
A protocol/nostr/fuzz_test.go +55 -0
@@ -0,0 +1,55 @@
1+package nostr2+3+import (4+ "strings"5+ "testing"6+)7+8+func TestParseFilterRejectsNonObjectsAndNullFields(t *testing.T) {9+ for _, raw := range []string{"null", "[]", `{"since":null}`, `{"until":null}`, `{"limit":null}`, `{"search":null}`, `{"ids":[null]}`, `{"authors":[null]}`, `{"kinds":[null]}`, `{"#t":[null]}`} {10+ if _, err := ParseFilter([]byte(raw)); err == nil {11+ t.Errorf("accepted invalid filter %s", raw)12+ }13+ }14+}15+16+// FuzzParse exercises the complete event wire boundary with arbitrary input.17+// Parse must reject malformed input without panicking.18+func FuzzParse(f *testing.F) {19+ f.Add([]byte(`{"id":"","pubkey":"","created_at":0,"kind":1,"tags":[],"content":"","sig":""}`))20+ f.Add([]byte(`null`))21+ f.Add([]byte(`{"id":1,"tags":null}`))22+ seed := Event{CreatedAt: 1_700_000_000, Kind: 1, Tags: [][]string{{"t", "seed"}}, Content: "valid seed"}23+ if err := Sign(&seed, strings.Repeat("0", 63)+"1"); err != nil {24+ f.Fatal(err)25+ }26+ encoded, err := Canonical(seed)27+ if err != nil {28+ f.Fatal(err)29+ }30+ f.Add(encoded)31+ f.Fuzz(func(t *testing.T, raw []byte) {32+ _, _ = Parse(raw)33+ })34+}35+36+// FuzzParseFilter covers both ordinary and #tag filter keys and checks that37+// accepted filters remain safe to marshal and parse again.38+func FuzzParseFilter(f *testing.F) {39+ f.Add([]byte(`{}`))40+ f.Add([]byte(`{"#t":["go"],"kinds":[1],"limit":10}`))41+ f.Add([]byte(`{"ids":null,"since":-1,"search":""}`))42+ f.Fuzz(func(t *testing.T, raw []byte) {43+ filter, err := ParseFilter(raw)44+ if err != nil {45+ return46+ }47+ encoded, err := filter.MarshalJSON()48+ if err != nil {49+ t.Fatalf("marshal accepted filter: %v", err)50+ }51+ if _, err := ParseFilter(encoded); err != nil {52+ t.Fatalf("accepted filter did not round trip: %v", err)53+ }54+ })55+}