Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

188 lines
6.3 KiB
Go

package server
import (
"context"
"net"
"net/http"
"strings"
)
// contextKey is an unexported type for context keys in this package.
type contextKey int
const (
// ctxKeyRemoteAuth indicates the request passed token auth.
// When set to true, host-check and CORS middleware skip
// their restrictions.
ctxKeyRemoteAuth contextKey = iota
)
// isRemoteAuth returns true if the request was authenticated as a
// remote client by the auth middleware.
func isRemoteAuth(r *http.Request) bool {
v, _ := r.Context().Value(ctxKeyRemoteAuth).(bool)
return v
}
// isLocalhostRequest returns true only for a request that arrived as a
// direct loopback connection (127.0.0.0/8, ::1) and was not relayed by a
// reverse proxy. Callers use it to gate exposure of secrets (the auth token,
// unredacted search snippets) to "local only" clients.
//
// RemoteAddr alone is insufficient: a managed Caddy proxy reverse-proxies to
// the loopback backend, so every proxied request — including ones from remote
// LAN clients allowed through the proxy — would carry a loopback RemoteAddr.
// Proxies add X-Forwarded-For/X-Real-IP/Forwarded, so any of those headers
// means the connection is not a direct local one and must not be trusted. A
// genuinely local attacker spoofing these headers only denies themselves, so
// the check fails closed.
func isLocalhostRequest(r *http.Request) bool {
if hasForwardingHeader(r) {
return false
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
host = r.RemoteAddr
}
ip := net.ParseIP(host)
if ip == nil {
return false
}
return ip.IsLoopback()
}
// hasForwardingHeader reports whether the request carries any header a reverse
// proxy adds when relaying a client, which indicates the connection reached
// the server through a proxy rather than directly.
func hasForwardingHeader(r *http.Request) bool {
return r.Header.Get("X-Forwarded-For") != "" ||
r.Header.Get("X-Real-IP") != "" ||
r.Header.Get("Forwarded") != ""
}
// protectedPath reports whether a path is subject to bearer auth and
// Host validation: every /api/ route, plus /debug/pprof/ when the
// profiling handlers are mounted. With pprof disabled the path is
// SPA fallback and stays ungated like other static assets.
func (s *Server) protectedPath(path string) bool {
return strings.HasPrefix(path, "/api/") ||
(s.pprofEnabled && strings.HasPrefix(path, "/debug/pprof/"))
}
// authMiddleware enforces Bearer token authentication for protected
// routes (/api/ including /api/ping, and /debug/pprof/ when enabled)
// when require_auth is on. Non-protected routes such as static
// assets are never gated.
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Static assets are always served.
if !s.protectedPath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
// Read config once for all checks below.
s.mu.RLock()
token := s.cfg.AuthToken
authRequired := s.cfg.RequireAuth
s.mu.RUnlock()
// CORS preflight requests (OPTIONS) never include credentials.
// Let them through so the browser can negotiate CORS before
// sending the authenticated request. When auth is required,
// mark OPTIONS as authenticated so the CORS middleware
// allows the preflight for cross-origin clients.
if r.Method == http.MethodOptions {
if authRequired && token != "" {
ctx := context.WithValue(r.Context(), ctxKeyRemoteAuth, true)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
next.ServeHTTP(w, r)
return
}
remoteSyncAuth := isRemoteSyncPath(r.URL.Path)
// When auth is not required, skip token checks entirely
// except for machine-to-machine remote sync archive APIs,
// which must still set ctxKeyRemoteAuth before host/CORS
// middleware runs.
if !authRequired && !remoteSyncAuth {
next.ServeHTTP(w, r)
return
}
// Auth required but no token configured — fail closed.
if token == "" {
if remoteSyncAuth {
http.Error(w,
"server misconfiguration: auth token required for remote sync",
http.StatusInternalServerError)
return
}
http.Error(w,
"server misconfiguration: auth required but no token set",
http.StatusInternalServerError)
return
}
// Check Bearer token in Authorization header. The ?token=
// query param fallback is restricted to SSE endpoints
// (see isSSEPath) because EventSource cannot set custom
// headers. All other endpoints must use the Authorization
// header.
var provided string
auth := r.Header.Get("Authorization")
if t, ok := strings.CutPrefix(auth, "Bearer "); ok {
provided = t
} else if qt := r.URL.Query().Get("token"); qt != "" && isSSEPath(r.URL.Path) {
provided = qt
} else {
setCORSOnAuthError(w, r)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if provided != token {
setCORSOnAuthError(w, r)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Mark this request as authenticated remote so downstream
// middleware (host-check, CORS) can relax restrictions.
ctx := context.WithValue(r.Context(), ctxKeyRemoteAuth, true)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// isSSEPath reports whether the given path is a server-sent events
// endpoint that accepts a ?token= query parameter in place of the
// Authorization header. The query-param fallback exists because
// browser EventSource cannot set headers.
func isSSEPath(path string) bool {
return strings.HasSuffix(path, "/watch") || path == "/api/v1/events"
}
func isRemoteSyncPath(path string) bool {
return strings.HasPrefix(path, "/api/v1/remote-sync/")
}
// setCORSOnAuthError adds CORS headers to 401 responses so
// cross-origin browsers can read the auth failure status. Without
// these headers, 401s from authMiddleware (which runs before
// corsMiddleware) become opaque network errors, preventing the
// frontend from detecting auth failures and prompting for a token.
//
// Only used for token-related 401s in remote mode, where the token
// is the access boundary and cross-origin requests are expected.
// Not used for 403s (remote access disabled / no token configured)
// which are not auth challenges the client can resolve.
func setCORSOnAuthError(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin == "" {
return
}
w.Header().Set("Access-Control-Allow-Origin", origin)
ensureVaryHeader(w.Header(), "Origin")
}