From be65147fee80ee8318ff2f19fc915be340d379f3 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Mon, 12 Jan 2026 15:23:12 +0100 Subject: [PATCH] proxy: integrate rate limiter into service and proxy Add rate limiting integration to the aperture proxy: - Service struct: Add RateLimits configuration field and rateLimiter instance field - prepareServices(): Validate rate limit config at startup, compile path regexes, and initialize RateLimiter instances per service - ServeHTTP(): Check rate limits after auth level determination but before authentication, applying to all requests including auth-whitelisted paths - sendRateLimitResponse(): Return HTTP 429 with Retry-After header for REST clients, or gRPC ResourceExhausted status for gRPC clients The rate limiter key is extracted from the L402 token ID when present, falling back to the client IP address for unauthenticated requests. --- proxy/proxy.go | 72 ++++++++++++++++++++++++++++++++++++++++++++++++ proxy/service.go | 52 ++++++++++++++++++++++++++++++++-- 2 files changed, 122 insertions(+), 2 deletions(-) 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.