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:
Atul Singh
2026-05-13 12:54:31 +02:00
committed by GitHub
parent 680ef32b31
commit 193e0d3c77
20 changed files with 1245 additions and 173 deletions
+41 -10
View File
@@ -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
}
+114
View File
@@ -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
}