Files
3x-ui/internal/web/controller/websocket.go
T
MHSanaei b1108a9e81 fix(web): match the WebSocket origin on scheme and port, not only host
The live-update socket accepted any Origin whose host name matched, but
cookies do not separate ports or schemes, so a page served by another
service on the same host name could open it with the admin's session.
Only http(s) origins are accepted now (https when the panel terminates
TLS itself), and the port must match whenever the Host header has one.
2026-10-31 09:06:41 +00:00

99 lines
3.0 KiB
Go

package controller
import (
"net"
"net/http"
"net/url"
"strings"
"github.com/mhsanaei/3x-ui/v3/internal/logger"
"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
"github.com/mhsanaei/3x-ui/v3/internal/web/session"
"github.com/gin-gonic/gin"
ws "github.com/gorilla/websocket"
)
var upgrader = ws.Upgrader{
ReadBufferSize: 32768,
WriteBufferSize: 32768,
EnableCompression: true,
CheckOrigin: checkSameOrigin,
}
// checkSameOrigin allows requests with no Origin header (same-origin or non-browser
// clients) and otherwise requires the Origin hostname to match the request hostname.
// Comparison is case-insensitive (RFC 7230 §2.7.3) and ignores port differences
// (the panel often sits behind a reverse proxy on a different port).
func checkSameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil || u.Hostname() == "" {
return false
}
// Cookies ignore scheme and port, so both must match too; behind a TLS-terminating
// proxy the request arrives as http and the browser's https origin still passes.
if u.Scheme != "https" && (u.Scheme != "http" || r.TLS != nil) {
return false
}
host, port, err := net.SplitHostPort(r.Host)
if err != nil {
// IPv6 literals without a port arrive as "[::1]"; net.SplitHostPort
// fails in that case while url.Hostname() returns the address without
// brackets. Strip them so same-origin checks pass for bare IPv6 hosts.
host = r.Host
if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' {
host = host[1 : len(host)-1]
}
}
if !strings.EqualFold(u.Hostname(), host) {
return false
}
return port == "" || port == originPort(u)
}
// originPort is the Origin's port, or the default a browser leaves out for its scheme.
func originPort(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
if u.Scheme == "https" {
return "443"
}
return "80"
}
// WebSocketController handles the HTTP→WebSocket upgrade for real-time updates.
// All per-connection lifecycle (pumps, hub registration) lives in
// panel.WebSocketService — this controller is HTTP-layer only.
type WebSocketController struct {
BaseController
service *panel.WebSocketService
}
// NewWebSocketController creates a controller wired to the given service.
func NewWebSocketController(svc *panel.WebSocketService) *WebSocketController {
return &WebSocketController{service: svc}
}
// HandleWebSocket authenticates the request, upgrades the HTTP connection, and
// hands ownership of the connection off to the service.
func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
if !session.IsLogin(c) {
logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
c.AbortWithStatus(http.StatusUnauthorized)
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Error("Failed to upgrade WebSocket connection:", err)
return
}
w.service.HandleConnection(conn, getRemoteIp(c))
}