From efac94298c546b718536ebec35a64dd2eadb0604 Mon Sep 17 00:00:00 2001 From: Peter Sanchez Date: Wed, 28 Aug 2024 17:31:30 -0600 Subject: [PATCH] Adding rate limiting support to api server --- api/api_test.go | 5 +- api/graph/generated.go | 88 ++++++++++++++++++++++++++++++++--- api/graph/schema.graphqls | 6 +-- api/graph/schema.resolvers.go | 4 +- cmd/api/main.go | 77 ++++++++++++++++++++++++++++++ config.example.ini | 13 ++++++ go.mod | 2 +- 7 files changed, 182 insertions(+), 13 deletions(-) diff --git a/api/api_test.go b/api/api_test.go index 089a95d..71545f2 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -774,6 +774,9 @@ func TestAPI(t *testing.T) { }) t.Run("org add member already existing user", func(t *testing.T) { + ctxNoAuth := server.ServerContext(context.Background(), srv) + ctxNoAuth = auth.Context(ctxNoAuth, test.NewTestUser(1, false, false, false, false)) + ctxNoAuth = crypto.Context(ctxNoAuth, entropy) type GraphQLResponseUser struct { User models.User `json:"register"` } @@ -790,7 +793,7 @@ func TestAPI(t *testing.T) { op.Var("email", "already@existing.com") op.Var("pass", "qwerty") op.Var("username", "already_existing") - err := links.Execute(ctx, op, &resultUser) + err := links.Execute(ctxNoAuth, op, &resultUser) c.NoError(err) user := resultUser.User user.SetVerified(true) diff --git a/api/graph/generated.go b/api/graph/generated.go index afaa93a..165de96 100644 --- a/api/graph/generated.go +++ b/api/graph/generated.go @@ -9713,8 +9713,28 @@ func (ec *executionContext) _Mutation_register(ctx context.Context, field graphq } }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().Register(rctx, fc.Args["input"].(*model.RegisterInput)) + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().Register(rctx, fc.Args["input"].(*model.RegisterInput)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + if ec.directives.Internal == nil { + return nil, errors.New("directive internal is not implemented") + } + return ec.directives.Internal(ctx, nil, directive0) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*models.User); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/models.User`, tmp) }) if err != nil { ec.Error(ctx, err) @@ -11204,8 +11224,36 @@ func (ec *executionContext) _Mutation_addQRCode(ctx context.Context, field graph } }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Mutation().AddQRCode(rctx, fc.Args["input"].(model.AddQRCodeInput)) + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Mutation().AddQRCode(rctx, fc.Args["input"].(model.AddQRCodeInput)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "QRCODES") + if err != nil { + return nil, err + } + kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RW") + if err != nil { + return nil, err + } + if ec.directives.Access == nil { + return nil, errors.New("directive access is not implemented") + } + return ec.directives.Access(ctx, nil, directive0, scope, kind) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.(*models.QRCode); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be *links/models.QRCode`, tmp) }) if err != nil { ec.Error(ctx, err) @@ -17589,8 +17637,36 @@ func (ec *executionContext) _Query_getQRList(ctx context.Context, field graphql. } }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().GetQRList(rctx, fc.Args["orgSlug"].(string), fc.Args["codeType"].(int), fc.Args["elementId"].(int)) + directive0 := func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.Query().GetQRList(rctx, fc.Args["orgSlug"].(string), fc.Args["codeType"].(int), fc.Args["elementId"].(int)) + } + directive1 := func(ctx context.Context) (interface{}, error) { + scope, err := ec.unmarshalNAccessScope2linksᚋapiᚋgraphᚋmodelᚐAccessScope(ctx, "QRCODES") + if err != nil { + return nil, err + } + kind, err := ec.unmarshalNAccessKind2linksᚋapiᚋgraphᚋmodelᚐAccessKind(ctx, "RO") + if err != nil { + return nil, err + } + if ec.directives.Access == nil { + return nil, errors.New("directive access is not implemented") + } + return ec.directives.Access(ctx, nil, directive0, scope, kind) + } + + tmp, err := directive1(rctx) + if err != nil { + return nil, graphql.ErrorOnPath(ctx, err) + } + if tmp == nil { + return nil, nil + } + if data, ok := tmp.([]*model.QRObject); ok { + return data, nil + } + return nil, fmt.Errorf(`unexpected type %T from directive, should be []*links/api/graph/model.QRObject`, tmp) }) if err != nil { ec.Error(ctx, err) diff --git a/api/graph/schema.graphqls b/api/graph/schema.graphqls index f3b46d3..c0c35da 100644 --- a/api/graph/schema.graphqls +++ b/api/graph/schema.graphqls @@ -727,7 +727,7 @@ type Query { getListingLink(slug: String!, id: Int!, domainId: Int!): ListingLink! @access(scope: LISTS, kind: RO) "Returns an array of QR codes" - getQRList(orgSlug: String!, codeType: Int!, elementId: Int!): [QRObject]! + getQRList(orgSlug: String!, codeType: Int!, elementId: Int!): [QRObject]! @access(scope: QRCODES, kind: RO) "Returns a specific QR code" getQRDetail(hashId: String!, orgSlug: String): QRCode! @access(scope: QRCODES, kind: RO) @@ -766,7 +766,7 @@ type Mutation { confirmMember(key: String!): AddMemberPayload! @access(scope: PROFILE, kind: RW) "Register an account" - register(input: RegisterInput): User! + register(input: RegisterInput): User! @internal completeRegister(input: CompleteRegisterInput): User! @access(scope: PROFILE, kind: RW) "Update user profile" @@ -793,7 +793,7 @@ type Mutation { deleteListingLink(id: Int!): DeletePayload! @access(scope: LISTS, kind: RW) "Add/Delete QR codes from short links and link listings" - addQRCode(input: AddQRCodeInput!): QRCode! + addQRCode(input: AddQRCodeInput!): QRCode! @access(scope: QRCODES, kind: RW) deleteQRCode(id: Int!): DeletePayload! @access(scope: QRCODES, kind: RW) "Un/Follow an organization as the calling user" diff --git a/api/graph/schema.resolvers.go b/api/graph/schema.resolvers.go index 978590b..4ab9927 100644 --- a/api/graph/schema.resolvers.go +++ b/api/graph/schema.resolvers.go @@ -2,7 +2,7 @@ package graph // This file will be automatically regenerated based on the schema, any resolver implementations // will be copied through when generating and any unknown code will be moved to the end. -// Code generated by github.com/99designs/gqlgen version v0.17.29 +// Code generated by github.com/99designs/gqlgen version v0.17.49 import ( "bytes" @@ -40,7 +40,7 @@ import ( "golang.org/x/image/draw" "golang.org/x/net/idna" "netlandish.com/x/gobwebs" - oauth2 "netlandish.com/x/gobwebs-oauth2" + "netlandish.com/x/gobwebs-oauth2" gaccounts "netlandish.com/x/gobwebs/accounts" "netlandish.com/x/gobwebs/crypto" "netlandish.com/x/gobwebs/database" diff --git a/cmd/api/main.go b/cmd/api/main.go index 9f8509e..0d1a3a7 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/hex" "fmt" "html/template" "links" @@ -12,15 +13,20 @@ import ( "links/api/loaders" "links/cmd" "links/core" + "net" "net/url" "os" "strconv" + "strings" "time" work "git.sr.ht/~sircmpwn/dowork" "github.com/99designs/gqlgen/graphql" "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "golang.org/x/time/rate" gobwebsgql "netlandish.com/x/gobwebs-graphql" + oauth2 "netlandish.com/x/gobwebs-oauth2" feedback "netlandish.com/x/gobwebs-ses-feedback" "netlandish.com/x/gobwebs/config" "netlandish.com/x/gobwebs/crypto" @@ -69,6 +75,46 @@ func run() error { } } + var wlnets []*net.IPNet + if val, ok := config.File.Get("links", "rate-limit-whitelist"); ok { + for _, nr := range strings.Split(val, ",") { + nr = strings.TrimSpace(nr) + _, subnet, err := net.ParseCIDR(nr) + if err != nil { + return fmt.Errorf("links:rate-limit-whitelist %s is invalid", nr) + } + wlnets = append(wlnets, subnet) + } + } else { + _, subnet, _ := net.ParseCIDR("127.0.0.0/8") + wlnets = append(wlnets, subnet) + } + + rlnums := struct { + Limit int + Burst int + Expire time.Duration + }{20, 40, 3 * time.Minute} + if val, ok := config.File.Get("links", "rate-limit-limit"); ok { + rlnums.Limit, err = strconv.Atoi(val) + if err != nil { + return fmt.Errorf("links:rate-limit-limit must be an integer value") + } + } + if val, ok := config.File.Get("links", "rate-limit-burst"); ok { + rlnums.Burst, err = strconv.Atoi(val) + if err != nil { + return fmt.Errorf("links:rate-limit-burst must be an integer value") + } + } + if val, ok := config.File.Get("links", "rate-limit-expire"); ok { + expire, err := strconv.Atoi(val) + if err != nil { + return fmt.Errorf("links:rate-limit-expire must be an integer value") + } + rlnums.Expire = time.Duration(expire) * time.Minute + } + esvc, err := cmd.LoadEmailService(config) if err != nil { return fmt.Errorf("unable to load email service: %v", err) @@ -98,6 +144,36 @@ func run() error { Sessions: false, ServerContext: true, } + + rlConfig := middleware.RateLimiterConfig{ + Skipper: func(c echo.Context) bool { + ip := net.ParseIP(c.RealIP()) + for _, subnet := range wlnets { + if subnet.Contains(ip) { + return true + } + } + return false + }, + Store: middleware.NewRateLimiterMemoryStoreWithConfig( + middleware.RateLimiterMemoryStoreConfig{ + Rate: rate.Limit(rlnums.Limit), + Burst: rlnums.Burst, + ExpiresIn: rlnums.Expire, + }, + ), + IdentifierExtractor: func(c echo.Context) (string, error) { + tuser := oauth2.ForContext(c.Request().Context()) + if tuser != nil { + hashStr := hex.EncodeToString(tuser.TokenHash[:]) + if hashStr != "" { + return hashStr, nil + } + } + return c.RealIP(), nil + }, + } + srv := server.New(e, db, config). Initialize(). WithAppInfo("links-api", Version). @@ -111,6 +187,7 @@ func run() error { core.TimezoneContext(), crypto.Middleware(entropy), core.InternalAuthMiddleware(accounts.NewUserFetch()), + middleware.RateLimiterWithConfig(rlConfig), ) // graphql diff --git a/config.example.ini b/config.example.ini index b73b6f2..9bd402a 100644 --- a/config.example.ini +++ b/config.example.ini @@ -169,6 +169,19 @@ api-listen-port=8003 domains-listen-address=localhost domains-listen-port=8004 +# White list IP ranges (MUST be in network/mask format) from the system +# rate limiting functions +rate-limit-whitelist=127.0.0.0/8 + +# How many API calls per minute are allowed +rate-limit-limit=20 + +# How many are allowed in a burst moment +rate-limit-burst=40 + +# How long (in minutes) of inactivity does the limit record live for +rate-limit-expire=3 + [stripe] secret-key= public-key= diff --git a/go.mod b/go.mod index c23eb34..f7d1cc9 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( golang.org/x/image v0.0.0-20211028202545-6944b10bf410 golang.org/x/net v0.26.0 golang.org/x/text v0.16.0 + golang.org/x/time v0.5.0 netlandish.com/x/gobwebs v0.0.0-20240815220346-26dc25c49eef netlandish.com/x/gobwebs-formguard v0.0.0-20231224192353-29706d23f156 netlandish.com/x/gobwebs-graphql v0.0.0-20240827211219-adfac6aa2e95 @@ -154,7 +155,6 @@ require ( golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.22.0 // indirect - golang.org/x/time v0.5.0 // indirect golang.org/x/tools v0.22.0 // indirect golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect google.golang.org/api v0.62.0 // indirect -- 2.45.2