mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-07-28 01:27:58 +00:00
Merge pull request #3160 from danmobot/fix/launcher-setup-csrf
fix(auth): reject cross-site launcher setup requests
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/sipeed/picoclaw/web/backend/middleware"
|
||||
@@ -89,6 +90,60 @@ func (h *launcherAuthHandlers) isStoreInitialized(ctx context.Context) (bool, er
|
||||
return h.store.IsInitialized(ctx)
|
||||
}
|
||||
|
||||
func launcherSetupCrossSite(r *http.Request) bool {
|
||||
fetchSite := strings.ToLower(strings.TrimSpace(r.Header.Get("Sec-Fetch-Site")))
|
||||
if fetchSite == "cross-site" {
|
||||
return true
|
||||
}
|
||||
|
||||
if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" {
|
||||
return !sameLauncherRequestOrigin(r, origin)
|
||||
}
|
||||
|
||||
if referer := strings.TrimSpace(r.Header.Get("Referer")); referer != "" {
|
||||
return !sameLauncherRequestOrigin(r, referer)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func sameLauncherRequestOrigin(r *http.Request, raw string) bool {
|
||||
if strings.ContainsAny(raw, " \t\r\n") {
|
||||
return false
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
wantScheme := launcherRequestScheme(r)
|
||||
wantHost := r.Host
|
||||
if wantHost == "" {
|
||||
wantHost = r.URL.Host
|
||||
}
|
||||
return strings.EqualFold(u.Scheme, wantScheme) && strings.EqualFold(u.Host, wantHost)
|
||||
}
|
||||
|
||||
func launcherRequestScheme(r *http.Request) string {
|
||||
if proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); proto != "" {
|
||||
if i := strings.IndexByte(proto, ','); i >= 0 {
|
||||
proto = proto[:i]
|
||||
}
|
||||
proto = strings.ToLower(strings.TrimSpace(proto))
|
||||
if proto == "http" || proto == "https" {
|
||||
return proto
|
||||
}
|
||||
}
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if r.URL != nil && r.URL.Scheme != "" {
|
||||
return r.URL.Scheme
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
func (h *launcherAuthHandlers) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
var body launcherAuthLoginBody
|
||||
@@ -198,6 +253,12 @@ func (h *launcherAuthHandlers) handleStatus(w http.ResponseWriter, r *http.Reque
|
||||
func (h *launcherAuthHandlers) handleSetup(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if launcherSetupCrossSite(r) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"cross-site setup request rejected"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if h.store == nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
if h.storeErr != nil {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLauncherAuthSetupRejectsCrossSiteFirstRun(t *testing.T) {
|
||||
store := &fakePasswordStore{}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
SessionCookie: "session-cookie-value",
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1:18800/api/auth/setup",
|
||||
strings.NewReader(`{"password":"CrossSitePwn123!","confirm":"CrossSitePwn123!"}`),
|
||||
)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
req.Header.Set("Referer", "https://evil.example/attack")
|
||||
req.Header.Set("Sec-Fetch-Site", "cross-site")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("cross-site setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if store.initialized || store.password != "" {
|
||||
t.Fatalf("cross-site setup mutated store: initialized=%v password=%q", store.initialized, store.password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLauncherAuthSetupAllowsSameOriginFirstRun(t *testing.T) {
|
||||
store := &fakePasswordStore{}
|
||||
mux := http.NewServeMux()
|
||||
RegisterLauncherAuthRoutes(mux, LauncherAuthRouteOpts{
|
||||
SessionCookie: "session-cookie-value",
|
||||
PasswordStore: store,
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"http://127.0.0.1:18800/api/auth/setup",
|
||||
strings.NewReader(`{"password":"LocalSetup123!","confirm":"LocalSetup123!"}`),
|
||||
)
|
||||
req.Header.Set("Origin", "http://127.0.0.1:18800")
|
||||
req.Header.Set("Sec-Fetch-Site", "same-origin")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
mux.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("same-origin setup code = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !store.initialized || store.password != "LocalSetup123!" {
|
||||
t.Fatalf("same-origin setup store: initialized=%v password=%q", store.initialized, store.password)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user