siyuan-note/siyuan · error

siyuan.storage: path traversal not allowed

Error message

siyuan.storage: path traversal not allowed

What it means

Thrown by the internal resolvePath helper used by every siyuan.storage.* method (get, put, remove, watcher.add, watcher.remove). It joins the plugin-supplied relative path under p.storageDir and then verifies the cleaned result still lives inside that directory; any path that escapes the sandbox is rejected. This is a security boundary preventing plugins from reading or writing arbitrary kernel/user files.

Source

Thrown at kernel/plugin/api_storage.go:43

	"github.com/dop251/goja"
	"github.com/samber/lo"
	"github.com/siyuan-note/filelock"
	"github.com/siyuan-note/logging"
	"github.com/siyuan-note/siyuan/kernel/util"
)

// injectStorage adds siyuan.storage.* methods for scoped file CRUD.
func injectStorage(p *KernelPlugin, rt *goja.Runtime, siyuan *goja.Object) (err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("injectStorage: %v", r)
		}
	}()

	resolvePath := func(relPath string) (abs string, err error) {
		abs = filepath.Join(p.storageDir, filepath.Clean(relPath))
		if !(abs == p.storageDir || strings.HasPrefix(abs, p.storageDir+string(filepath.Separator))) {
			err = fmt.Errorf("siyuan.storage: path traversal not allowed")
		}
		return
	}

	watcher := rt.NewObject()

	// siyuan.storage.watcher.add(path) -> Promise<void>
	lo.Must0(watcher.Set("add", rt.ToValue(func(call goja.FunctionCall, rt *goja.Runtime) goja.Value {
		promise, resolve, reject := rt.NewPromise()

		var argErr error
		var path string
		if len(call.Arguments) >= 1 && goja.IsString(call.Argument(0)) {
			path = call.Argument(0).String()
		} else {
			argErr = fmt.Errorf("path required")
		}

View on GitHub (pinned to 251596fc0d)

Solutions

  1. Use only simple relative paths (e.g. 'notes/x.json') and never prepend '/' or '..'.
  2. Sanitize user-supplied path segments: reject any segment equal to '..' and strip leading slashes.
  3. Keep all storage under subfolders of the plugin's storage dir and treat that dir as the virtual root.

Example fix

// before
await siyuan.storage.get('../config/settings.json');
// after
await siyuan.storage.get('settings.json');
Defensive patterns

Strategy: validation

Validate before calling

function safeStoragePath(rel) {
  if (typeof rel !== 'string' || rel.length === 0) throw new TypeError('storage path required');
  const segments = rel.split(/[\\/]+/);
  if (segments.some(s => s === '..')) throw new Error('storage path must not contain parent traversal');
  return rel.replace(/^[\\/]+/, '');
}
// await siyuan.storage.get(safeStoragePath(userInput));

Type guard

const isSafeRelPath = (p) => typeof p === 'string' && p.length > 0 && !p.split(/[\\/]+/).includes('..') && !/^[A-Za-z]:/.test(p);

Try / catch

try { await siyuan.storage.get(rel); }
catch (e) { if (/path traversal not allowed/.test(String(e))) { /* reject user input */ } else throw e; }

Prevention

When it happens

Trigger: Passing a path containing parent traversal segments that escape the root after filepath.Clean: '../secret', '../../etc/passwd', '/absolute/path', 'data/../../conf'. On Windows, drive-prefixed or UNC paths also resolve outside the storage dir.

Common situations: Plugin naively concatenates user input into the path; a plugin stores a relative path that was later moved; use of absolute paths assuming storage is the FS root; symlinks inside storage that point outside (Clean does not resolve symlinks, but Join semantics can still trip).

Related errors


AI-assisted analysis of siyuan-note/siyuan@251596fc0d (2026-08-12). Data as JSON: /api/errors/c18c071071c17f2f. Report an issue: GitHub.