GitHub Security Advisory: GHSA-j7wh-x834-p3r7
Package: github.com/siyuan-note/siyuan/kernel (GO)
Severity: CRITICAL (CVSS 9.8)
CWE: CWE-89: Improper Neutralization of Special Elements used in an SQL Command ('SQL Injection'), CWE-863: Incorrect Authorization
Vulnerable versions: <= 0.0.0[PII_REDACTED]4916-fd6526133bb3
Patched in: unpatched

Summary: SiYuan: Authorization Bypass Allows Arbitrary SQL Execution via Search API

Details:
## Summary

SiYuan Note v3.6.0 (and likely prior versions) contains an authorization bypass vulnerability in the `/api/search/fullTextSearchBlock` endpoint. When the `method` parameter is set to `2`, the endpoint passes user-supplied input directly as a raw SQL statement to the underlying SQLite database without any authorization or read-only checks. This allows any authenticated user — including those with the `Reader` role — to execute arbitrary SQL statements (SELECT, DELETE, UPDATE, DROP TABLE, etc.) against the application's database.

This is inconsistent with the application's own security model: the dedicated SQL endpoint (`/api/query/sql`) correctly requires both `CheckAdminRole` and `CheckReadonly` middleware, but the search endpoint bypasses these controls entirely.

## Root Cause Analysis

### The Vulnerable Endpoint

**File:** `kernel/api/router.go`, line 188

```go
ginServer.Handle("POST", "/api/search/fullTextSearchBlock", model.CheckAuth, fullTextSearchBlock)
```

This endpoint only applies `model.CheckAuth`, which permits **any** authenticated role (Administrator, Editor, or Reader).

### The Properly Protected Endpoint (for comparison)

**File:** `kernel/api/router.go`, line 177

```go
ginServer.Handle("POST", "/api/query/sql", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, SQL)
```

This endpoint correctly chains `CheckAdminRole` and `CheckReadonly`, restricting SQL execution to administrators in read-write mode.

### The Vulnerable Code Path

**File:** `kernel/api/search.go`, lines 389-411

```go
func fullTextSearchBlock(c *gin.Context) {
    // ...
    page, pageSize, query, paths, boxes, types, method, orderBy, groupBy := parseSearchBlockArgs(arg)
    blocks, matchedBlockCount, matchedRootCount, pageCount, docMode :=
        model.FullTextSearchBlock(query, boxes, paths, types, method, orderBy, groupBy, page, pageSize)
    // ...
}
```

**File:** `kernel/model/search.go`, lines 1205-1206

```go
case 2: // SQL
    blocks, matchedBlockCount, matchedRootCount = searchBySQL(query, beforeLen, page, pageSize)
```

When `method=2`, the raw `query` string is passed directly to `searchBySQL()`.

**File:** `kernel/model/search.go`, lines 1460-1462

```go
func searchBySQL(stmt string, beforeLen, page, pageSize int) (ret []*Block, ...) {
    stmt = strings.TrimSpace(stmt)
    blocks := sql.SelectBlocksRawStmt(stmt, page, pageSize)
```

**File:** `kernel/sql/block_query.go`, lines 566-569, 713-714

```go
func SelectBlocksRawStmt(stmt string, page, limit int) (ret []*Block) {
    parsedStmt, err := sqlparser.Parse(stmt)
    if err != nil {
        return selectBlocksRawStmt(stmt, limit)  // Falls through to raw execution
    }
    // ...
}

func selectBlocksRawStmt(stmt string, limit int) (ret []*Block) {
    rows, err := query(stmt)  // Executes arbitrary SQL
    // ...
}
```

**File:** `kernel/sql/database.go`, lines 1327-1337

```go
func query(query string, args ...interface{}) (*sql.Rows, error) {
    // ...
    return db