From 96b36f41b902b0304c692eb6d52ae408366255b3 Mon Sep 17 00:00:00 2001 From: 0x <0xfbfe7617@gmail.com> Date: Thu, 27 Aug 2026 00:56:54 +0300 Subject: [PATCH 1/2] Harden the dev IPC server against cross-origin and DNS-rebinding attacks Gate the wails dev IPC WebSocket behind a per-launch capability cookie and a strict same-origin check, and validate every request's Host against an allowlist (localhost, IP literals, the configured bind address, and WAILS_DEV_ALLOWED_HOSTS) so a malicious page in the developer's browser cannot reach the bound-method dispatcher. --- v2/internal/frontend/devserver/devserver.go | 72 +++++- .../frontend/devserver/devserver_test.go | 235 ++++++++++++++++++ v2/internal/frontend/devserver/host_guard.go | 106 ++++++++ .../frontend/devserver/host_guard_test.go | 90 +++++++ v2/internal/frontend/devserver/origin_test.go | 42 ++++ v2/internal/frontend/ipcauth/ipcauth.go | 46 ++++ v2/internal/frontend/ipcauth/ipcauth_test.go | 24 ++ website/src/pages/changelog.mdx | 4 + 8 files changed, 616 insertions(+), 3 deletions(-) create mode 100644 v2/internal/frontend/devserver/devserver_test.go create mode 100644 v2/internal/frontend/devserver/host_guard.go create mode 100644 v2/internal/frontend/devserver/host_guard_test.go create mode 100644 v2/internal/frontend/devserver/origin_test.go create mode 100644 v2/internal/frontend/ipcauth/ipcauth.go create mode 100644 v2/internal/frontend/ipcauth/ipcauth_test.go diff --git a/v2/internal/frontend/devserver/devserver.go b/v2/internal/frontend/devserver/devserver.go index 8a130890d7e..6ee2e4b2ea7 100644 --- a/v2/internal/frontend/devserver/devserver.go +++ b/v2/internal/frontend/devserver/devserver.go @@ -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" @@ -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 { @@ -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 { @@ -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() @@ -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) diff --git a/v2/internal/frontend/devserver/devserver_test.go b/v2/internal/frontend/devserver/devserver_test.go new file mode 100644 index 00000000000..87bce9a43ee --- /dev/null +++ b/v2/internal/frontend/devserver/devserver_test.go @@ -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:, 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) + } +} diff --git a/v2/internal/frontend/devserver/host_guard.go b/v2/internal/frontend/devserver/host_guard.go new file mode 100644 index 00000000000..fd40c64df8d --- /dev/null +++ b/v2/internal/frontend/devserver/host_guard.go @@ -0,0 +1,106 @@ +//go:build dev +// +build dev + +package devserver + +import ( + "net" + "net/http" + "net/netip" + "os" + "strings" + + "github.com/labstack/echo/v4" +) + +// allowedHostsEnvVar names additional hostnames the dev server should trust, +// beyond loopback and its own bind address. The value is a comma-separated +// list, matched against the request Host's hostname only (the port is not +// checked, so a reverse proxy listening on a different public port still +// works). This is the escape hatch for LAN-by-hostname and proxied setups. +const allowedHostsEnvVar = "WAILS_DEV_ALLOWED_HOSTS" + +// localhostName is the only hostname that is inherently loopback: unlike an +// arbitrary name it cannot be repointed elsewhere by a DNS answer. +const localhostName = "localhost" + +// defaultHTTPPort is assumed when a Host header carries no port. The dev server +// speaks plain HTTP, so a port-less Host can only have meant port 80. +const defaultHTTPPort = "80" + +// hostAllowed reports whether a request carrying requestHost may be served. +// +// DNS rebinding requires a resolvable name in the Host header, so the only +// hosts accepted are ones that cannot be repointed by DNS: localhost, any IP +// literal, and the exact address the server was told to bind. Extra hostnames +// can be trusted through allowedHostsEnvVar for reverse-proxy or +// LAN-by-hostname setups. Anything else — including exotic spellings such as a +// trailing dot, shorthand IPs, or unbracketed IPv6 — is rejected, so the guard +// fails closed and the env var is the one way to widen it. +func hostAllowed(requestHost, bindHost, bindPort string, extraHosts []string) bool { + host, port := splitRequestHost(requestHost) + + lowerHost := strings.ToLower(host) + for _, extra := range extraHosts { + if lowerHost == extra { + return true + } + } + + if port != bindPort { + return false + } + + if strings.EqualFold(host, localhostName) { + return true + } + if _, err := netip.ParseAddr(host); err == nil { + return true + } + // An empty bind host (e.g. a ":34115" wildcard bind) only matches the empty + // host the CLI's own poller sends; a browser cannot emit an empty Host. + return strings.EqualFold(host, bindHost) +} + +// splitRequestHost splits a Host header into host and port, treating a missing +// port as defaultHTTPPort. A bracketed IPv6 literal with no port (e.g. "[::1]") +// has its brackets stripped so the caller sees the bare address. +func splitRequestHost(requestHost string) (host, port string) { + if h, p, err := net.SplitHostPort(requestHost); err == nil { + return h, p + } + return strings.Trim(requestHost, "[]"), defaultHTTPPort +} + +// parseAllowedHosts turns the comma-separated allowedHostsEnvVar value into a +// slice of trimmed, lower-cased hostnames, dropping empty entries. +func parseAllowedHosts(value string) []string { + var hosts []string + for _, entry := range strings.Split(value, ",") { + if entry = strings.ToLower(strings.TrimSpace(entry)); entry != "" { + hosts = append(hosts, entry) + } + } + return hosts +} + +// requireAllowedHost builds middleware that rejects any request whose Host +// header is not allowed by hostAllowed. It is registered ahead of the cookie +// middleware so a rebound page is turned away before it is handed the +// capability or reaches any route. The bind address and env var are read once, +// at registration; an unparseable bind address leaves both parts empty and so +// rejects everything, which is the safe default (and unreachable in practice, +// since no listener starts without a valid address). +func (d *DevWebServer) requireAllowedHost() echo.MiddlewareFunc { + bindHost, bindPort, _ := net.SplitHostPort(d.devServerAddr) + extraHosts := parseAllowedHosts(os.Getenv(allowedHostsEnvVar)) + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + if !hostAllowed(c.Request().Host, bindHost, bindPort, extraHosts) { + d.logger.Error("Dev server request rejected: Host %q is not an allowed host; set "+allowedHostsEnvVar+" to trust additional hostnames", c.Request().Host) + return c.NoContent(http.StatusForbidden) + } + return next(c) + } + } +} diff --git a/v2/internal/frontend/devserver/host_guard_test.go b/v2/internal/frontend/devserver/host_guard_test.go new file mode 100644 index 00000000000..085d47c9373 --- /dev/null +++ b/v2/internal/frontend/devserver/host_guard_test.go @@ -0,0 +1,90 @@ +//go:build dev + +package devserver + +import ( + "testing" +) + +func TestHostAllowed(t *testing.T) { + cases := []struct { + name string + requestHost string + bindHost, bindPort string + extraHosts []string + want bool + }{ + // Default localhost:34115 bind. + {"localhost matches", "localhost:34115", "localhost", "34115", nil, true}, + {"localhost is case-insensitive", "LOCALHOST:34115", "localhost", "34115", nil, true}, + {"wrong port is rejected", "localhost:9999", "localhost", "34115", nil, false}, + {"elided port defaults to 80 and misses", "localhost", "localhost", "34115", nil, false}, + {"trailing dot is not localhost", "localhost.:34115", "localhost", "34115", nil, false}, + {"subdomain of localhost is rejected", "app.localhost:34115", "localhost", "34115", nil, false}, + {"loopback IPv4 literal", "127.0.0.1:34115", "localhost", "34115", nil, true}, + {"loopback IPv6 literal", "[::1]:34115", "localhost", "34115", nil, true}, + {"unspecified IPv4 literal", "0.0.0.0:34115", "localhost", "34115", nil, true}, + {"unspecified IPv6 literal", "[::]:34115", "localhost", "34115", nil, true}, + {"IPv6 literal with zone", "[fe80::1%en0]:34115", "localhost", "34115", nil, true}, + {"unbracketed IPv6 is rejected", "::1:34115", "localhost", "34115", nil, false}, + {"shorthand IPv4 is rejected", "127.1:34115", "localhost", "34115", nil, false}, + {"octal IPv4 is rejected", "0177.0.0.1:34115", "localhost", "34115", nil, false}, + {"foreign name is rejected (rebinding)", "attacker.example:34115", "localhost", "34115", nil, false}, + {"localhost-prefixed name is rejected", "localhost.attacker.example:34115", "localhost", "34115", nil, false}, + {"empty host is rejected", "", "localhost", "34115", nil, false}, + {"malformed host is rejected", "a:b:c", "localhost", "34115", nil, false}, + + // Wildcard bind ":34115" — bind host is empty; only the CLI's own + // empty-host poll matches, never a browser. + {"empty bind host matches empty request host", ":34115", "", "34115", nil, true}, + {"empty bind host still rejects a name", "evil.example:34115", "", "34115", nil, false}, + + // 0.0.0.0 bind — LAN device testing reaches the machine by IP. + {"LAN IP under wildcard bind", "192.168.1.5:34115", "0.0.0.0", "34115", nil, true}, + {"LAN hostname under wildcard bind is rejected", "mylaptop.local:34115", "0.0.0.0", "34115", nil, false}, + + // Explicit hostname bind. + {"configured bind host is case-insensitive", "MYBOX.LAN:34115", "mybox.lan", "34115", nil, true}, + + // Env-var escape hatch (entries are already lower-cased by parseAllowedHosts). + {"extra host without port", "dev.example.com", "localhost", "34115", []string{"dev.example.com"}, true}, + {"extra host is port-exempt", "dev.example.com:443", "localhost", "34115", []string{"dev.example.com"}, true}, + {"extra host is case-insensitive", "DEV.EXAMPLE.COM:443", "localhost", "34115", []string{"dev.example.com"}, true}, + {"subdomain of an extra host is rejected", "sub.dev.example.com:34115", "localhost", "34115", []string{"dev.example.com"}, false}, + } + + for _, c := range cases { + if got := hostAllowed(c.requestHost, c.bindHost, c.bindPort, c.extraHosts); got != c.want { + t.Errorf("%s: hostAllowed(%q, %q, %q, %v) = %v, want %v", + c.name, c.requestHost, c.bindHost, c.bindPort, c.extraHosts, got, c.want) + } + } +} + +func TestParseAllowedHosts(t *testing.T) { + cases := []struct { + name string + value string + want []string + }{ + {"empty", "", nil}, + {"whitespace only", " ", nil}, + {"simple list", "a,b", []string{"a", "b"}}, + {"trims, lower-cases, drops empties", " A , ,b ", []string{"a", "b"}}, + {"lower-cases a hostname", "Dev.Example.COM", []string{"dev.example.com"}}, + } + + for _, c := range cases { + got := parseAllowedHosts(c.value) + if len(got) != len(c.want) { + t.Errorf("%s: parseAllowedHosts(%q) = %v, want %v", c.name, c.value, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("%s: parseAllowedHosts(%q) = %v, want %v", c.name, c.value, got, c.want) + break + } + } + } +} diff --git a/v2/internal/frontend/devserver/origin_test.go b/v2/internal/frontend/devserver/origin_test.go new file mode 100644 index 00000000000..b6dee1fb9e0 --- /dev/null +++ b/v2/internal/frontend/devserver/origin_test.go @@ -0,0 +1,42 @@ +//go:build dev + +package devserver + +import ( + "net/http" + "testing" +) + +func req(origin, host string) *http.Request { + r := &http.Request{Host: host, Header: http.Header{}} + if origin != "" { + r.Header.Set("Origin", origin) + } + return r +} + +func TestSameOrigin(t *testing.T) { + cases := []struct { + name string + origin, host string + want bool + }{ + {"same origin page", "http://localhost:34115", "localhost:34115", true}, + {"same origin over https", "https://localhost:34115", "localhost:34115", true}, + {"host comparison is case-insensitive", "http://LocalHost:34115", "localhost:34115", true}, + {"same origin IPv6", "http://[::1]:34115", "[::1]:34115", true}, + {"headerless client is now refused", "", "localhost:34115", false}, + {"foreign https page", "https://evil.example", "localhost:34115", false}, + {"foreign loopback port", "http://localhost:9999", "localhost:34115", false}, + {"unparseable origin", "://nope", "localhost:34115", false}, + {"null origin", "null", "localhost:34115", false}, + {"file origin", "file:///etc/passwd", "localhost:34115", false}, + {"wails scheme origin", "wails://wails", "localhost:34115", false}, + {"extension origin", "chrome-extension://abcdef", "localhost:34115", false}, + } + for _, c := range cases { + if got := sameOrigin(req(c.origin, c.host)); got != c.want { + t.Errorf("%s: origin=%q host=%q got %v want %v", c.name, c.origin, c.host, got, c.want) + } + } +} diff --git a/v2/internal/frontend/ipcauth/ipcauth.go b/v2/internal/frontend/ipcauth/ipcauth.go new file mode 100644 index 00000000000..670e5139b5e --- /dev/null +++ b/v2/internal/frontend/ipcauth/ipcauth.go @@ -0,0 +1,46 @@ +// Package ipcauth holds the per-launch capability that hardens access to the +// development IPC server against the browser vector. +// +// The `wails dev` IPC WebSocket forwards straight to the bound-method +// dispatcher. The token is minted once per launch, handed only to pages the dev +// server itself serves (as an HttpOnly, SameSite=Strict cookie), and required +// on every WebSocket upgrade. Together with the dev server's Host allowlist and +// same-origin check it raises the bar for a malicious page in the developer's +// browser. +// +// It is deliberately not a defence against other processes running as the same +// user: such a process can read the cookie from a served response, or read the +// developer's memory and traffic outright, so it is outside this package's +// threat model. +package ipcauth + +import ( + "crypto/rand" + "crypto/subtle" + "sync" +) + +// CookieName is the cookie the dev server sets on the pages it serves and +// requires back on the IPC WebSocket upgrade. +const CookieName = "wails_ipc_capability" + +var ( + once sync.Once + token string +) + +// Token returns the process-wide dev-IPC capability, minting it on first use. +// rand.Text is a CSPRNG string and cannot fail, so obtaining the capability can +// never be the thing that breaks the dev server. +func Token() string { + once.Do(func() { token = rand.Text() }) + return token +} + +// Valid reports whether presented equals the capability. subtle.ConstantTimeCompare +// returns early when the lengths differ, so the compare is constant-time only +// across equal-length inputs; that is enough here, since the token has a fixed +// length and a length mismatch already means the wrong value. +func Valid(presented string) bool { + return subtle.ConstantTimeCompare([]byte(presented), []byte(Token())) == 1 +} diff --git a/v2/internal/frontend/ipcauth/ipcauth_test.go b/v2/internal/frontend/ipcauth/ipcauth_test.go new file mode 100644 index 00000000000..8847bf5df34 --- /dev/null +++ b/v2/internal/frontend/ipcauth/ipcauth_test.go @@ -0,0 +1,24 @@ +package ipcauth + +import "testing" + +func TestTokenIsStableAndNonEmpty(t *testing.T) { + a, b := Token(), Token() + if a == "" { + t.Fatal("token is empty") + } + if a != b { + t.Fatalf("token is not stable across calls: %q vs %q", a, b) + } +} + +func TestValidAcceptsOnlyTheToken(t *testing.T) { + if !Valid(Token()) { + t.Error("the real token was rejected") + } + for _, bad := range []string{"", "nope", Token() + "x", Token()[:len(Token())-1]} { + if Valid(bad) { + t.Errorf("an invalid capability %q was accepted", bad) + } + } +} diff --git a/website/src/pages/changelog.mdx b/website/src/pages/changelog.mdx index f5daf0729ec..18bbdcaedca 100644 --- a/website/src/pages/changelog.mdx +++ b/website/src/pages/changelog.mdx @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Security + +- Hardened the `wails dev` server against cross-origin and DNS-rebinding attacks on the IPC WebSocket. It previously accepted any origin and forwarded straight to the bound-method dispatcher, so a malicious page in the developer's browser could connect to `ws://localhost:34115/wails/ipc` and invoke the entire bound Go API. Every request's `Host` header is now checked against an allowlist (localhost, IP literals, the configured bind address, plus any hosts named in `WAILS_DEV_ALLOWED_HOSTS`), the IPC upgrade requires a same-origin `Origin` header, and a per-launch capability cookie (HttpOnly, SameSite=Strict) is required on the upgrade as defense in depth. This protects against browser-based attacks; other processes running as the developer's own user are outside the dev server's threat model by @nft + ## v2.15.0 - 2026-08-17 ### Added From 8e36637d1dd77bac95e8148f9b16f59a2e807ae8 Mon Sep 17 00:00:00 2001 From: 0x <0xfbfe7617@gmail.com> Date: Mon, 31 Aug 2026 01:10:13 +0300 Subject: [PATCH 2/2] Silence the unhandled-key system beep in WailsWindow --- .../frontend/desktop/darwin/WailsContext.m | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/v2/internal/frontend/desktop/darwin/WailsContext.m b/v2/internal/frontend/desktop/darwin/WailsContext.m index 55878eab7be..518f2e55335 100644 --- a/v2/internal/frontend/desktop/darwin/WailsContext.m +++ b/v2/internal/frontend/desktop/darwin/WailsContext.m @@ -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