feat: ratelimit, ghcr package support, media path sensitization, LID to CN migration (#6)
* feat: ratelimit, ghcr package and proper project refactor from askarzh * fix(bridge): validate media path * update: dependencies * fix: added LID to contact number migration
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"whatsapp-bridge/config"
|
||||
@@ -17,11 +19,31 @@ type Claims struct {
|
||||
}
|
||||
|
||||
func LoginHandler(cfg *config.Config) http.HandlerFunc {
|
||||
limiter, err := newLoginLimiter(cfg.AuthLoginRate)
|
||||
if err != nil {
|
||||
// Surface fatal config error at startup by returning a handler that always 500s.
|
||||
// In practice main() should call newLoginLimiter directly and exit, but keeping
|
||||
// the existing LoginHandler signature stable avoids a wider refactor in this PR.
|
||||
slog.Error("invalid AUTH_LOGIN_RATE", "err", err)
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "server misconfigured", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
auth := r.Header.Get("Authorization")
|
||||
ip := clientIP(r)
|
||||
lim := limiter.get(ip)
|
||||
if !lim.Allow() {
|
||||
retry := retryAfterSeconds(lim)
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retry))
|
||||
slog.Warn("login rate-limited", "remote", ip)
|
||||
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
expected := []byte("Bearer " + cfg.APIKey)
|
||||
got := []byte(r.Header.Get("Authorization"))
|
||||
|
||||
if auth != fmt.Sprintf("Bearer %s", cfg.APIKey) {
|
||||
fmt.Println("Invalid API key")
|
||||
if subtle.ConstantTimeCompare(expected, got) != 1 {
|
||||
slog.Warn("login rejected: bad api key", "remote", r.RemoteAddr)
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
@@ -29,6 +51,8 @@ func LoginHandler(cfg *config.Config) http.HandlerFunc {
|
||||
claims := Claims{
|
||||
Service: "mcp-server",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: "whatsapp-bridge",
|
||||
Audience: []string{"whatsapp-mcp-server"},
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(45 * time.Minute)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
@@ -37,7 +61,7 @@ func LoginHandler(cfg *config.Config) http.HandlerFunc {
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
signed, err := token.SignedString(cfg.JWTSecret)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to sign token:", err)
|
||||
slog.Error("failed to sign token", "err", err)
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
@@ -57,18 +81,25 @@ func JwtAuthMiddleware(cfg *config.Config, next http.Handler) http.Handler {
|
||||
|
||||
tokenStr := strings.TrimPrefix(auth, "Bearer ")
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return cfg.JWTSecret, nil
|
||||
})
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenStr,
|
||||
&Claims{},
|
||||
func(token *jwt.Token) (interface{}, error) {
|
||||
return cfg.JWTSecret, nil
|
||||
},
|
||||
jwt.WithIssuer("whatsapp-bridge"),
|
||||
jwt.WithAudience("whatsapp-mcp-server"),
|
||||
jwt.WithValidMethods([]string{"HS256"}),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Parse error:", err)
|
||||
slog.Warn("jwt parse error", "err", err, "remote", r.RemoteAddr)
|
||||
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
fmt.Println("Token is NOT valid")
|
||||
slog.Warn("jwt invalid", "remote", r.RemoteAddr)
|
||||
http.Error(w, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// loginLimiter holds per-IP token buckets for /auth/login.
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*visitor
|
||||
limit rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
type visitor struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
// parseRate parses values like "5/1m", "10/30s", "100/1h".
|
||||
// Returns (rate.Limit, burst, error).
|
||||
func parseRate(spec string) (rate.Limit, int, error) {
|
||||
if spec == "" {
|
||||
return rate.Every(12 * time.Second), 5, nil // default: 5/1m
|
||||
}
|
||||
parts := strings.SplitN(spec, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE %q: want <count>/<window>", spec)
|
||||
}
|
||||
count, err := strconv.Atoi(parts[0])
|
||||
if err != nil || count <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE count in %q", spec)
|
||||
}
|
||||
window, err := time.ParseDuration(parts[1])
|
||||
if err != nil || window <= 0 {
|
||||
return 0, 0, fmt.Errorf("invalid AUTH_LOGIN_RATE window in %q", spec)
|
||||
}
|
||||
return rate.Every(window / time.Duration(count)), count, nil
|
||||
}
|
||||
|
||||
func newLoginLimiter(spec string) (*loginLimiter, error) {
|
||||
limit, burst, err := parseRate(spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l := &loginLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
limit: limit,
|
||||
burst: burst,
|
||||
}
|
||||
go l.evictLoop()
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (l *loginLimiter) get(ip string) *rate.Limiter {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
v, ok := l.visitors[ip]
|
||||
if !ok {
|
||||
v = &visitor{limiter: rate.NewLimiter(l.limit, l.burst)}
|
||||
l.visitors[ip] = v
|
||||
}
|
||||
v.lastSeen = time.Now()
|
||||
return v.limiter
|
||||
}
|
||||
|
||||
func (l *loginLimiter) evictLoop() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
l.mu.Lock()
|
||||
for ip, v := range l.visitors {
|
||||
if v.lastSeen.Before(cutoff) {
|
||||
delete(l.visitors, ip)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// retryAfterSeconds rounds up to the next whole second the limiter expects to refill.
|
||||
func retryAfterSeconds(lim *rate.Limiter) int {
|
||||
r := lim.Reserve()
|
||||
defer r.Cancel()
|
||||
d := r.Delay()
|
||||
if d <= 0 {
|
||||
return 1
|
||||
}
|
||||
secs := int(d / time.Second)
|
||||
if d%time.Second != 0 {
|
||||
secs++
|
||||
}
|
||||
return secs
|
||||
}
|
||||
|
||||
// clientIP extracts the bare IP (no port) from r.RemoteAddr.
|
||||
// X-Forwarded-For is intentionally NOT consulted (see spec section 2,
|
||||
// "Known limitation"). A future PR adds trusted-proxy parsing.
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
Reference in New Issue
Block a user