Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions v2/internal/frontend/desktop/darwin/WailsContext.m
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,26 @@ - (void) disableWindowConstraints {
}

- (void)cancelOperation:(id)sender {
if (self.disableEscapeExitsFullscreen &&
(self.styleMask & NSWindowStyleMaskFullScreen) == NSWindowStyleMaskFullScreen) {
if ((self.styleMask & NSWindowStyleMaskFullScreen) == NSWindowStyleMaskFullScreen) {
if (!self.disableEscapeExitsFullscreen) {
// Keep the system behaviour: Escape leaves fullscreen.
[super cancelOperation:sender];
}
return;
}
[super cancelOperation:sender];
// Swallow the event outside fullscreen: an Escape the web content left
// unhandled would otherwise fall off the responder chain and play the
// system alert sound on every press.
}

// An unhandled key event that reaches the window plays the system alert
// (NSBeep) by default — audible on every keypress the web content chooses not
// to claim. Swallow just the keyDown case; other unhandled selectors keep
// their default behaviour.
- (void)noResponderFor:(SEL)eventSelector {
if (eventSelector != @selector(keyDown:)) {
[super noResponderFor:eventSelector];
}
}

@end
Expand Down
72 changes: 69 additions & 3 deletions v2/internal/frontend/devserver/devserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/wailsapp/wails/v2/internal/binding"
"github.com/wailsapp/wails/v2/internal/frontend"
"github.com/wailsapp/wails/v2/internal/frontend/ipcauth"
"github.com/wailsapp/wails/v2/internal/logger"
"github.com/wailsapp/wails/v2/internal/menumanager"
"github.com/wailsapp/wails/v2/pkg/options"
Expand All @@ -34,7 +35,31 @@ type Screen = frontend.Screen
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool { return true },
// Refuse a WebSocket upgrade unless its Origin is the dev server's own. The
// dev runtime builds its socket URL from window.location, so a genuine
// browser client is always same-origin; there is no legitimate headerless
// client, so a missing Origin is refused too.
CheckOrigin: sameOrigin,
}

// sameOrigin reports whether the upgrade carries an Origin header equal to the
// request Host. A missing, malformed, non-HTTP, or foreign Origin is refused.
// Because requireAllowedHost has already validated the Host header against an
// allowlist, comparing the Origin against it is meaningful rather than a
// comparison of two attacker-controlled values.
func sameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
u, err := url.Parse(origin)
if err != nil {
return false
}
if u.Scheme != "http" && u.Scheme != "https" {
return false
}
return u.Host != "" && strings.EqualFold(u.Host, r.Host)
}

type DevWebServer struct {
Expand All @@ -58,8 +83,7 @@ type DevWebServer struct {
func (d *DevWebServer) Run(ctx context.Context) error {
d.ctx = ctx

d.server.GET("/wails/reload", d.handleReload)
d.server.GET("/wails/ipc", d.handleIPCWebSocket)
d.registerRoutes()

assetServerConfig, err := assetserver.BuildAssetServerConfig(d.appoptions)
if err != nil {
Expand Down Expand Up @@ -136,6 +160,39 @@ func (d *DevWebServer) Run(ctx context.Context) error {
return err
}

// registerRoutes wires the dev server's middleware and fixed routes. The host
// guard is registered first so it runs outermost (echo composes Use middleware
// outermost-first): a request whose Host is not allowed is rejected before the
// capability cookie is set or any route runs.
func (d *DevWebServer) registerRoutes() {
d.server.Use(d.requireAllowedHost())

// Hand the per-launch capability to every page the dev server serves, as an
// HttpOnly, SameSite=Strict cookie — defense in depth behind the host guard
// and origin check. A same-origin page returns it automatically on the IPC
// WebSocket upgrade. It is not local-client authentication: a process
// running as the same user can read it straight off a served response. Its
// job is to raise the bar for the browser vector, not to authenticate an
// arbitrary local caller.
d.server.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if !c.IsWebSocket() {
http.SetCookie(c.Response(), &http.Cookie{
Name: ipcauth.CookieName,
Value: ipcauth.Token(),
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
}
return next(c)
}
})

d.server.GET("/wails/reload", d.handleReload)
d.server.GET("/wails/ipc", d.handleIPCWebSocket)
}

func (d *DevWebServer) WindowReload() {
d.broadcast("reload")
d.Frontend.WindowReload()
Expand All @@ -161,6 +218,15 @@ func (d *DevWebServer) handleReloadApp(c echo.Context) error {
}

func (d *DevWebServer) handleIPCWebSocket(c echo.Context) error {
// Require the capability cookie on the upgrade as defense in depth, behind
// the host guard and the same-origin check. This is not local-client
// authentication — a process running as the same user can read the cookie
// from a served response — so it is not relied on to stop such a caller.
if cookie, err := c.Cookie(ipcauth.CookieName); err != nil || !ipcauth.Valid(cookie.Value) {
d.logger.Error("IPC WebSocket rejected: missing or invalid capability")
return c.NoContent(http.StatusForbidden)
}

conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
d.logger.Error("WebSocket upgrade failed %v", err)
Expand Down
235 changes: 235 additions & 0 deletions v2/internal/frontend/devserver/devserver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
//go:build dev

package devserver

import (
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"

"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/wailsapp/wails/v2/internal/frontend"
"github.com/wailsapp/wails/v2/internal/frontend/ipcauth"
"github.com/wailsapp/wails/v2/internal/logger"
)

const echoPrefix = "echo:"

// stubDispatcher echoes each message back, so the IPC round-trip can be
// observed without a real bindings dispatcher.
type stubDispatcher struct{}

func (stubDispatcher) ProcessMessage(message string, _ frontend.Frontend) (string, error) {
return echoPrefix + message, nil
}

// nullFrontend satisfies frontend.Frontend for the reload path. Only
// WindowReload is exercised by these tests; the embedded nil interface covers
// the rest, which are never called.
type nullFrontend struct {
frontend.Frontend
}

func (nullFrontend) WindowReload() {}

// newTestServer builds a DevWebServer served over httptest and returns it with
// the server and its bind port. devServerAddr is set to the httptest listener
// address before registerRoutes runs, so the host guard's allowlist is keyed on
// the real bind address (a loopback IP).
func newTestServer(t *testing.T) (*DevWebServer, *httptest.Server, string) {
t.Helper()
d := &DevWebServer{
server: echo.New(),
logger: logger.New(nil),
dispatcher: stubDispatcher{},
Frontend: nullFrontend{},
websocketClients: make(map[*websocket.Conn]*sync.Mutex),
}
d.server.HideBanner = true
d.server.HidePort = true

ts := httptest.NewServer(d.server)
t.Cleanup(ts.Close)

d.devServerAddr = ts.Listener.Addr().String()
d.registerRoutes()

_, port, err := net.SplitHostPort(d.devServerAddr)
if err != nil {
t.Fatalf("splitting test server addr %q: %v", d.devServerAddr, err)
}
return d, ts, port
}

func getWithHost(t *testing.T, url, host string) *http.Response {
t.Helper()
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatalf("building request: %v", err)
}
if host != "" {
req.Host = host
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request to %s (host %q): %v", url, host, err)
}
return resp
}

func hasCapabilityCookie(resp *http.Response) bool {
for _, ck := range resp.Cookies() {
if ck.Name == ipcauth.CookieName {
return true
}
}
return false
}

func statusOf(resp *http.Response) int {
if resp == nil {
return -1
}
return resp.StatusCode
}

func wsURL(ts *httptest.Server) string {
return "ws" + strings.TrimPrefix(ts.URL, "http") + "/wails/ipc"
}

func TestReloadRouteHostGuard(t *testing.T) {
_, ts, port := newTestServer(t)
reloadURL := ts.URL + "/wails/reload"

t.Run("allowed host sets the capability cookie", func(t *testing.T) {
resp := getWithHost(t, reloadURL, "")
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
}
if !hasCapabilityCookie(resp) {
t.Errorf("an allowed response should carry a %s cookie", ipcauth.CookieName)
}
})

t.Run("localhost host is allowed against a loopback-IP bind", func(t *testing.T) {
resp := getWithHost(t, reloadURL, "localhost:"+port)
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusNoContent)
}
})

t.Run("forged host is rejected and gets no cookie", func(t *testing.T) {
resp := getWithHost(t, reloadURL, "attacker.example:"+port)
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden)
}
if hasCapabilityCookie(resp) {
t.Errorf("a rejected request must not receive a %s cookie", ipcauth.CookieName)
}
})

t.Run("wrong port is rejected", func(t *testing.T) {
resp := getWithHost(t, reloadURL, "127.0.0.1:1")
defer resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden)
}
})
}

func TestIPCWebSocketGate(t *testing.T) {
_, ts, port := newTestServer(t)
sameOriginHeader := ts.URL // http://127.0.0.1:<port>, equal to the request Host

withCookie := func() http.Header {
h := http.Header{}
h.Set("Cookie", ipcauth.CookieName+"="+ipcauth.Token())
return h
}
expectRejected := func(t *testing.T, conn *websocket.Conn, resp *http.Response, err error) {
t.Helper()
if err == nil {
conn.Close()
t.Fatal("expected the upgrade to be rejected")
}
if statusOf(resp) != http.StatusForbidden {
t.Fatalf("status = %d, want %d", statusOf(resp), http.StatusForbidden)
}
}

t.Run("missing cookie is rejected", func(t *testing.T) {
h := http.Header{}
h.Set("Origin", sameOriginHeader)
conn, resp, err := websocket.DefaultDialer.Dial(wsURL(ts), h)
expectRejected(t, conn, resp, err)
})

t.Run("valid cookie and same origin upgrade and echo", func(t *testing.T) {
h := withCookie()
h.Set("Origin", sameOriginHeader)
conn, resp, err := websocket.DefaultDialer.Dial(wsURL(ts), h)
if err != nil {
t.Fatalf("upgrade failed: %v (status %d)", err, statusOf(resp))
}
defer conn.Close()
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusSwitchingProtocols)
}
if err := conn.WriteMessage(websocket.TextMessage, []byte("ping")); err != nil {
t.Fatalf("write: %v", err)
}
_, msg, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read: %v", err)
}
if string(msg) != echoPrefix+"ping" {
t.Errorf("got %q, want %q", msg, echoPrefix+"ping")
}
})

t.Run("foreign origin is rejected", func(t *testing.T) {
h := withCookie()
h.Set("Origin", "http://attacker.example")
conn, resp, err := websocket.DefaultDialer.Dial(wsURL(ts), h)
expectRejected(t, conn, resp, err)
})

t.Run("missing origin is rejected", func(t *testing.T) {
conn, resp, err := websocket.DefaultDialer.Dial(wsURL(ts), withCookie())
expectRejected(t, conn, resp, err)
})

// The DNS-rebinding regression: a rebound page presents Host and Origin that
// agree (both the attacker's name) and a valid capability cookie the browser
// attached first-party. Only the Host allowlist stands between it and the
// dispatcher — so this must be rejected, and it must be the guard doing it.
t.Run("rebound host is rejected despite matching origin and a valid cookie", func(t *testing.T) {
forged := "attacker.example:" + port
h := withCookie()
h.Set("Host", forged)
h.Set("Origin", "http://"+forged)
conn, resp, err := websocket.DefaultDialer.Dial(wsURL(ts), h)
expectRejected(t, conn, resp, err)
})
}

// TestAllowedHostsEnvVar proves the escape hatch: a host that the fixed rules
// reject is accepted once named in WAILS_DEV_ALLOWED_HOSTS. The env var is read
// when routes are registered, so it must be set before newTestServer.
func TestAllowedHostsEnvVar(t *testing.T) {
t.Setenv(allowedHostsEnvVar, "attacker.example")
_, ts, port := newTestServer(t)

resp := getWithHost(t, ts.URL+"/wails/reload", "attacker.example:"+port)
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("with %s set, status = %d, want %d", allowedHostsEnvVar, resp.StatusCode, http.StatusNoContent)
}
}
Loading
Loading