diff --git a/proxy/proxy.go b/proxy/proxy.go index 7dfb5d4..ff47038 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -4,12 +4,14 @@ import ( "crypto/tls" "crypto/x509" "fmt" + "math" "net" "net/http" "net/http/httputil" "os" "strconv" "strings" + "time" "github.com/lightninglabs/aperture/auth" "github.com/lightninglabs/aperture/l402" @@ -172,6 +174,26 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Determine auth level required to access service and dispatch request // accordingly. authLevel := target.AuthRequired(r) + + // checkRateLimit is a helper that checks rate limits after determining + // the authentication status. This ensures we only use L402 token IDs + // for authenticated requests, preventing DoS via garbage tokens. + checkRateLimit := func(authenticated bool) bool { + if target.rateLimiter == nil { + return true + } + key := ExtractRateLimitKey(r, remoteIP, authenticated) + allowed, retryAfter := target.rateLimiter.Allow(r, key) + if !allowed { + prefixLog.Infof("Rate limit exceeded for key %s, "+ + "retry after %v", key, retryAfter) + addCorsHeaders(w.Header()) + sendRateLimitResponse(w, r, retryAfter) + } + + return allowed + } + skipInvoiceCreation := target.SkipInvoiceCreation(r) switch { case authLevel.IsOn(): @@ -215,6 +237,11 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // User is authenticated, apply rate limit with L402 token ID. + if !checkRateLimit(true) { + return + } + case authLevel.IsFreebie(): // We only need to respect the freebie counter if the user // is not authenticated at all. @@ -267,6 +294,21 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { ) return } + + // Unauthenticated freebie user, rate limit by IP. + if !checkRateLimit(false) { + return + } + } else if !checkRateLimit(true) { + // Authenticated user on freebie path, rate limit by + // L402 token. + return + } + + default: + // Auth is off, rate limit by IP for unauthenticated access. + if !checkRateLimit(false) { + return } } @@ -486,6 +528,36 @@ func sendDirectResponse(w http.ResponseWriter, r *http.Request, } } +// sendRateLimitResponse sends a rate limit exceeded response to the client. +// For HTTP clients, it returns 429 Too Many Requests with Retry-After header. +// For gRPC clients, it returns a ResourceExhausted status. +func sendRateLimitResponse(w http.ResponseWriter, r *http.Request, + retryAfter time.Duration) { + + // Round up to ensure clients don't retry before the limit resets. + retrySeconds := int(math.Ceil(retryAfter.Seconds())) + if retrySeconds < 1 { + retrySeconds = 1 + } + + // Set Retry-After header for both HTTP and gRPC. + w.Header().Set("Retry-After", strconv.Itoa(retrySeconds)) + + // Check if this is a gRPC request. + if strings.HasPrefix(r.Header.Get(hdrContentType), hdrTypeGrpc) { + w.Header().Set( + hdrGrpcStatus, + strconv.Itoa(int(codes.ResourceExhausted)), + ) + w.Header().Set(hdrGrpcMessage, "rate limit exceeded") + + // gRPC requires 200 OK even for errors. + w.WriteHeader(http.StatusOK) + } else { + http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) + } +} + type trailerFixingTransport struct { next http.RoundTripper } diff --git a/proxy/service.go b/proxy/service.go index 8f58d03..d2a952d 100644 --- a/proxy/service.go +++ b/proxy/service.go @@ -110,6 +110,12 @@ type Service struct { // request, but still try to do the l402 authentication. AuthSkipInvoiceCreationPaths []string `long:"authskipinvoicecreationpaths" description:"List of regular expressions for paths that will skip invoice creation'"` + // RateLimits is an optional list of rate-limiting rules for this + // service. Each rule specifies a path pattern and rate limit + // parameters. All matching rules are evaluated; if any rule denies + // the request, it is rejected. + RateLimits []*RateLimitConfig `long:"ratelimits" description:"List of rate limiting rules for this service"` + // compiledHostRegexp is the compiled host regex. compiledHostRegexp *regexp.Regexp @@ -123,8 +129,9 @@ type Service struct { // invoice creation paths. compiledAuthSkipInvoiceCreationPaths []*regexp.Regexp - freebieDB freebie.DB - pricer pricer.Pricer + freebieDB freebie.DB + pricer pricer.Pricer + rateLimiter *RateLimiter } // ResourceName returns the string to be used to identify which resource a @@ -275,6 +282,47 @@ func prepareServices(services []*Service) error { ) } + // Validate and compile rate limit configurations. + if len(service.RateLimits) > 0 { + for i, rl := range service.RateLimits { + // Validate required fields. + if rl.Requests <= 0 { + return fmt.Errorf("service %s rate "+ + "limit %d: requests must be "+ + "positive", service.Name, i) + } + if rl.Per <= 0 { + return fmt.Errorf("service %s rate "+ + "limit %d: per duration must "+ + "be positive", service.Name, i) + } + + // Compile path regex if provided. + if rl.PathRegexp != "" { + compiled, err := regexp.Compile( + rl.PathRegexp, + ) + if err != nil { + return fmt.Errorf("service %s "+ + "rate limit %d: error "+ + "compiling path regex: "+ + "%w", service.Name, i, + err) + } + rl.compiledPathRegexp = compiled + } + } + + // Create the rate limiter for this service. + service.rateLimiter = NewRateLimiter( + service.Name, service.RateLimits, + ) + + log.Infof("Initialized rate limiter for service %s "+ + "with %d rules", service.Name, + len(service.RateLimits)) + } + // If dynamic prices are enabled then use the provided // DynamicPrice options to initialise a gRPC backed // pricer client.