fix: harden plugin runtime and UI bridge

This commit is contained in:
shaw
2026-08-24 09:50:46 +08:00
parent 26ac0498f2
commit 684d9efb1f
4 changed files with 110 additions and 5 deletions
@@ -77,7 +77,7 @@ func (h *PluginHandler) Upload(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
c.JSON(http.StatusCreated, plugin)
response.Created(c, plugin)
}
type pluginEnableRequest struct {
@@ -235,7 +235,7 @@ func (h *PluginHandler) ServeUIAsset(c *gin.Context) {
// sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。
// 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'")
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'")
c.Data(http.StatusOK, contentType, data)
}
@@ -28,6 +28,7 @@ const (
pluginConfigMaxBytes = 4 * 1024 * 1024
pluginUIAssetMaxBytes = 32 * 1024 * 1024
pluginReconcilePeriod = time.Second
pluginHealthTimeout = 5 * time.Second
pluginUITokenPrefix = "sub2api:plugin-ui:v1:"
)
@@ -267,6 +268,7 @@ func (m *PluginManager) reconcileOnce(ctx context.Context) error {
m.publishUnavailableRoute(0, 100, "插件启用状态暂时无法读取")
return fmt.Errorf("读取插件启用状态: %w", err)
}
m.cleanupStaleLocalInstallations(installations)
var enabled *PluginInstallation
for _, installation := range installations {
if !hasEnabledOpenAIBinding(installation.Bindings) {
@@ -304,6 +306,13 @@ func (m *PluginManager) reconcileOnce(ctx context.Context) error {
!current.runtime.client.Exited() && current.rolloutPercent == rollout &&
current.runtime.installation.BinarySHA256 == enabled.BinarySHA256 &&
current.runtime.installation.ConfigEncrypted == enabled.ConfigEncrypted {
healthCtx, cancel := context.WithTimeout(ctx, pluginHealthTimeout)
healthErr := current.runtime.checkHealth(healthCtx)
cancel()
if healthErr != nil {
m.markRuntimeUnavailable(current, healthErr.Error())
return healthErr
}
if enabled.State == PluginStateError || (enabled.State == PluginStateStarting && m.startingStateExpired(enabled)) {
return m.repo.MarkRuntimeHealthy(ctx, enabled.ID, enabled.BinarySHA256, enabled.ConfigEncrypted)
}
@@ -455,6 +464,32 @@ func (m *PluginManager) ensureLocalInstallation(ctx context.Context, installatio
return local, nil
}
// cleanupStaleLocalInstallations 回收本实例缓存中已从数据库删除或已被新包替换的文件。
// 数据库是跨实例的权威状态,本地目录不能因其他实例的卸载/升级永久残留。
func (m *PluginManager) cleanupStaleLocalInstallations(installations []*PluginInstallation) {
persisted := make(map[int64]*PluginInstallation, len(installations))
for _, installation := range installations {
if installation != nil {
persisted[installation.ID] = installation
}
}
m.mu.Lock()
stale := make([]*PluginInstallation, 0)
for id, local := range m.localInstallations {
current := persisted[id]
if current == nil || current.BinarySHA256 != local.BinarySHA256 || current.Version != local.Version {
stale = append(stale, local)
delete(m.localInstallations, id)
}
}
m.mu.Unlock()
for _, local := range stale {
if err := m.cleanupInstallationFiles(local); err != nil {
slog.Warn("plugin_stale_local_install_cleanup_failed", "plugin_id", local.ID, "error", err)
}
}
}
func mergeLocalInstallation(local, persisted *PluginInstallation) *PluginInstallation {
merged := *persisted
merged.ArtifactData = nil
@@ -669,6 +704,12 @@ func (m *PluginManager) SaveConfig(ctx context.Context, id int64, raw json.RawMe
if err := json.Unmarshal(raw, &normalized); err != nil {
return nil, err
}
if normalized == nil {
return nil, errors.New("插件配置 JSON 根节点必须是对象")
}
if _, ok := normalized.(map[string]any); !ok {
return nil, errors.New("插件配置 JSON 根节点必须是对象")
}
canonical, err := json.Marshal(normalized)
if err != nil {
return nil, err
@@ -123,6 +123,12 @@ func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, co
if err := json.Unmarshal(configJSON, &normalized); err != nil {
return nil, fmt.Errorf("解析插件规范化配置: %w", err)
}
if normalized == nil {
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
}
if _, ok := normalized.(map[string]any); !ok {
return nil, errors.New("插件返回的规范化配置根节点必须是对象")
}
configJSON, err = json.Marshal(normalized)
if err != nil {
return nil, fmt.Errorf("序列化插件规范化配置: %w", err)
@@ -137,6 +143,24 @@ func (r *pluginRuntime) validateAndApplyNormalizedConfig(ctx context.Context, co
return configJSON, nil
}
func (r *pluginRuntime) checkHealth(ctx context.Context) error {
if r == nil || r.api == nil || r.client == nil || r.client.Exited() {
return errors.New("插件进程已退出")
}
health, err := r.api.Health(ctx, &pluginv1.HealthRequest{})
if err != nil {
return fmt.Errorf("插件健康检查失败: %w", err)
}
if health == nil || !health.Healthy {
message := "插件报告不健康"
if health != nil && strings.TrimSpace(health.Message) != "" {
message = "插件不健康: " + health.Message
}
return errors.New(message)
}
return nil
}
func (r *pluginRuntime) beginRequest() bool {
if r == nil || r.draining.Load() {
return false
+43 -3
View File
@@ -322,7 +322,7 @@
:title="
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
"
@load="uiLoading = false"
@load="handlePluginFrameLoad"
/>
</div>
</BaseDialog>
@@ -378,6 +378,7 @@ const pluginFrame = ref<HTMLIFrameElement | null>(null);
const uiLoading = ref(false);
const uiError = ref("");
const iframeHeight = ref(640);
const pendingBridgeRequests = new Map<string, number>();
function errorMessage(error: unknown): string {
if (typeof error === "object" && error !== null && "message" in error) {
@@ -535,25 +536,52 @@ async function openConfiguration(plugin: PluginInstallation): Promise<void> {
}
function closeConfiguration(): void {
clearPendingBridgeRequests();
configPlugin.value = null;
uiSession.value = null;
uiLoading.value = false;
uiError.value = "";
}
function clearPendingBridgeRequests(): void {
for (const timeout of pendingBridgeRequests.values()) window.clearTimeout(timeout);
pendingBridgeRequests.clear();
}
function handlePluginFrameLoad(): void {
// A load can also be caused by a plugin navigating its iframe. Drop all
// outstanding responses so a late config response is never sent to the new document.
clearPendingBridgeRequests();
uiLoading.value = false;
}
function registerBridgeRequest(requestID: string): void {
const timeout = window.setTimeout(() => {
pendingBridgeRequests.delete(requestID);
}, 30_000);
pendingBridgeRequests.set(requestID, timeout);
}
function postBridgeResult(
request: PluginBridgeMessage,
payload: Record<string, unknown>,
): void {
if (!pluginFrame.value?.contentWindow || !uiSession.value) return;
const requestID = typeof request.request_id === "string" ? request.request_id.trim() : "";
const timeout = pendingBridgeRequests.get(requestID);
if (!requestID || timeout === undefined) return;
window.clearTimeout(timeout);
pendingBridgeRequests.delete(requestID);
pluginFrame.value.contentWindow.postMessage(
{
source: "sub2api-plugin-host",
bridge_token: uiSession.value.bridge_token,
type: `${request.type}.result`,
request_id: request.request_id,
request_id: requestID,
...payload,
},
// The sandboxed iframe has an opaque origin, so no fixed target origin exists.
// Pending request tracking plus load invalidation prevents cross-navigation leaks.
"*",
);
}
@@ -562,7 +590,8 @@ async function handleBridgeMessage(event: MessageEvent): Promise<void> {
if (
!uiSession.value ||
!configPlugin.value ||
event.source !== pluginFrame.value?.contentWindow
event.source !== pluginFrame.value?.contentWindow ||
event.origin !== "null"
)
return;
const message = event.data as PluginBridgeMessage;
@@ -573,6 +602,16 @@ async function handleBridgeMessage(event: MessageEvent): Promise<void> {
)
return;
const requestID = typeof message.request_id === "string" ? message.request_id.trim() : "";
const expectsResponse =
message.type === "config.load" ||
message.type === "config.save" ||
message.type === "config.test";
if (expectsResponse) {
if (!requestID || pendingBridgeRequests.has(requestID)) return;
registerBridgeRequest(requestID);
}
try {
switch (message.type) {
case "sub2api.plugin.ready":
@@ -667,5 +706,6 @@ onMounted(() => {
onBeforeUnmount(() => {
window.removeEventListener("message", handleBridgeMessage);
clearPendingBridgeRequests();
});
</script>