Files
multica/server/internal/handler/plugin_surface.go
f0c9146c4b MUL-7437: fix(api): classify disabled capabilities as non-retryable (#8487)
* fix(plugin): return 403 when API is disabled (MUL-7437)

Co-authored-by: multica-agent <[email protected]>

* fix(api): classify disabled capabilities as non-retryable (MUL-7437)

Co-authored-by: multica-agent <[email protected]>

* fix(api): address disabled capability review feedback (MUL-7437)

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-16 17:27:05 +08:00

367 lines
13 KiB
Go

package handler
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/internal/util/secretbox"
"github.com/multica-ai/multica/server/pkg/plugincontract"
)
const (
pluginSurfaceLaunchTTL = 2 * time.Minute
pluginSurfaceProtocolVersion = 2
pluginSurfaceConnectMessage = "multica:plugin-bridge-connect"
pluginSurfacePortGlobal = "__multicaPluginBridgePortV2"
)
var errInvalidPluginSurfaceOrigin = errors.New("plugin surface origin must be an absolute HTTP(S) origin without a path")
type pluginSurfaceLaunchClaims struct {
WorkspaceID string `json:"workspace_id"`
InstallationID string `json:"installation_id"`
VersionID string `json:"version_id"`
SurfaceKey string `json:"surface_key"`
Digest string `json:"digest"`
Challenge string `json:"challenge"`
ExpiresAt int64 `json:"expires_at"`
}
type pluginSurfaceLaunch struct {
URL string `json:"url"`
BridgeToken string `json:"bridge_token"`
Version string `json:"version"`
Digest string `json:"digest"`
}
// NewPluginSurfaceTokenBox derives a separate AES-GCM key from the plugin
// deployment key. A surface URL can never be opened as a stored config secret,
// even though operators rotate both with one deployment setting.
func NewPluginSurfaceTokenBox(deploymentKey []byte) (*secretbox.Box, error) {
if len(deploymentKey) != secretbox.KeySize {
return nil, secretbox.ErrInvalidKey
}
mac := hmac.New(sha256.New, deploymentKey)
_, _ = mac.Write([]byte("multica/plugin-surface-launch/v1"))
return secretbox.New(mac.Sum(nil))
}
func parsePluginSurfaceOrigin(raw string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") ||
(parsed.Path != "" && parsed.Path != "/") || parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
return nil, errInvalidPluginSurfaceOrigin
}
parsed.Path = ""
return parsed, nil
}
func (h *Handler) pluginSurfaceOriginIsDedicated(origin *url.URL) bool {
candidates := make([]string, 0, len(h.cfg.AttachmentFrameAncestors)+2)
candidates = append(candidates, h.cfg.PublicURL)
candidates = append(candidates, h.cfg.AppURL)
candidates = append(candidates, h.cfg.AttachmentFrameAncestors...)
for _, candidate := range candidates {
appOrigin, err := parsePluginSurfaceOrigin(candidate)
if err == nil && strings.EqualFold(appOrigin.Host, origin.Host) {
return false
}
}
return true
}
// PluginSurfaceHostBoundary turns the configured content host into a one-route
// origin. Reusing the API process is operationally convenient; exposing its
// login, JSON API, uploads or redirects under the plugin host is not part of
// that bargain.
func (h *Handler) PluginSurfaceHostBoundary(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin, err := parsePluginSurfaceOrigin(h.cfg.PluginSurfaceOrigin)
if err == nil && h.pluginSurfaceOriginIsDedicated(origin) && strings.EqualFold(r.Host, origin.Host) &&
!strings.HasPrefix(r.URL.Path, "/plugin-surfaces/") {
http.NotFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func randomPluginSurfaceChallenge() (string, error) {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func (h *Handler) mintPluginSurfaceToken(claims pluginSurfaceLaunchClaims) (string, error) {
if h.PluginSurfaceTokens == nil {
return "", errors.New("plugin surface token encryption is not configured")
}
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("marshal plugin surface launch: %w", err)
}
sealed, err := h.PluginSurfaceTokens.Seal(payload)
if err != nil {
return "", fmt.Errorf("seal plugin surface launch: %w", err)
}
return base64.RawURLEncoding.EncodeToString(sealed), nil
}
func (h *Handler) openPluginSurfaceToken(token string) (pluginSurfaceLaunchClaims, error) {
if h.PluginSurfaceTokens == nil {
return pluginSurfaceLaunchClaims{}, errors.New("plugin surface token encryption is not configured")
}
sealed, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return pluginSurfaceLaunchClaims{}, errors.New("invalid plugin surface token encoding")
}
payload, err := h.PluginSurfaceTokens.Open(sealed)
if err != nil {
return pluginSurfaceLaunchClaims{}, errors.New("invalid plugin surface token")
}
var claims pluginSurfaceLaunchClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return pluginSurfaceLaunchClaims{}, errors.New("invalid plugin surface claims")
}
if claims.WorkspaceID == "" || claims.InstallationID == "" || claims.VersionID == "" ||
claims.SurfaceKey == "" || claims.Digest == "" || claims.Challenge == "" ||
claims.ExpiresAt <= time.Now().Unix() {
return pluginSurfaceLaunchClaims{}, errors.New("expired or incomplete plugin surface claims")
}
return claims, nil
}
// GetPluginSurfaceLaunch returns no executable bytes. It mints one short-lived
// hosted URL plus the proof that exact document must present to the exact frame
// which requested it.
func (h *Handler) GetPluginSurfaceLaunch(w http.ResponseWriter, r *http.Request) {
if !h.requirePluginsV1(w, r) {
return
}
if strings.TrimSpace(h.cfg.PluginSurfaceOrigin) == "" || h.PluginSurfaceTokens == nil {
writeFeatureDisabled(w, "plugin_surfaces_not_configured", "Plugin surfaces are unavailable: MULTICA_PLUGIN_SURFACE_ORIGIN and MULTICA_PLUGIN_SECRET_KEY must be configured")
return
}
origin, err := parsePluginSurfaceOrigin(h.cfg.PluginSurfaceOrigin)
if err != nil {
writeErrorCode(w, http.StatusInternalServerError, "plugin_surfaces_misconfigured", "Plugin surfaces require a valid MULTICA_PLUGIN_SURFACE_ORIGIN")
return
}
if !h.pluginSurfaceOriginIsDedicated(origin) {
writeErrorCode(w, http.StatusInternalServerError, "plugin_surfaces_misconfigured", "Plugin surfaces require a dedicated content origin separate from the app and API origins")
return
}
workspaceID, ok := parseUUIDOrBadRequest(w, workspaceIDFromURL(r, "id"), "workspace_id")
if !ok {
return
}
installation, err := h.PluginService.InstallationForWorkspace(r.Context(), workspaceID, chi.URLParam(r, "installationId"))
if err != nil {
writePluginError(w, err, "failed to load the Plugin")
return
}
if !installation.Enabled {
writeError(w, http.StatusForbidden, "this Plugin is disabled")
return
}
script, err := h.PluginService.SurfaceScript(r.Context(), installation, chi.URLParam(r, "surfaceKey"))
if err != nil {
writePluginError(w, err, "failed to load the Plugin surface")
return
}
challenge, err := randomPluginSurfaceChallenge()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create the Plugin surface launch")
return
}
claims := pluginSurfaceLaunchClaims{
WorkspaceID: util.UUIDToString(workspaceID),
InstallationID: util.UUIDToString(installation.ID),
VersionID: util.UUIDToString(installation.PackageVersionID),
SurfaceKey: chi.URLParam(r, "surfaceKey"),
Digest: script.Digest,
Challenge: challenge,
ExpiresAt: time.Now().Add(pluginSurfaceLaunchTTL).Unix(),
}
token, err := h.mintPluginSurfaceToken(claims)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create the Plugin surface launch")
return
}
w.Header().Set("Cache-Control", "private, no-store")
writeJSON(w, http.StatusOK, pluginSurfaceLaunch{
URL: origin.String() + "/plugin-surfaces/" + token,
BridgeToken: challenge,
Version: script.Version,
Digest: script.Digest,
})
}
// ServePluginSurface renders stored plugin code only on the configured content
// host. The path credential is encrypted, authenticated, short-lived and bound
// to the installation's immutable version, so this route needs no session and
// receives no app cookie.
func (h *Handler) ServePluginSurface(w http.ResponseWriter, r *http.Request) {
origin, err := parsePluginSurfaceOrigin(h.cfg.PluginSurfaceOrigin)
if err != nil || !h.pluginSurfaceOriginIsDedicated(origin) || !strings.EqualFold(r.Host, origin.Host) {
http.NotFound(w, r)
return
}
// Enforce the deployment promise at the request boundary. A broad parent-
// domain auth cookie must make the surface fail visibly, not quietly arrive
// on a host that was supposed to be cookie-free.
if r.Header.Get("Cookie") != "" || r.Header.Get("Authorization") != "" {
writeError(w, http.StatusBadRequest, "the Plugin content origin must not receive app credentials")
return
}
claims, err := h.openPluginSurfaceToken(chi.URLParam(r, "token"))
if err != nil {
http.NotFound(w, r)
return
}
workspaceID, err := util.ParseUUID(claims.WorkspaceID)
if err != nil {
http.NotFound(w, r)
return
}
installation, err := h.PluginService.InstallationForWorkspace(r.Context(), workspaceID, claims.InstallationID)
if err != nil || !installation.Enabled || util.UUIDToString(installation.PackageVersionID) != claims.VersionID {
http.NotFound(w, r)
return
}
script, err := h.PluginService.SurfaceScript(r.Context(), installation, claims.SurfaceKey)
if err != nil || !hmac.Equal([]byte(script.Digest), []byte(claims.Digest)) {
http.NotFound(w, r)
return
}
var scopes []string
if err := json.Unmarshal(installation.GrantedScopes, &scopes); err != nil {
http.NotFound(w, r)
return
}
document := buildPluginSurfaceDocument(script.Code, claims.Challenge)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=(), usb=()")
w.Header().Set("Cross-Origin-Resource-Policy", "cross-origin")
w.Header().Set("Content-Security-Policy", pluginSurfaceCSP(scopes))
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(document))
}
func pluginSurfaceCSP(scopes []string) string {
connect := make([]string, 0)
for _, domain := range plugincontract.NetDomains(scopes) {
// Installation scopes are validated when written, but the CSP remains a
// boundary if a row is corrupted or restored from an older database.
if plugincontract.ValidateScope(plugincontract.ScopeNetPrefix+domain) != nil {
continue
}
connect = append(connect, "https://"+domain)
}
connectSource := "'none'"
if len(connect) > 0 {
connectSource = strings.Join(connect, " ")
}
return strings.Join([]string{
"default-src 'none'",
"script-src 'unsafe-inline'",
"style-src 'unsafe-inline'",
"img-src data: blob:",
"font-src data:",
"connect-src " + connectSource,
"frame-src 'none'",
"object-src 'none'",
"base-uri 'none'",
"form-action 'none'",
}, "; ")
}
func buildPluginSurfaceDocument(code, challenge string) string {
encodedCode := base64.StdEncoding.EncodeToString([]byte(code))
bootstrap := fmt.Sprintf(`(function () {
var challenge = %s;
var codeElement = document.getElementById("multica-surface-code");
var bootstrapElement = document.currentScript;
var failed = false;
function reportSurfaceError() {
if (failed) return;
failed = true;
parent.postMessage({ type: "multica:plugin-surface-error" }, "*");
}
window.addEventListener("error", reportSurfaceError);
window.addEventListener("unhandledrejection", reportSurfaceError);
try {
var channel = new MessageChannel();
Object.defineProperty(globalThis, %s, {
value: channel.port2,
configurable: true,
enumerable: false,
writable: false
});
window.addEventListener("pagehide", function () {
parent.postMessage({ type: "multica:plugin-surface-navigated" }, "*");
});
parent.postMessage({
type: %s,
version: %d,
challenge: challenge
}, "*", [channel.port1]);
challenge = "";
var binary = atob(codeElement.textContent || "");
var bytes = new Uint8Array(binary.length);
for (var index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index);
codeElement.remove();
bootstrapElement.remove();
var plugin = document.createElement("script");
plugin.textContent = new TextDecoder().decode(bytes);
document.body.appendChild(plugin);
} catch (error) {
reportSurfaceError();
}
})();`, strconv.Quote(challenge), strconv.Quote(pluginSurfacePortGlobal), strconv.Quote(pluginSurfaceConnectMessage), pluginSurfaceProtocolVersion)
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body {
background: var(--background, transparent);
color: var(--foreground, inherit);
font: 400 var(--text-body, 14px)/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/plain" id="multica-surface-code">` + html.EscapeString(encodedCode) + `</script>
<script>` + bootstrap + `</script>
</body>
</html>`
}