mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-13 08:02:34 +00:00
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
125 lines
4.8 KiB
Go
125 lines
4.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
"github.com/warmbly/warmbly/internal/app/token"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
)
|
|
|
|
type socketTicketStub struct{ ticket string }
|
|
|
|
func (s socketTicketStub) GenerateWebsocketToken(context.Context, uuid.UUID) (string, *errx.Error) {
|
|
return s.ticket, nil
|
|
}
|
|
|
|
func TestSocketTicketNormalizesLegacyURL(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
for _, endpoint := range []string{"wss://realtime.test", "wss://realtime.test/socket", "wss://realtime.test/socket/websocket/"} {
|
|
h := &Handler{WebsocketURI: endpoint, SocketService: socketTicketStub{ticket: "short-lived"}}
|
|
w := httptest.NewRecorder()
|
|
c, _ := gin.CreateTestContext(w)
|
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/getaway", nil)
|
|
c.Set("user_id", uuid.New().String())
|
|
h.GenerateWebsocket(c)
|
|
var response map[string]any
|
|
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if response["url"] != "wss://realtime.test/socket/websocket?token=short-lived" {
|
|
t.Fatalf("legacy response: %s", w.Body)
|
|
}
|
|
if response["proxy_path"] != "/realtime/socket/websocket?token=short-lived" {
|
|
t.Fatalf("missing API-origin path: %s", w.Body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSocketProxyPreservesHandshakeAndRejectsOtherCredentials(t *testing.T) {
|
|
gin.SetMode(gin.TestMode)
|
|
service := token.NewService(nil, nil, nil, nil, "test-secret-at-least-32-characters-long")
|
|
userID, ticketID := uuid.New(), uuid.New()
|
|
now := time.Now()
|
|
ticket, err := service.GenerateTokenFor(token.PurposeWebSocket, userID, ticketID, "", "nonce", now, now.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/prefix/socket/websocket" || r.URL.Query().Get("vsn") != "1.0.0" || r.URL.Query().Get("tenant") != "legacy" || r.URL.Query().Get("target") != "" {
|
|
t.Errorf("wrong upstream route: %s", r.URL.Path)
|
|
}
|
|
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.Header.Get("X-Warmbly-Token") != "" {
|
|
t.Error("forwarded unrelated credentials")
|
|
}
|
|
proof, xerr := service.VerifyTokenFor(token.PurposeWebSocketProxy, r.Header.Get("X-Warmbly-Proxy-Proof"))
|
|
if xerr != nil || proof.SessionID != ticketID || proof.ClientIP != "127.0.0.1" {
|
|
t.Error("missing authenticated peer identity")
|
|
}
|
|
upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return r.Header.Get("Origin") == "https://dashboard.test" }}
|
|
connection, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer connection.Close()
|
|
kind, frame, err := connection.ReadMessage()
|
|
if err == nil {
|
|
_ = connection.WriteMessage(kind, frame)
|
|
}
|
|
}))
|
|
defer upstream.Close()
|
|
h := &Handler{WebsocketURI: upstream.URL + "/prefix/socket?tenant=legacy", TokenService: service}
|
|
router := gin.New()
|
|
_ = router.SetTrustedProxies(nil)
|
|
router.GET("/v1/realtime/socket/websocket", h.ProxyWebsocket)
|
|
api := httptest.NewServer(router)
|
|
defer api.Close()
|
|
base := strings.Replace(api.URL, "http", "ws", 1) + "/v1/realtime/socket/websocket?vsn=1.0.0&target=https://untrusted.test&token="
|
|
headers := http.Header{"Origin": {"https://dashboard.test"}, "Authorization": {"Bearer unrelated"}, "Cookie": {"session=unrelated"}, "X-Warmbly-Token": {"wmbly_unrelated"}, "X-Warmbly-Proxy-Proof": {"forged"}}
|
|
connection, handshake, err := websocket.DefaultDialer.Dial(base+url.QueryEscape(ticket), headers)
|
|
if handshake != nil {
|
|
defer handshake.Body.Close()
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer connection.Close()
|
|
frame := []byte(`{"topic":"phoenix","event":"heartbeat","payload":{},"ref":"1"}`)
|
|
if err := connection.WriteMessage(websocket.TextMessage, frame); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, echoed, err := connection.ReadMessage()
|
|
if err != nil || string(echoed) != string(frame) {
|
|
t.Fatalf("frame relay failed: %v", err)
|
|
}
|
|
for _, purpose := range []string{token.PurposeAccess, token.PurposePasswordReset, token.PurposeWebSocketProxy} {
|
|
invalid, err := service.GenerateTokenFor(purpose, userID, ticketID, "", "nonce", now, now.Add(time.Minute))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, response, err := websocket.DefaultDialer.Dial(base+url.QueryEscape(invalid), headers)
|
|
if response != nil {
|
|
_ = response.Body.Close()
|
|
}
|
|
if err == nil || response == nil || response.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("accepted %s credential", purpose)
|
|
}
|
|
}
|
|
_, response, err := websocket.DefaultDialer.Dial(base+url.QueryEscape(ticket), http.Header{"Origin": {"https://untrusted.test"}})
|
|
if response != nil {
|
|
_ = response.Body.Close()
|
|
}
|
|
if err == nil || response == nil || response.StatusCode != http.StatusForbidden {
|
|
t.Fatal("lost upstream origin validation")
|
|
}
|
|
}
|