mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
291 lines
7.1 KiB
Go
291 lines
7.1 KiB
Go
package api
|
|
|
|
import (
|
|
"crypto/subtle"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
func (s *Server) wrapHandler(next http.Handler) http.Handler {
|
|
handler := next
|
|
handler = s.authMiddleware(handler)
|
|
handler = s.rateLimitMiddleware(handler)
|
|
handler = s.securityHeadersMiddleware(handler)
|
|
handler = s.accessLogMiddleware(handler)
|
|
// Check before ServeMux can clean dot segments and route an escaped path
|
|
// to a different host-shaped endpoint.
|
|
handler = rejectDotSegmentsMiddleware(handler)
|
|
return handler
|
|
}
|
|
|
|
func rejectDotSegmentsMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if hasDotPathSegment(r.URL.Path) || (r.URL.RawPath != "" && hasDotPathSegment(r.URL.RawPath)) {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func hasDotPathSegment(rawPath string) bool {
|
|
decoded, err := url.PathUnescape(rawPath)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
for _, segment := range strings.Split(decoded, "/") {
|
|
if segment == "." || segment == ".." {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
|
|
if isAdminDashboardPath(r.URL.Path) {
|
|
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' http: https:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'")
|
|
} else {
|
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|
if s.cfg.AuthToken == "" {
|
|
return next
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if s.authExempt(r.URL.Path) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if !constantTimeTokenEqual(s.requestToken(r), s.cfg.AuthToken) {
|
|
w.Header().Set("WWW-Authenticate", `Bearer realm="bat-api"`)
|
|
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized", "missing or invalid access token")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) authExempt(path string) bool {
|
|
for _, exempt := range s.cfg.AuthExemptPaths {
|
|
if exempt == path {
|
|
return true
|
|
}
|
|
if strings.HasSuffix(exempt, "/") && strings.HasPrefix(path, exempt) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Server) requestToken(r *http.Request) string {
|
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
|
const prefix = "Bearer "
|
|
if strings.HasPrefix(auth, prefix) {
|
|
return strings.TrimSpace(strings.TrimPrefix(auth, prefix))
|
|
}
|
|
}
|
|
for _, header := range []string{"X-BAT-Token", "X-BAT-API-Key"} {
|
|
if token := strings.TrimSpace(r.Header.Get(header)); token != "" {
|
|
return token
|
|
}
|
|
}
|
|
if s.cfg.AuthQueryParam != "" {
|
|
return r.URL.Query().Get(s.cfg.AuthQueryParam)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func constantTimeTokenEqual(got, want string) bool {
|
|
if got == "" || want == "" {
|
|
return false
|
|
}
|
|
gotBytes := []byte(got)
|
|
wantBytes := []byte(want)
|
|
if len(gotBytes) != len(wantBytes) {
|
|
subtle.ConstantTimeCompare(wantBytes, wantBytes)
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare(gotBytes, wantBytes) == 1
|
|
}
|
|
|
|
func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler {
|
|
if s.limiter == nil {
|
|
return next
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
client := s.clientIdentity(r)
|
|
if !s.limiter.allow(client, time.Now()) {
|
|
w.Header().Set("Retry-After", "1")
|
|
writeErrorJSON(w, http.StatusTooManyRequests, "rate_limited", "too many requests")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) accessLogMiddleware(next http.Handler) http.Handler {
|
|
if !s.cfg.AccessLog {
|
|
return next
|
|
}
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
start := time.Now()
|
|
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
|
next.ServeHTTP(rec, r)
|
|
requestID := safeLogValue(r.Header.Get("X-Request-ID"))
|
|
if requestID == "" {
|
|
requestID = "-"
|
|
}
|
|
s.logger.Printf(
|
|
"access method=%s path=%s status=%d bytes=%d duration_ms=%d client_ip=%s request_id=%s user_agent=%q",
|
|
r.Method,
|
|
r.URL.Path,
|
|
rec.status,
|
|
rec.bytes,
|
|
time.Since(start).Milliseconds(),
|
|
s.clientIdentity(r),
|
|
requestID,
|
|
r.UserAgent(),
|
|
)
|
|
})
|
|
}
|
|
|
|
func (s *Server) clientIdentity(r *http.Request) string {
|
|
if s.cfg.TrustProxyHeaders {
|
|
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
|
|
for _, part := range strings.Split(forwarded, ",") {
|
|
if ip := strings.TrimSpace(part); ip != "" {
|
|
return ip
|
|
}
|
|
}
|
|
}
|
|
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
|
|
return realIP
|
|
}
|
|
}
|
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err == nil && host != "" {
|
|
return host
|
|
}
|
|
return r.RemoteAddr
|
|
}
|
|
|
|
func safeLogValue(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
raw = strings.Map(func(r rune) rune {
|
|
if r < 32 || r == 127 {
|
|
return -1
|
|
}
|
|
return r
|
|
}, raw)
|
|
if len(raw) > 128 {
|
|
return raw[:128]
|
|
}
|
|
return raw
|
|
}
|
|
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
}
|
|
|
|
func (r *statusRecorder) WriteHeader(status int) {
|
|
r.status = status
|
|
r.ResponseWriter.WriteHeader(status)
|
|
}
|
|
|
|
func (r *statusRecorder) Write(data []byte) (int, error) {
|
|
n, err := r.ResponseWriter.Write(data)
|
|
r.bytes += n
|
|
return n, err
|
|
}
|
|
|
|
func (r *statusRecorder) Unwrap() http.ResponseWriter {
|
|
return r.ResponseWriter
|
|
}
|
|
|
|
type tokenBucketLimiter struct {
|
|
mu sync.Mutex
|
|
rate float64
|
|
burst float64
|
|
buckets map[string]*tokenBucket
|
|
lastCleanup time.Time
|
|
}
|
|
|
|
type tokenBucket struct {
|
|
tokens float64
|
|
last time.Time
|
|
lastSeen time.Time
|
|
}
|
|
|
|
func newTokenBucketLimiter(rps float64, burst int) *tokenBucketLimiter {
|
|
return &tokenBucketLimiter{
|
|
rate: rps,
|
|
burst: float64(burst),
|
|
buckets: map[string]*tokenBucket{},
|
|
}
|
|
}
|
|
|
|
func (l *tokenBucketLimiter) allow(key string, now time.Time) bool {
|
|
if key == "" {
|
|
key = "unknown"
|
|
}
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) > time.Minute {
|
|
l.cleanup(now)
|
|
}
|
|
bucket := l.buckets[key]
|
|
if bucket == nil {
|
|
bucket = &tokenBucket{tokens: l.burst, last: now, lastSeen: now}
|
|
l.buckets[key] = bucket
|
|
}
|
|
elapsed := now.Sub(bucket.last).Seconds()
|
|
bucket.tokens += elapsed * l.rate
|
|
if bucket.tokens > l.burst {
|
|
bucket.tokens = l.burst
|
|
}
|
|
bucket.last = now
|
|
bucket.lastSeen = now
|
|
if bucket.tokens < 1 {
|
|
return false
|
|
}
|
|
bucket.tokens--
|
|
return true
|
|
}
|
|
|
|
func (l *tokenBucketLimiter) cleanup(now time.Time) {
|
|
for key, bucket := range l.buckets {
|
|
if now.Sub(bucket.lastSeen) > 10*time.Minute {
|
|
delete(l.buckets, key)
|
|
}
|
|
}
|
|
l.lastCleanup = now
|
|
}
|
|
|
|
func writeErrorJSON(w http.ResponseWriter, status int, code string, message string) {
|
|
writeNoStoreJSON(w, status, ErrorResponse{
|
|
Error: ErrorBody{
|
|
Code: code,
|
|
Message: message,
|
|
Status: status,
|
|
},
|
|
})
|
|
}
|