Files
siyuan/kernel/plugin/sandbox.go
Yingyi / 颖逸 c59b8ec7f7 🎨 Harden RPC handling and improve error reporting in kernel plugin (#17655)
* perf(kernel-plugin): strengthen RPC, sandbox, and form parsing

Validate and harden plugin RPC and request handling: ensure RPC API call first argument is a string; treat missing method using HasValue(); return InvalidParams for malformed params; bail out early when kernel is incompatible or missing. Fix sandbox promise invocation to return after reporting errors to avoid continuing on nil/invalid values. Change RequestForm files to []*RequestFile, allocate pointer entries, properly open/read/close uploaded files, and clone request headers before modifying them. These changes prevent nil derefs, resource leaks, and improve error reporting.

* perf(kernel-plugin): Skip empty Content-Type; use safe type assertions

Avoid setting an empty Content-Type header in the proxy when gin.Context.ContentType() is empty. Replace unsafe type assertions with comma-ok checks when converting request and file body bytes to Data objects to prevent panics on unexpected types or nil pointers. Also comment out assignments of c.Request.Context().Err() in plugin request handlers to avoid overwriting other error state on context cancellation. Affected files: kernel/api/network.go, kernel/plugin/plugin.go, kernel/plugin/sandbox.go.
2026-05-09 19:38:54 +08:00

532 lines
15 KiB
Go

// SiYuan - Refactor your thinking
// Copyright (c) 2020-present, b3log.org
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package plugin
import (
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja_nodejs/buffer"
"github.com/dop251/goja_nodejs/console"
"github.com/dop251/goja_nodejs/require"
"github.com/dop251/goja_nodejs/url"
"github.com/imroc/req/v3"
"github.com/samber/lo"
"github.com/siyuan-note/logging"
)
type WebSocketState int64
const (
WebSocketReadyStateConnecting WebSocketState = iota
WebSocketReadyStateOpen
WebSocketReadyStateClosing
WebSocketReadyStateClosed
)
type EventSourceState int64
const (
EventSourceConnecting EventSourceState = iota
EventSourceOpen
EventSourceClosed
)
var (
httpClient *req.Client = req.C().SetTimeout(time.Minute)
)
type FunctionResult[T any] struct {
Value T
Error error
}
type CallResult FunctionResult[goja.Value]
func (r *CallResult) TaskResult() *TaskResult {
if r.Error != nil {
return &TaskResult{err: r.Error}
}
return &TaskResult{value: r.Value.Export()}
}
// EnableExtendModules registers extended modules (e.g. url, buffer) to the plugin's goja runtime.
func EnableExtendModules(p *KernelPlugin, rt *goja.Runtime) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("failed to enable extend modules: %v", r)
}
}()
registry := require.NewRegistry()
registry.Enable(rt)
registry.RegisterNativeModule(
console.ModuleName,
console.RequireWithPrinter(&Printer{name: p.Name}),
)
url.Enable(rt)
buffer.Enable(rt)
console.Enable(rt)
return
}
// EnableSiyuanModule injects all siyuan.* APIs into the plugin's goja global context.
func EnableSiyuanModule(p *KernelPlugin, rt *goja.Runtime) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("failed to inject global context: %v", r)
}
}()
siyuan := rt.NewObject()
lo.Must0(injectPlugin(p, rt, siyuan))
lo.Must0(injectEvent(p, rt, siyuan))
lo.Must0(injectLogger(p, rt, siyuan))
lo.Must0(injectStorage(p, rt, siyuan))
lo.Must0(injectRpc(p, rt, siyuan))
lo.Must0(injectClient(p, rt, siyuan))
lo.Must0(injectServer(p, rt, siyuan))
lo.Must0(ObjectFreeze(rt, siyuan))
lo.Must0(rt.GlobalObject().Set("siyuan", siyuan))
return
}
// ObjectFreeze calls Object.freeze() on the given goja object.
func ObjectFreeze(rt *goja.Runtime, obj *goja.Object) error {
Object := rt.GlobalObject().Get("Object").ToObject(rt)
if Object == nil {
return fmt.Errorf("globalThis.Object is not an object")
}
freeze, ok := goja.AssertFunction(Object.Get("freeze"))
if !ok {
return fmt.Errorf("globalThis.Object.freeze is not a function")
}
_, err := freeze(Object, obj)
return err
}
// ObjectSeal calls Object.seal() on the given goja object.
func ObjectSeal(rt *goja.Runtime, obj *goja.Object) error {
Object := rt.GlobalObject().Get("Object").ToObject(rt)
if Object == nil {
return fmt.Errorf("globalThis.Object is not an object")
}
seal, ok := goja.AssertFunction(Object.Get("seal"))
if !ok {
return fmt.Errorf("globalThis.Object.seal is not a function")
}
_, err := seal(Object, obj)
return err
}
// ObjectSetDataMethods attaches text(), json(), buffer() and arrayBuffer() methods to a JS object,
// each returning a Promise that resolves with the corresponding representation of data.
func ObjectSetDataMethods(p *KernelPlugin, rt *goja.Runtime, object *goja.Object, data []byte) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("ObjectSetDataMethods: %v", r)
}
}()
lo.Must0(object.Set("text", rt.ToValue(func(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
promise, resolve, reject := rt.NewPromise()
runErr := p.worker.Run(func(rt *goja.Runtime) (result any, err error) {
result = string(data)
return
}, func(rt *goja.Runtime, result any, err error) {
if lo.IsNil(err) {
if resolveErr := resolve(result); resolveErr != nil {
logging.LogErrorf("[plugin:%s] data.text() resolve: %v", p.Name, resolveErr)
}
} else {
if rejectErr := reject(rt.NewGoError(err)); rejectErr != nil {
logging.LogErrorf("[plugin:%s] data.text() reject: %v", p.Name, rejectErr)
}
}
})
if runErr != nil {
logging.LogErrorf("[plugin:%s] text worker run: %v", p.Name, runErr)
}
return rt.ToValue(promise)
})))
lo.Must0(object.Set("json", rt.ToValue(func(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
promise, resolve, reject := rt.NewPromise()
runErr := p.worker.Run(func(rt *goja.Runtime) (result any, err error) {
var value any
if unmarshalErr := json.Unmarshal(data, &value); unmarshalErr != nil {
err = unmarshalErr
return
}
result = rt.ToValue(value)
return
}, func(rt *goja.Runtime, result any, err error) {
if lo.IsNil(err) {
if resolveErr := resolve(result); resolveErr != nil {
logging.LogErrorf("[plugin:%s] data.json() resolve: %v", p.Name, resolveErr)
}
} else {
if rejectErr := reject(rt.NewGoError(err)); rejectErr != nil {
logging.LogErrorf("[plugin:%s] data.json() reject: %v", p.Name, rejectErr)
}
}
})
if runErr != nil {
logging.LogErrorf("[plugin:%s] json worker run: %v", p.Name, runErr)
}
return rt.ToValue(promise)
})))
lo.Must0(object.Set("buffer", rt.ToValue(func(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
promise, resolve, reject := rt.NewPromise()
runErr := p.worker.Run(func(rt *goja.Runtime) (result any, err error) {
result = buffer.WrapBytes(rt, data)
return
}, func(rt *goja.Runtime, result any, err error) {
if lo.IsNil(err) {
if resolveErr := resolve(rt.ToValue(result)); resolveErr != nil {
logging.LogErrorf("[plugin:%s] data.buffer() resolve: %v", p.Name, resolveErr)
}
} else {
if rejectErr := reject(rt.NewGoError(err)); rejectErr != nil {
logging.LogErrorf("[plugin:%s] data.buffer() reject: %v", p.Name, rejectErr)
}
}
})
if runErr != nil {
logging.LogErrorf("[plugin:%s] buffer worker run: %v", p.Name, runErr)
}
return rt.ToValue(promise)
})))
lo.Must0(object.Set("arrayBuffer", rt.ToValue(func(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
promise, resolve, reject := rt.NewPromise()
runErr := p.worker.Run(func(rt *goja.Runtime) (result any, err error) {
result = rt.NewArrayBuffer(data)
return
}, func(rt *goja.Runtime, result any, err error) {
if lo.IsNil(err) {
if resolveErr := resolve(rt.ToValue(result)); resolveErr != nil {
logging.LogErrorf("[plugin:%s] data.arrayBuffer() resolve: %v", p.Name, resolveErr)
}
} else {
if rejectErr := reject(rt.NewGoError(err)); rejectErr != nil {
logging.LogErrorf("[plugin:%s] data.arrayBuffer() reject: %v", p.Name, rejectErr)
}
}
})
if runErr != nil {
logging.LogErrorf("[plugin:%s] arrayBuffer worker run: %v", p.Name, runErr)
}
return rt.ToValue(promise)
})))
return
}
// NewDataObject creates a new JS object with text(), json(), buffer() and arrayBuffer() methods for the given data.
func NewDataObject(p *KernelPlugin, rt *goja.Runtime, data []byte) (*goja.Object, error) {
obj := rt.NewObject()
if err := ObjectSetDataMethods(p, rt, obj, data); err != nil {
return nil, err
}
return obj, nil
}
// getJsContextValue safely retrieves a nested value from the plugin's JS context, returning nil if any step fails.
func getJsContextValue(rt *goja.Runtime, paths []any) (value goja.Value, err error) {
var cursor goja.Value = rt.GlobalObject()
var path string = "globalThis"
for _, key := range paths {
if cursor == nil {
err = fmt.Errorf("path %v: value is nil", key)
return
}
if goja.IsUndefined(cursor) || goja.IsNull(cursor) {
err = fmt.Errorf("path %v: value is %s", key, cursor.String())
return
}
obj := cursor.ToObject(rt)
if obj == nil {
err = fmt.Errorf("path %v: expected object, got %T", key, cursor)
return
}
switch k := key.(type) {
case string:
cursor = obj.Get(k)
path = fmt.Sprintf("%s.%s", path, k)
case int:
cursor = obj.Get(strconv.Itoa(k))
path = fmt.Sprintf("%s[%d]", path, k)
default:
err = fmt.Errorf("unsupported path type: %T", key)
return
}
}
value = cursor
return
}
// dispatchEvent calls the globalThis.siyuan.event.on hook with the given event object.
func dispatchEvent(p *KernelPlugin, rt *goja.Runtime, e any) (async bool, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("goja panic during dispatchEvent: %v", r)
}
}()
event, err := getJsContextValue(rt, []any{"siyuan", "event"})
if err != nil {
return
}
if event == nil {
err = fmt.Errorf("globalThis.siyuan.event not found")
return
}
if goja.IsUndefined(event) || goja.IsNull(event) {
err = fmt.Errorf("globalThis.siyuan.event is %s", event.String())
return
}
eventObj := event.ToObject(rt)
if eventObj == nil {
err = fmt.Errorf("globalThis.siyuan.event is not an object")
return
}
handlerValue := eventObj.Get("handler")
handler, ok := goja.AssertFunction(handlerValue)
if !ok {
return
}
eventJs := rt.ToValue(e)
invokeResult, invokeErr := handler(event, eventJs)
if invokeErr != nil {
err = invokeErr
return
}
async = isJsPromise(invokeResult)
return
}
// invokeFunction calls a goja.Callable with the given this and arguments, handling both synchronous return values and Promises.
func invokeFunction(callback func(rt *goja.Runtime, result *CallResult), rt *goja.Runtime, async bool, fn goja.Callable, this goja.Value, args ...goja.Value) {
resultJs, invokeErr := fn(this, args...)
if callback == nil {
return
}
if invokeErr != nil {
callback(rt, &CallResult{Error: invokeErr})
return
}
result := resultJs.Export()
if isGoPromise(result) {
if !async {
panic(fmt.Errorf("synchronous function returned a Promise"))
}
resultObj := resultJs.ToObject(rt)
if resultObj == nil {
callback(rt, &CallResult{Error: fmt.Errorf("expected promise object, got %T", result)})
return
}
thenValue := resultObj.Get("then")
then, ok := goja.AssertFunction(thenValue)
if !ok {
callback(rt, &CallResult{Error: fmt.Errorf("'promise.then property is not a function")})
return
}
then(resultObj, rt.ToValue(func(call goja.FunctionCall, rt *goja.Runtime) {
// ⚠️ call.Arguments always is an empty array.
promise, ok := result.(*goja.Promise)
if ok {
callback(rt, &CallResult{Value: promise.Result()})
} else {
callback(rt, &CallResult{Value: resultJs})
}
}), rt.ToValue(func(call goja.FunctionCall, rt *goja.Runtime) {
callback(rt, &CallResult{Error: fmt.Errorf("promise rejected: %v", call.Argument(0).Export())})
}))
} else {
callback(rt, &CallResult{Value: resultJs})
}
}
// isJsPromise checks if a goja.Value is a JavaScript Promise.
func isJsPromise(jsValue goja.Value) bool {
if jsValue == nil {
return false
}
goValue := jsValue.Export()
return isGoPromise(goValue)
}
// isGoPromise checks if a Go value is a *goja.Promise.
func isGoPromise(goValue any) bool {
if goValue == nil {
return false
}
_, ok := goValue.(*goja.Promise)
return ok
}
// isJsArray checks if a goja.Value is a JavaScript Array.
func isJsArray(rt *goja.Runtime, jsValue goja.Value) bool {
if jsValue == nil {
return false
}
if goja.IsUndefined(jsValue) || goja.IsNull(jsValue) {
return false
}
jsObject := jsValue.ToObject(rt)
return isJsObjectArray(jsObject)
}
// isJsObjectArray checks if a goja.Object is a JavaScript Array by inspecting its class name.
func isJsObjectArray(jsObject *goja.Object) bool {
if jsObject == nil {
return false
}
switch jsObject.ClassName() {
case "Array":
return true
default:
return false
}
}
// isJsValueNotUndefined checks if a goja.Value is not nil and not undefined.
func isJsValueNotUndefined(jsValue goja.Value) bool {
return jsValue != nil && !goja.IsUndefined(jsValue)
}
// isJsValueNotNull checks if a goja.Value is not nil, undefined or null.
func isJsValueNotNull(jsValue goja.Value) bool {
return isJsValueNotUndefined(jsValue) && !goja.IsNull(jsValue)
}
// jsValueToBytes attempts to convert a goja.Value to a byte slice, supporting string, Buffer, ArrayBuffer, etc.
func jsValueToBytes(rt *goja.Runtime, value goja.Value) (data []byte, err error) {
if goValue := value.Export(); goValue != nil {
switch d := goValue.(type) {
case string: // string
data = []byte(d)
case []byte: // Buffer
data = d
case goja.ArrayBuffer: // ArrayBuffer
data = d.Bytes()
case buffer.Buffer: // ?
data = buffer.Bytes(rt, value)
default:
err = fmt.Errorf("unsupported data type: %T", goValue)
}
return
}
err = fmt.Errorf("js value cannot be exported to a valid Go value")
return
}
// getRequestHandler retrieves the handler function and its containing object for a given scope and request type from the plugin's JS context.
func getRequestHandler(rt *goja.Runtime, scope AccessScope, requestType RequestType) (handler goja.Callable, handlerObj *goja.Object, err error) {
// Get handler object: siyuan.server[scope][requestType]
handlerObjValue, getObjErr := getJsContextValue(rt, []any{"siyuan", "server", string(scope), string(requestType)})
if getObjErr != nil {
err = getObjErr
return
}
handlerObj = handlerObjValue.ToObject(rt)
if handlerObj == nil {
err = fmt.Errorf("globalThis.siyuan.server[%s][%s] is not an object", scope, requestType)
return
}
// Get handler: siyuan.server[scope][requestType].handler
handlerValue := handlerObj.Get("handler")
if !isJsValueNotNull(handlerValue) {
err = fmt.Errorf("siyuan.server[%s][%s].handler is not set", scope, requestType)
return
}
handler, ok := goja.AssertFunction(handlerValue)
if !ok {
err = fmt.Errorf("siyuan.server[%s][%s].handler is not a function", scope, requestType)
return
}
return
}
// requestGoToJs converts a Go Request to a JavaScript value.
func requestGoToJs(p *KernelPlugin, rt *goja.Runtime, request *Request) (jsRequest goja.Value, err error) {
// convert body raw data to js object
if data, ok := request.Request.Body.Data.(*[]byte); ok && data != nil {
request.Request.Body.Data, err = NewDataObject(p, rt, *data)
if err != nil {
return
}
}
// convert body form files data to js object
if request.Request.Body.Form != nil {
for _, fileList := range request.Request.Body.Form.File {
for _, file := range fileList {
if data, ok := file.Data.(*[]byte); ok && data != nil {
file.Data, err = NewDataObject(p, rt, *data)
if err != nil {
return
}
}
}
}
}
jsRequest = rt.ToValue(request)
return
}