返回 DeepSeek-Reasonix
mcp_apps_sandbox.go
根目录 / desktop / mcp_apps_sandbox.go
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
299 lines GO