Compare commits

..

24 Commits

Author SHA1 Message Date
fiatjaf
3f73a9690a GetConnection() and GetAuthed() may return empty. 2024-07-03 22:16:44 -03:00
fiatjaf
2a8b704299 returning a nil chan from QueryEvents causes an immediate eose from that source. 2024-05-29 07:51:48 -03:00
fiatjaf
746f030f46 I'm a teapot -> Too many requests 2024-05-17 20:58:17 -03:00
fiatjaf
81ad56e85c simplify RestrictToSpecifiedKinds() 2024-05-12 20:54:53 -03:00
fiatjaf
f8afb51ee9 ratelimits. 2024-05-12 20:37:00 -03:00
fiatjaf
848e76c664 do not notify listeners when a duplicated event is received. 2024-04-26 14:56:50 -03:00
fiatjaf
8b1a7f2195 ApplySaneDefaults() 2024-04-19 15:38:34 -03:00
fiatjaf
8557c7a8dc policy to reject events with base64 media. 2024-04-19 15:33:15 -03:00
fiatjaf
f1f54a7bf3 stop and error on delete failed. 2024-04-18 21:20:46 -03:00
fiatjaf
e03a02fed7 prevent storing duplicates. 2024-04-18 21:20:35 -03:00
fiatjaf
255f7bc827 delete all previous replaceable events by default. 2024-04-10 21:34:23 -03:00
fiatjaf
3214dac302 fix pre-search on policies. 2024-03-30 14:23:17 -03:00
fiatjaf
5efadf6256 do not give away so much. 2024-03-29 18:25:47 -03:00
fiatjaf
27d6769009 format last commit. 2024-03-29 18:24:44 -03:00
Sebastix
44baacac42 * sort kinds before the binary search is run
* optimized return messages with more context why the policy blocks an event
2024-03-29 18:24:21 -03:00
fiatjaf
35053f6215 when LimitZero don't do any database queries. 2024-03-29 08:12:39 -03:00
fiatjaf
8854ad7a95 don't send a NOTICE when REQs are rejected anymore, just the CLOSED. 2024-03-25 10:55:59 -03:00
fiatjaf
c5c17029ba basic kind validation policy. 2024-03-13 12:40:54 -03:00
fiatjaf
e174dd6a95 support 1, 11 and 70 on NIP-11 list. 2024-02-13 12:24:06 -03:00
fiatjaf
cd4c25c845 implement NIP-70 ["-"] tag support. 2024-02-13 12:22:15 -03:00
fiatjaf
9b43da0b17 use stdlib "slices". 2024-02-08 16:35:35 -03:00
fiatjaf
e9bcad8614 policies that remove elements from the query should just cancel the query if they remove everything. 2024-02-07 08:38:42 -03:00
fiatjaf
eb83307005 update dependencies. 2024-01-18 18:20:39 -03:00
fiatjaf
d721fcdd67 make overwriting and broadcasting work for kind:5 delete events too. 2024-01-18 18:20:24 -03:00
21 changed files with 365 additions and 87 deletions

View File

@@ -69,6 +69,11 @@ func main() {
// there are many other configurable things you can set
relay.RejectEvent = append(relay.RejectEvent,
// built-in policies
policies.ValidateKind,
// define your own policies
policies.PreventLargeTags(80),
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if event.PubKey == "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" {
return true, "we don't allow this person to write here"
@@ -79,6 +84,10 @@ func main() {
// you can request auth by rejecting an event or a request with the prefix "auth-required: "
relay.RejectFilter = append(relay.RejectFilter,
// built-in policies
policies.NoComplexFilters,
// define your own policies
func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if pubkey := khatru.GetAuthed(ctx); pubkey != "" {
log.Printf("request from %s\n", pubkey)
@@ -119,3 +128,13 @@ Fear no more. Using the https://github.com/fiatjaf/eventstore module you get a b
relay.CountEvents = append(relay.CountEvents, db.CountEvents)
relay.DeleteEvent = append(relay.DeleteEvent, db.DeleteEvent)
```
### But I don't want to write a bunch of custom policies!
Fear no more. We have a bunch of common policies written in the `github.com/fiatjaf/khatru/policies` package and also a handpicked selection of base sane defaults, which you can apply with:
```go
policies.ApplySaneDefaults(relay)
```
Contributions to this are very much welcomed.

View File

@@ -10,17 +10,17 @@ import (
)
// AddEvent sends an event through then normal add pipeline, as if it was received from a websocket.
func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) (skipBroadcast bool, writeError error) {
if evt == nil {
return errors.New("error: event is nil")
return false, errors.New("error: event is nil")
}
for _, reject := range rl.RejectEvent {
if reject, msg := reject(ctx, evt); reject {
if msg == "" {
return errors.New("blocked: no reason")
return false, errors.New("blocked: no reason")
} else {
return errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
return false, errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
}
}
}
@@ -31,29 +31,53 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
oee(ctx, evt)
}
} else {
// will store
// but first check if we already have it
filter := nostr.Filter{IDs: []string{evt.ID}}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
if err != nil {
continue
}
for range ch {
// if we run this it means we already have this event, so we just return a success and exit
return true, nil
}
}
// if it's replaceable we first delete old versions
if evt.Kind == 0 || evt.Kind == 3 || (10000 <= evt.Kind && evt.Kind < 20000) {
// replaceable event, delete before storing
filter := nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}})
ch, err := query(ctx, filter)
if err != nil {
continue
}
if previous := <-ch; previous != nil && isOlder(previous, evt) {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
for previous := range ch {
if isOlder(previous, evt) {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
}
}
}
} else if 30000 <= evt.Kind && evt.Kind < 40000 {
// parameterized replaceable event, delete before storing
d := evt.Tags.GetFirst([]string{"d", ""})
if d != nil {
for _, query := range rl.QueryEvents {
ch, err := query(ctx, nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}, Tags: nostr.TagMap{"d": []string{d.Value()}}})
if err != nil {
continue
}
if previous := <-ch; previous != nil && isOlder(previous, evt) {
if d == nil {
return false, fmt.Errorf("invalid: missing 'd' tag on parameterized replaceable event")
}
filter := nostr.Filter{Authors: []string{evt.PubKey}, Kinds: []int{evt.Kind}, Tags: nostr.TagMap{"d": []string{(*d)[1]}}}
for _, query := range rl.QueryEvents {
ch, err := query(ctx, filter)
if err != nil {
continue
}
for previous := range ch {
if isOlder(previous, evt) {
for _, del := range rl.DeleteEvent {
del(ctx, previous)
}
@@ -67,9 +91,9 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
if saveErr := store(ctx, evt); saveErr != nil {
switch saveErr {
case eventstore.ErrDupEvent:
return nil
return true, nil
default:
return fmt.Errorf(nostr.NormalizeOKMessage(saveErr.Error(), "error"))
return false, fmt.Errorf(nostr.NormalizeOKMessage(saveErr.Error(), "error"))
}
}
}
@@ -79,10 +103,5 @@ func (rl *Relay) AddEvent(ctx context.Context, evt *nostr.Event) error {
}
}
for _, ovw := range rl.OverwriteResponseEvent {
ovw(ctx, evt)
}
notifyListeners(evt)
return nil
return false, nil
}

View File

@@ -34,7 +34,9 @@ func (rl *Relay) handleDeleteRequest(ctx context.Context, evt *nostr.Event) erro
if acceptDeletion {
// delete it
for _, del := range rl.DeleteEvent {
del(ctx, target)
if err := del(ctx, target); err != nil {
return err
}
}
} else {
// fail and stop here

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"net/http"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/eventstore/elasticsearch"
"github.com/fiatjaf/khatru"
)
func main() {

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"net/http"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/eventstore/postgresql"
"github.com/fiatjaf/khatru"
)
func main() {

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"net/http"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/eventstore/sqlite3"
"github.com/fiatjaf/khatru"
)
func main() {

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"github.com/fiatjaf/khatru"
"github.com/fiatjaf/khatru/policies"
"github.com/nbd-wtf/go-nostr"
)
@@ -53,6 +54,11 @@ func main() {
// there are many other configurable things you can set
relay.RejectEvent = append(relay.RejectEvent,
// built-in policies
policies.ValidateKind,
// define your own policies
policies.PreventLargeTags(80),
func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
if event.PubKey == "fa984bd7dbb282f07e16e7ae87b26a2a7b9b90b7246a44771f0cf5ae58018f52" {
return true, "we don't allow this person to write here"
@@ -63,6 +69,10 @@ func main() {
// you can request auth by rejecting an event or a request with the prefix "auth-required: "
relay.RejectFilter = append(relay.RejectFilter,
// built-in policies
policies.NoComplexFilters,
// define your own policies
func(ctx context.Context, filter nostr.Filter) (reject bool, msg string) {
if pubkey := khatru.GetAuthed(ctx); pubkey != "" {
log.Printf("request from %s\n", pubkey)

15
go.mod
View File

@@ -3,13 +3,11 @@ module github.com/fiatjaf/khatru
go 1.21.4
require (
github.com/fasthttp/websocket v1.5.3
github.com/fiatjaf/eventstore v0.3.1
github.com/nbd-wtf/go-nostr v0.28.0
github.com/fasthttp/websocket v1.5.7
github.com/fiatjaf/eventstore v0.3.8
github.com/nbd-wtf/go-nostr v0.30.0
github.com/puzpuzpuz/xsync/v3 v3.0.2
github.com/rs/cors v1.7.0
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a
golang.org/x/exp v0.0.0-20231006140011-7918f672742d
)
require (
@@ -39,7 +37,7 @@ require (
github.com/google/flatbuffers v23.5.26+incompatible // indirect
github.com/jmoiron/sqlx v1.3.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.2 // indirect
github.com/klauspost/compress v1.17.3 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v1.14.18 // indirect
@@ -49,9 +47,10 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.47.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.18.0 // indirect
golang.org/x/sys v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)

26
go.sum
View File

@@ -43,12 +43,12 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek=
github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs=
github.com/fasthttp/websocket v1.5.7 h1:0a6o2OfeATvtGgoMKleURhLT6JqWPg7fYfWnH4KHau4=
github.com/fasthttp/websocket v1.5.7/go.mod h1:bC4fxSono9czeXHQUVKxsC0sNjbm7lPJR04GDFqClfU=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fiatjaf/eventstore v0.3.1 h1:GDuF8RxBNL6km9Y7qEucDQbkzKfkPJqoA/YiiIE0wao=
github.com/fiatjaf/eventstore v0.3.1/go.mod h1:q3r6SuNhCxaSwfnK++2BratEVUcwK2NNr004hLOpz7Q=
github.com/fiatjaf/eventstore v0.3.8 h1:q4jcN95O2CVA+wP47V25BcVSNvjfOiPPIWgPmQ6hTRk=
github.com/fiatjaf/eventstore v0.3.8/go.mod h1:Qsm5loQICkazpsj8tQmcOK95AVkQQNF09Xx/NS/Biow=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
@@ -103,8 +103,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.2 h1:RlWWUY/Dr4fL8qk9YG7DTZ7PDgME2V4csBXA8L/ixi4=
github.com/klauspost/compress v1.17.2/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -113,8 +113,8 @@ github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJ
github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/nbd-wtf/go-nostr v0.28.0 h1:SLYyoFeCNYb7HyWtmPUzD6rifBOMR66Spj5fzCk+5GE=
github.com/nbd-wtf/go-nostr v0.28.0/go.mod h1:OQ8sNLFJnsj17BdqZiLSmjJBIFTfDqckEYC3utS4qoY=
github.com/nbd-wtf/go-nostr v0.30.0 h1:rN085pe4IxmSBVht8LChZbWLggonjA8hPIk8l4/+Hjk=
github.com/nbd-wtf/go-nostr v0.30.0/go.mod h1:tiKJY6fWYSujbTQb201Y+IQ3l4szqYVt+fsTnsm7FCk=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -126,8 +126,6 @@ github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik=
github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk=
github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g=
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a h1:iLcLb5Fwwz7g/DLK89F+uQBDeAhHhwdzB5fSlVdhGcM=
github.com/sebest/xff v0.0.0-20210106013422-671bd2870b3a/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -146,8 +144,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.47.0 h1:y7moDoxYzMooFpT5aHgNgVOQDrS3qlkfiP9mDtGGK9c=
github.com/valyala/fasthttp v1.47.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
@@ -172,8 +170,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@@ -34,6 +34,13 @@ func (rl *Relay) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
for _, reject := range rl.RejectConnection {
if reject(r) {
w.WriteHeader(429) // Too many requests
return
}
}
conn, err := rl.upgrader.Upgrade(w, r, nil)
if err != nil {
rl.Log.Printf("failed to upgrade websocket: %v\n", err)
@@ -134,19 +141,51 @@ func (rl *Relay) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
return
}
// check NIP-70 protected
for _, v := range env.Event.Tags {
if len(v) == 1 && v[0] == "-" {
msg := "must be published by event author"
authed := GetAuthed(ctx)
if authed == "" {
RequestAuth(ctx)
ws.WriteJSON(nostr.OKEnvelope{
EventID: env.Event.ID,
OK: false,
Reason: "auth-required: " + msg,
})
return
}
if authed != env.Event.PubKey {
ws.WriteJSON(nostr.OKEnvelope{
EventID: env.Event.ID,
OK: false,
Reason: "blocked: " + msg,
})
return
}
}
}
var ok bool
var writeErr error
var skipBroadcast bool
if env.Event.Kind == 5 {
// this always returns "blocked: " whenever it returns an error
writeErr = rl.handleDeleteRequest(ctx, &env.Event)
} else {
// this will also always return a prefixed reason
writeErr = rl.AddEvent(ctx, &env.Event)
skipBroadcast, writeErr = rl.AddEvent(ctx, &env.Event)
}
var reason string
if writeErr == nil {
ok = true
for _, ovw := range rl.OverwriteResponseEvent {
ovw(ctx, &env.Event)
}
if !skipBroadcast {
notifyListeners(&env.Event)
}
} else {
reason = writeErr.Error()
if strings.HasPrefix(reason, "auth-required:") {

View File

@@ -1,6 +1,7 @@
package khatru
import (
"net"
"net/http"
"strconv"
"strings"
@@ -34,3 +35,43 @@ func getServiceBaseURL(r *http.Request) string {
}
return proto + "://" + host
}
var privateMasks = func() []net.IPNet {
privateCIDRs := []string{
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"fc00::/7",
}
masks := make([]net.IPNet, len(privateCIDRs))
for i, cidr := range privateCIDRs {
_, netw, err := net.ParseCIDR(cidr)
if err != nil {
return nil
}
masks[i] = *netw
}
return masks
}()
func isPrivate(ip net.IP) bool {
for _, mask := range privateMasks {
if mask.Contains(ip) {
return true
}
}
return false
}
func GetIPFromRequest(r *http.Request) string {
if xffh := r.Header.Get("X-Forwarded-For"); xffh != "" {
for _, v := range strings.Split(xffh, ",") {
if ip := net.ParseIP(strings.TrimSpace(v)); ip != nil && ip.IsGlobalUnicast() && !isPrivate(ip) {
return ip.String()
}
}
}
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
return ip
}

View File

@@ -2,9 +2,11 @@ package policies
import (
"context"
"fmt"
"slices"
"strings"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
// PreventTooManyIndexableTags returns a function that can be used as a RejectFilter that will reject
@@ -13,6 +15,9 @@ import (
// If ignoreKinds is given this restriction will not apply to these kinds (useful for allowing a bigger).
// If onlyKinds is given then all other kinds will be ignored.
func PreventTooManyIndexableTags(max int, ignoreKinds []int, onlyKinds []int) func(context.Context, *nostr.Event) (bool, string) {
slices.Sort(ignoreKinds)
slices.Sort(onlyKinds)
ignore := func(kind int) bool { return false }
if len(ignoreKinds) > 0 {
ignore = func(kind int) bool {
@@ -62,32 +67,15 @@ func PreventLargeTags(maxTagValueLen int) func(context.Context, *nostr.Event) (b
// RestrictToSpecifiedKinds returns a function that can be used as a RejectFilter that will reject
// any events with kinds different than the specified ones.
func RestrictToSpecifiedKinds(kinds ...uint16) func(context.Context, *nostr.Event) (bool, string) {
max := 0
min := 0
for _, kind := range kinds {
if int(kind) > max {
max = int(kind)
}
if int(kind) < min {
min = int(kind)
}
}
// sort the kinds in increasing order
slices.Sort(kinds)
return func(ctx context.Context, event *nostr.Event) (reject bool, msg string) {
// these are cheap and very questionable optimizations, but they exist for a reason:
// we would have to ensure that the kind number is within the bounds of a uint16 anyway
if event.Kind > max {
return true, "event kind not allowed"
}
if event.Kind < min {
return true, "event kind not allowed"
}
// hopefully this map of uint16s is very fast
if _, allowed := slices.BinarySearch(kinds, uint16(event.Kind)); allowed {
return false, ""
}
return true, "event kind not allowed"
return true, fmt.Sprintf("received event kind %d not allowed", event.Kind)
}
}
@@ -108,3 +96,7 @@ func PreventTimestampsInTheFuture(thresholdSeconds nostr.Timestamp) func(context
return false, ""
}
}
func RejectEventsWithBase64Media(ctx context.Context, evt *nostr.Event) (bool, string) {
return strings.Contains(evt.Content, "data:image/") || strings.Contains(evt.Content, "data:video/"), "event with base64 media"
}

View File

@@ -2,9 +2,9 @@ package policies
import (
"context"
"slices"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
// NoComplexFilters disallows filters with more than 2 tags.
@@ -45,7 +45,10 @@ func NoSearchQueries(ctx context.Context, filter nostr.Filter) (reject bool, msg
}
func RemoveSearchQueries(ctx context.Context, filter *nostr.Filter) {
filter.Search = ""
if filter.Search != "" {
filter.Search = ""
filter.LimitZero = true // signals that this query should be just skipped
}
}
func RemoveAllButKinds(kinds ...uint16) func(context.Context, *nostr.Filter) {
@@ -58,15 +61,23 @@ func RemoveAllButKinds(kinds ...uint16) func(context.Context, *nostr.Filter) {
}
}
filter.Kinds = newKinds
if len(filter.Kinds) == 0 {
filter.LimitZero = true // signals that this query should be just skipped
}
}
}
}
func RemoveAllButTags(tagNames ...string) func(context.Context, *nostr.Filter) {
return func(ctx context.Context, filter *nostr.Filter) {
for tagName := range filter.Tags {
if !slices.Contains(tagNames, tagName) {
delete(filter.Tags, tagName)
if n := len(filter.Tags); n > 0 {
for tagName := range filter.Tags {
if !slices.Contains(tagNames, tagName) {
delete(filter.Tags, tagName)
}
}
if len(filter.Tags) == 0 {
filter.LimitZero = true // signals that this query should be just skipped
}
}
}

43
policies/helpers.go Normal file
View File

@@ -0,0 +1,43 @@
package policies
import (
"sync/atomic"
"time"
"github.com/puzpuzpuz/xsync/v3"
)
func startRateLimitSystem[K comparable](
tokensPerInterval int,
interval time.Duration,
maxTokens int,
) func(key K) (ratelimited bool) {
negativeBuckets := xsync.NewMapOf[K, *atomic.Int32]()
maxTokensInt32 := int32(maxTokens)
go func() {
for {
time.Sleep(interval)
negativeBuckets.Range(func(key K, bucket *atomic.Int32) bool {
newv := bucket.Add(int32(-tokensPerInterval))
if newv <= 0 {
negativeBuckets.Delete(key)
}
return true
})
}
}()
return func(key K) bool {
nb, _ := negativeBuckets.LoadOrStore(key, &atomic.Int32{})
if nb.Load() < maxTokensInt32 {
nb.Add(1)
// rate limit not reached yet
return false
}
// rate limit reached
return true
}
}

View File

@@ -0,0 +1,29 @@
package policies
import (
"context"
"encoding/json"
"github.com/nbd-wtf/go-nostr"
)
func ValidateKind(ctx context.Context, evt *nostr.Event) (bool, string) {
switch evt.Kind {
case 0:
var m struct {
Name string `json:"name"`
}
json.Unmarshal([]byte(evt.Content), &m)
if m.Name == "" {
return true, "missing json name in kind 0"
}
case 1:
return false, ""
case 2:
return true, "this kind has been deprecated"
}
// TODO: all other kinds
return false, ""
}

View File

@@ -3,9 +3,10 @@ package policies
import (
"context"
"slices"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
"golang.org/x/exp/slices"
)
// RejectKind04Snoopers prevents reading NIP-04 messages from people not involved in the conversation.

42
policies/ratelimits.go Normal file
View File

@@ -0,0 +1,42 @@
package policies
import (
"context"
"net/http"
"time"
"github.com/fiatjaf/khatru"
"github.com/nbd-wtf/go-nostr"
)
func EventIPRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
return rl(khatru.GetIP(ctx)), "rate-limited: slow down, please"
}
}
func EventPubKeyRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ *nostr.Event) (reject bool, msg string) {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(ctx context.Context, evt *nostr.Event) (reject bool, msg string) {
return rl(evt.PubKey), "rate-limited: slow down, please"
}
}
func ConnectionRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(r *http.Request) bool {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(r *http.Request) bool {
return rl(khatru.GetIPFromRequest(r))
}
}
func FilterIPRateLimiter(tokensPerInterval int, interval time.Duration, maxTokens int) func(ctx context.Context, _ nostr.Filter) (reject bool, msg string) {
rl := startRateLimitSystem[string](tokensPerInterval, interval, maxTokens)
return func(ctx context.Context, _ nostr.Filter) (reject bool, msg string) {
return rl(khatru.GetIP(ctx)), "rate-limited: there is a bug in the client, no one should be making so many requests"
}
}

24
policies/sane_defaults.go Normal file
View File

@@ -0,0 +1,24 @@
package policies
import (
"time"
"github.com/fiatjaf/khatru"
)
func ApplySaneDefaults(relay *khatru.Relay) {
relay.RejectEvent = append(relay.RejectEvent,
RejectEventsWithBase64Media,
EventIPRateLimiter(2, time.Minute*3, 5),
)
relay.RejectFilter = append(relay.RejectFilter,
NoEmptyFilters,
NoComplexFilters,
FilterIPRateLimiter(20, time.Minute, 100),
)
relay.RejectConnection = append(relay.RejectConnection,
ConnectionRateLimiter(1, time.Minute*5, 3),
)
}

View File

@@ -20,7 +20,7 @@ func NewRelay() *Relay {
Info: &nip11.RelayInformationDocument{
Software: "https://github.com/fiatjaf/khatru",
Version: "n/a",
SupportedNIPs: make([]int, 0),
SupportedNIPs: []int{1, 11, 70},
},
upgrader: websocket.Upgrader{
@@ -45,6 +45,7 @@ type Relay struct {
RejectEvent []func(ctx context.Context, event *nostr.Event) (reject bool, msg string)
RejectFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
RejectCountFilter []func(ctx context.Context, filter nostr.Filter) (reject bool, msg string)
RejectConnection []func(r *http.Request) bool
OverwriteDeletionOutcome []func(ctx context.Context, target *nostr.Event, deletion *nostr.Event) (acceptDeletion bool, msg string)
OverwriteResponseEvent []func(ctx context.Context, event *nostr.Event)
OverwriteFilter []func(ctx context.Context, filter *nostr.Filter)

View File

@@ -17,9 +17,8 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
ovw(ctx, &filter)
}
if filter.Limit < 0 {
// this is a special situation through which the implementor signals to us that it doesn't want
// to event perform any queries whatsoever
if filter.LimitZero {
// don't do any queries, just subscribe to future events
return nil
}
@@ -29,7 +28,6 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
// filter we can just reject it)
for _, reject := range rl.RejectFilter {
if reject, msg := reject(ctx, filter); reject {
ws.WriteJSON(nostr.NoticeEnvelope(msg))
return errors.New(nostr.NormalizeOKMessage(msg, "blocked"))
}
}
@@ -43,6 +41,9 @@ func (rl *Relay) handleRequest(ctx context.Context, id string, eose *sync.WaitGr
ws.WriteJSON(nostr.NoticeEnvelope(err.Error()))
eose.Done()
continue
} else if ch == nil {
eose.Done()
continue
}
go func(ch chan *nostr.Event) {

View File

@@ -4,7 +4,6 @@ import (
"context"
"github.com/nbd-wtf/go-nostr"
"github.com/sebest/xff"
)
const (
@@ -23,15 +22,23 @@ func RequestAuth(ctx context.Context) {
}
func GetConnection(ctx context.Context) *WebSocket {
return ctx.Value(wsKey).(*WebSocket)
wsi := ctx.Value(wsKey)
if wsi != nil {
return wsi.(*WebSocket)
}
return nil
}
func GetAuthed(ctx context.Context) string {
return GetConnection(ctx).AuthedPublicKey
conn := GetConnection(ctx)
if conn != nil {
return conn.AuthedPublicKey
}
return ""
}
func GetIP(ctx context.Context) string {
return xff.GetRemoteAddr(GetConnection(ctx).Request)
return GetIPFromRequest(GetConnection(ctx).Request)
}
func GetSubscriptionID(ctx context.Context) string {