| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "crypto/sha256" |
| 6 | "encoding/hex" |
| 7 | "fmt" |
| 8 | "io" |
| 9 | "net" |
| 10 | "net/http" |
| 11 | "net/url" |
| 12 | "slices" |
| 13 | "strings" |
| 14 | "sync" |
| 15 | "sync/atomic" |
| 16 | "time" |
| 17 | |
| 18 | "reasonix/internal/control" |
| 19 | "reasonix/internal/plugin" |
| 20 | ) |
| 21 | |
| 22 | // mcpAppsSandbox serves MCP Apps resources from per-server loopback origins: |
| 23 | // each Apps server gets its own 127.0.0.1 listener, so two servers' apps can |
| 24 | // never share cookies, storage, or an origin. The origin serves the outer |
| 25 | // sandbox relay page and proxies validated resources to the inner sandboxed |
| 26 | // iframe; the desktop webview never loads app HTML directly. A bind failure |
| 27 | // permanently degrades this desktop to the interactive MCP profile. |
| 28 | type mcpAppsSandbox struct { |
| 29 | down atomic.Bool |
| 30 | mu sync.Mutex |
| 31 | |
| 32 | origins map[string]*mcpAppOrigin |
| 33 | bindings map[string]mcpAppBinding |
| 34 | } |
| 35 | |
| 36 | type mcpAppBinding struct { |
| 37 | tabID string |
| 38 | server string |
| 39 | host *plugin.Host |
| 40 | ctrl control.SessionAPI |
| 41 | } |
| 42 | |
| 43 | type mcpAppOrigin struct { |
| 44 | server string |
| 45 | listener net.Listener |
| 46 | http *http.Server |
| 47 | nonce string |
| 48 | } |
| 49 | |
| 50 | // maxAppResourceBytes caps one decoded ui resource; maxAppPostMessageBytes |
| 51 | // caps one relayed frame. |
| 52 | const ( |
| 53 | maxAppResourceBytes = 4 << 20 |
| 54 | maxAppPostMessageBytes = 8 << 20 |
| 55 | appResourceReadTimeout = 30 * time.Second |
| 56 | ) |
| 57 | |
| 58 | func (s *mcpAppsSandbox) available() bool { return !s.down.Load() } |
| 59 | |
| 60 | func (s *mcpAppsSandbox) bind(token string, binding mcpAppBinding) { |
| 61 | s.mu.Lock() |
| 62 | defer s.mu.Unlock() |
| 63 | if s.bindings == nil { |
| 64 | s.bindings = map[string]mcpAppBinding{} |
| 65 | } |
| 66 | s.bindings[token] = binding |
| 67 | } |
| 68 | |
| 69 | func (s *mcpAppsSandbox) binding(token string) (mcpAppBinding, bool) { |
| 70 | s.mu.Lock() |
| 71 | defer s.mu.Unlock() |
| 72 | binding, ok := s.bindings[token] |
| 73 | return binding, ok |
| 74 | } |
| 75 | |
| 76 | func (s *mcpAppsSandbox) release(token string) (mcpAppBinding, bool) { |
| 77 | s.mu.Lock() |
| 78 | defer s.mu.Unlock() |
| 79 | binding, ok := s.bindings[token] |
| 80 | delete(s.bindings, token) |
| 81 | return binding, ok |
| 82 | } |
| 83 | |
| 84 | // appOriginURL returns the outer sandbox page URL for a server, binding the |
| 85 | // per-server listener on first use. |
| 86 | func (a *App) appOriginURL(server string) (string, error) { |
| 87 | s := &a.mcpAppsSandbox |
| 88 | if !s.available() { |
| 89 | return "", fmt.Errorf("MCP Apps sandbox unavailable") |
| 90 | } |
| 91 | s.mu.Lock() |
| 92 | defer s.mu.Unlock() |
| 93 | if s.origins == nil { |
| 94 | s.origins = map[string]*mcpAppOrigin{} |
| 95 | } |
| 96 | if o, ok := s.origins[server]; ok { |
| 97 | return o.relayURL(), nil |
| 98 | } |
| 99 | ln, err := net.Listen("tcp", "127.0.0.1:0") |
| 100 | if err != nil { |
| 101 | s.down.Store(true) |
| 102 | return "", fmt.Errorf("bind MCP Apps origin: %w", err) |
| 103 | } |
| 104 | nonceBytes := make([]byte, 16) |
| 105 | if _, err := rand.Read(nonceBytes); err != nil { |
| 106 | _ = ln.Close() |
| 107 | return "", fmt.Errorf("app origin nonce: %w", err) |
| 108 | } |
| 109 | o := &mcpAppOrigin{server: server, listener: ln, nonce: hex.EncodeToString(nonceBytes)} |
| 110 | o.http = &http.Server{ |
| 111 | Handler: o.mux(a), |
| 112 | ReadHeaderTimeout: 10 * time.Second, |
| 113 | IdleTimeout: 2 * time.Minute, |
| 114 | MaxHeaderBytes: 1 << 20, |
| 115 | } |
| 116 | s.origins[server] = o |
| 117 | go func() { _ = o.http.Serve(ln) }() |
| 118 | if a.ctx != nil { |
| 119 | a.goSafe("mcpAppOrigin:"+server, func() { |
| 120 | <-a.ctx.Done() |
| 121 | _ = o.http.Close() |
| 122 | }) |
| 123 | } |
| 124 | return o.relayURL(), nil |
| 125 | } |
| 126 | |
| 127 | func (o *mcpAppOrigin) relayURL() string { |
| 128 | return fmt.Sprintf("http://127.0.0.1:%d/sandbox?nonce=%s", o.listener.Addr().(*net.TCPAddr).Port, o.nonce) |
| 129 | } |
| 130 | |
| 131 | func (o *mcpAppOrigin) mux(a *App) *http.ServeMux { |
| 132 | mux := http.NewServeMux() |
| 133 | mux.HandleFunc("/sandbox", o.serveRelayPage) |
| 134 | mux.HandleFunc("/resource", o.serveResource(a)) |
| 135 | return mux |
| 136 | } |
| 137 | |
| 138 | // outerSandboxRelay is the only page served at the loopback origin: a relay |
| 139 | // between the desktop webview (parent) and the inner sandboxed iframe. It |
| 140 | // hardens the channel: no top navigation, popup, object, or download; the |
| 141 | // instance nonce binds the first parent message; every relayed frame checks |
| 142 | // event.source; frames above the cap are refused; RPC before the inner frame |
| 143 | // loads is dropped. |
| 144 | const outerSandboxRelay = `<!doctype html> |
| 145 | <html><head><meta charset="utf-8"><title>MCP App</title> |
| 146 | <script> |
| 147 | (function () { |
| 148 | var params = new URLSearchParams(location.search); |
| 149 | var nonce = params.get("nonce"); |
| 150 | var src = params.get("src"); |
| 151 | var inner = null; |
| 152 | try { |
| 153 | var resource = new URL(src, location.href); |
| 154 | if (resource.origin !== location.origin || resource.pathname !== "/resource") return; |
| 155 | src = resource.pathname + resource.search; |
| 156 | } catch (e) { return; } |
| 157 | function frameSize(data) { |
| 158 | try { |
| 159 | var text = typeof data === "string" ? data : JSON.stringify(data); |
| 160 | return new TextEncoder().encode(text).byteLength; |
| 161 | } catch (e) { return %d + 1; } |
| 162 | } |
| 163 | window.addEventListener("message", function (event) { |
| 164 | if (event.source === window.parent) { |
| 165 | if (event.data && event.data.__mcpInit === nonce) { |
| 166 | if (inner) return; |
| 167 | inner = document.createElement("iframe"); |
| 168 | inner.setAttribute("sandbox", "allow-scripts"); |
| 169 | inner.setAttribute("src", src); |
| 170 | document.body.appendChild(inner); |
| 171 | return; |
| 172 | } |
| 173 | if (!inner || !inner.contentWindow || frameSize(event.data) > %d) return; |
| 174 | try { inner.contentWindow.postMessage(event.data, "*"); } catch (e) {} |
| 175 | return; |
| 176 | } |
| 177 | if (!inner || event.source !== inner.contentWindow) return; |
| 178 | if (frameSize(event.data) > %d) return; |
| 179 | try { window.parent.postMessage(event.data, "*"); } catch (e) {} |
| 180 | }); |
| 181 | }()); |
| 182 | </script></head><body></body></html>` |
| 183 | |
| 184 | func (o *mcpAppOrigin) serveRelayPage(w http.ResponseWriter, r *http.Request) { |
| 185 | if r.Method != http.MethodGet || r.URL.Query().Get("nonce") != o.nonce { |
| 186 | http.Error(w, "unknown instance", http.StatusForbidden) |
| 187 | return |
| 188 | } |
| 189 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 190 | w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-src 'self'; script-src 'unsafe-inline'") |
| 191 | fmt.Fprintf(w, outerSandboxRelay, maxAppPostMessageBytes, maxAppPostMessageBytes, maxAppPostMessageBytes) |
| 192 | } |
| 193 | |
| 194 | // serveResource validates the instance token and digest, then serves the |
| 195 | // immutable resource snapshot captured when the App was opened. Only the |
| 196 | // inner sandboxed iframe loads this copy. |
| 197 | func (o *mcpAppOrigin) serveResource(a *App) http.HandlerFunc { |
| 198 | return func(w http.ResponseWriter, r *http.Request) { |
| 199 | if r.Method != http.MethodGet { |
| 200 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) |
| 201 | return |
| 202 | } |
| 203 | token := r.URL.Query().Get("token") |
| 204 | binding, ok := a.mcpAppsSandbox.binding(token) |
| 205 | if !ok || binding.host == nil || binding.server != o.server { |
| 206 | http.Error(w, "unknown instance", http.StatusForbidden) |
| 207 | return |
| 208 | } |
| 209 | inst, ok := binding.host.LookupAppInstance(token) |
| 210 | if !ok || inst.Server != o.server || !strings.HasPrefix(inst.ResourceURI, "ui://") { |
| 211 | a.mcpAppsSandbox.release(token) |
| 212 | http.Error(w, "unknown instance", http.StatusForbidden) |
| 213 | return |
| 214 | } |
| 215 | snapshot, ok := binding.host.AppResource(token) |
| 216 | if !ok || r.URL.Query().Get("digest") != snapshot.Digest || len(snapshot.Content) > maxAppResourceBytes || !isAppHTMLMimeType(snapshot.MIME) { |
| 217 | http.Error(w, "resource unavailable", http.StatusBadGateway) |
| 218 | return |
| 219 | } |
| 220 | w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| 221 | w.Header().Set("Content-Security-Policy", appResourceCSP(snapshot.CSP)) |
| 222 | w.Header().Set("Cache-Control", "no-store") |
| 223 | w.Header().Set("X-App-Sha256", snapshot.Digest) |
| 224 | _, _ = io.WriteString(w, snapshot.Content) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | func isAppHTMLMimeType(mime string) bool { |
| 229 | mime = strings.ToLower(strings.TrimSpace(mime)) |
| 230 | return mime == "" || mime == "text/html" || strings.HasPrefix(mime, "text/html;") || |
| 231 | strings.Contains(mime, "profile=mcp-app") |
| 232 | } |
| 233 | |
| 234 | // appResourceCSP defaults every channel to deny and extends it only with exact |
| 235 | // declared origins. Wildcards, credentials, paths, and undeclared hosts are |
| 236 | // refused; connectDomains additionally accepts exact ws/wss origins. |
| 237 | func appResourceCSP(csp map[string][]string) string { |
| 238 | connect := allowedCSPSources(csp, []string{"connectDomains", "connect-src"}, []string{"http", "https", "ws", "wss"}) |
| 239 | resources := allowedCSPSources(csp, []string{"resourceDomains", "resource-src"}, []string{"http", "https"}) |
| 240 | frames := allowedCSPSources(csp, []string{"frameDomains", "frame-src"}, []string{"http", "https"}) |
| 241 | bases := allowedCSPSources(csp, []string{"baseUriDomains", "base-uri"}, []string{"http", "https"}) |
| 242 | directives := []string{ |
| 243 | "default-src 'none'", |
| 244 | "object-src 'none'", |
| 245 | "script-src " + cspWithBase("'unsafe-inline'", resources), |
| 246 | "style-src " + cspWithBase("'unsafe-inline'", resources), |
| 247 | "img-src " + cspWithBase("data:", resources), |
| 248 | "font-src " + cspOr("'none'", resources), |
| 249 | "media-src " + cspOr("'none'", resources), |
| 250 | "connect-src " + cspOr("'none'", connect), |
| 251 | "frame-src " + cspOr("'none'", frames), |
| 252 | "base-uri " + cspOr("'self'", bases), |
| 253 | "frame-ancestors 'self'", |
| 254 | } |
| 255 | return strings.Join(directives, "; ") |
| 256 | } |
| 257 | |
| 258 | func cspOr(fallback string, sources []string) string { |
| 259 | if len(sources) == 0 { |
| 260 | return fallback |
| 261 | } |
| 262 | return strings.Join(sources, " ") |
| 263 | } |
| 264 | |
| 265 | func cspWithBase(base string, sources []string) string { |
| 266 | if len(sources) == 0 { |
| 267 | return base |
| 268 | } |
| 269 | return base + " " + strings.Join(sources, " ") |
| 270 | } |
| 271 | |
| 272 | func allowedCSPSources(csp map[string][]string, keys, schemes []string) []string { |
| 273 | var allowed []string |
| 274 | for _, key := range keys { |
| 275 | for _, source := range csp[key] { |
| 276 | if cspOriginAllowed(source, schemes) { |
| 277 | allowed = append(allowed, strings.TrimSpace(source)) |
| 278 | } |
| 279 | } |
| 280 | } |
| 281 | slices.Sort(allowed) |
| 282 | return slices.Compact(allowed) |
| 283 | } |
| 284 | |
| 285 | func cspOriginAllowed(origin string, schemes []string) bool { |
| 286 | origin = strings.TrimSpace(origin) |
| 287 | if origin == "" || strings.ContainsAny(origin, "*'; ") { |
| 288 | return false |
| 289 | } |
| 290 | u, err := url.Parse(origin) |
| 291 | return err == nil && slices.Contains(schemes, u.Scheme) && u.Host != "" && u.User == nil && |
| 292 | (u.Path == "" || u.Path == "/") && u.RawQuery == "" && u.Fragment == "" |
| 293 | } |
| 294 | |
| 295 | func resourceDigest(content string) string { |
| 296 | sum := sha256.Sum256([]byte(content)) |
| 297 | return hex.EncodeToString(sum[:]) |
| 298 | } |
| 299 |