Not ratedosv:GHSA-xp3q-r38w-vgqm

    SiYuan: The publish filter for local storage returns the administrator's entire storage map with only three keys sanitized

    Published Sources: OSV

    What is affected, what to do

    The available sources name no fixed version. Check the vendor guidance in the references below.

    Not sure whether and how you are affected? Ask a question about this vulnerability

    Metrics

    —

    Priority

    No value is available for this vulnerability.

    —

    CVSS v3

    No value is available for this vulnerability.

    —

    EPSS score

    No value is available for this vulnerability.

    Show all metrics
    Severity
    Not rated
    Priority (0–325)

    The priority score is a consolidated metric that condenses several individual signals into a single, action-guiding ranking: technical severity (CVSS), exploitation likelihood (EPSS), known-exploited status (KEV) and the context of the affected asset. The aim is to answer the only truly important question in vulnerability management: what first?

    Read in glossary
    30.00 Methodology
    Exploit maturity

    An exploit is code or a technique that specifically uses a concrete vulnerability to force unintended behaviour — such as executing code or escalating privileges. It is the tool that turns a theoretical flaw into a practical attack.

    Read in glossary
    no public PoC known
    KEV status

    The KEV catalogue is a list maintained by the US authority CISA of vulnerabilities for which active exploitation in the wild has been confirmed. It answers not the question „how severe?“ but „is it already being attacked?“ — thereby sharply prioritising patching.

    Read in glossary
    —
    Published

    Classification under NIS2 and CRA

    Rule-based classification from exploitation status and severity. It does not replace a case-by-case assessment.

    NIS2

    NIS2 is EU Directive (EU) 2022/2555 to raise the level of cybersecurity across the Union. It substantially broadens obligations compared with the first NIS directive: more sectors, mandatory risk management, staged reporting duties and personal accountability of senior management.

    Read in glossary
    Handle the vulnerability in regular vulnerability handling under Art. 21(2)(e) NIS2.
    CRA

    The CRA is EU Regulation (EU) 2024/2847 setting horizontal cybersecurity requirements for „products with digital elements“ — from industrial controllers and software to connected consumer devices. For the first time manufacturers must ensure security across the entire product lifecycle, handle vulnerabilities and provide security updates.

    Read in glossary
    From 11.12.2027, Annex I Part II CRA requires manufacturers of affected products with digital elements to handle vulnerabilities without delay and provide security updates.
    ISO/IEC 27001

    ISO/IEC 27001 is the internationally leading standard for an information security management system (ISMS). It describes a risk-based, process-oriented framework to systematically plan, implement, review and improve information security.

    Read in glossary
    Control A.8.8, management of technical vulnerabilities: assess relevance to your systems, plan remediation and keep evidence.

    Description

    Relationship to GHSA-hgfg-j9pg-43xw

    That advisory concerned the administrator's live UI layout, and commit 63c7d5474 resolved it by removing FilterUILayoutByPublishIgnore and nulling Conf.UILayout for readers. That fix is correct and complete for the live layout.

    This report concerns a different endpoint, a different filter function and a different storage backing. /api/storage/getLocalStorage reads data/storage/local.json rather than Conf.UILayout, and its filter FilterLocalStorageByPublishAccess was not touched by that commit. The consequence is that the same serialized tab objects the earlier fix withheld continue to be served, from the closed-tab history rather than the live layout.

    Summary

    Three routes, /api/storage/getLocalStorage, getLocalStorageVal and getLocalStorageVals, are registered with CheckAuth only and are reachable by the publish RoleReader token and anonymously when Publish.Auth.Enable is false. All three call model.GetLocalStorage() and then, for read-only roles, FilterLocalStorageByPublishAccess.

    That filter begins by assigning the administrator's entire storage map to its return value and then removes three specific fields. Everything else in the map is returned unmodified. A single unauthenticated POST with an empty body returns the administrator's recently closed tabs with their document titles and identifiers, their current search keywords and the paths those searches cover, the expanded private paths in their file tree, and per-document identifiers from several other keys.

    Details

    Routes. kernel/api/router.go:87-89 on master, the same block on the development branch:

    ginServer.Handle("POST", "/api/storage/getLocalStorage",     model.CheckAuth, getLocalStorage)
    ginServer.Handle("POST", "/api/storage/getLocalStorageVal",  model.CheckAuth, getLocalStorageVal)
    ginServer.Handle("POST", "/api/storage/getLocalStorageVals", model.CheckAuth, getLocalStorageVals)
    

    No CheckAdminRole, no CheckReadonly.

    The filter is an allowlist inverted into a blocklist. kernel/model/publish_access.go:911 on master and :1481 on the development branch, byte-identical on both:

    func FilterLocalStorageByPublishAccess(publishAccess PublishAccess, localStorage map[string]any) (ret map[string]any) {
        ret = localStorage            // the entire administrator map
        publishIgnore := GetInvisiblePublishAccess(publishAccess)
        // clears local-searchkeys["keys"]
        // clears local-searchasset["k"] and ["keys"]
        // blanks local-docinfo["id"], only when bt != nil, visible tier only, no password check
        return
    }
    

    local.json holds roughly thirty keys. Three are sanitized. The rest are returned as stored.

    What survives. Taking the client's own list of keys it round-trips through this endpoint (app/src/protyle/util/compatibility.ts:563), the notable survivors are:

    • local-closed-tabs. Up to SIZE_UNDO entries, each produced by layoutToJSON(item, tabJSON) at app/src/layout/Wnd.ts:779. This is the same serializer that produced the data in the earlier advisory: tab title, docIcon, rootId, notebookId, blockId, mode, action, and for a Search tab the entire config object. Unfiltered.
    • local-searchdata. The live document-search configuration, including k (the search text), r (replace text), hPath, idPath (the paths the search was scoped to) and types. Note the filter clears the search history array and the asset-search keyword, but leaves the document-search keyword itself. The project's own standard is applied to two of the three related fields.
    • local-filespaths. IFilesPath[] of {notebookId, openPaths[]}, the expanded private notebook and folder paths in the file-tree dock.
    • local-fileposition, local-outline, local-zoom. Keyed by document or block identifier, so each discloses private document identifiers.
    • local-move-path. {keys[], k}, the move-document dialog's path history and last keyword.
    • local-recent-docs, local-history, local-flashcard, local-dailynoteid, local-layouts.

    The asymmetry is visible in the function signature. In the same file, kernel/api/storage.go:

    HandlerFilterReceives *gin.Context
    getCriteriaFilterCriteriaByPublishAccess(c, ...)yes
    getRecentDocsFilterRecentDocsByPublishAccess(c, ...)yes
    getLocalStorage / Val / ValsFilterLocalStorageByPublishAccess(publishAccess, data)no

    The two sibling filters take the request context and can therefore call CheckPublishAuthCookie to evaluate the password tier. FilterLocalStorageByPublishAccess does not receive the context, so it cannot perform that check at all. The visible-only limitation is fixed in the parameter list rather than in the body.

    Separately, local-recent-docs passes through this endpoint unfiltered, which bypasses the dedicated FilterRecentDocsByPublishAccess that guards the same data on getRecentDocs.

    Note on a prior assessment. getLocalStorage appears on lists of endpoints considered correctly filtered, on the basis that it calls a Filter*ByPublishAccess function. That observation is accurate and this report does not contradict it. The filter is called. What it does is assign the whole map and remove three fields.

    No persistence side effect. The in-place mutations the filter performs do not corrupt the administrator's stored state, because GetLocalStorage re-reads and unmarshals storage/local.json on each call (kernel/model/storage.go:82), so the map is per-request. This is noted to pre-empt the concern rather than as part of the finding.

    Proof of Concept

    Precondition: publish mode enabled (default port 6808), anonymous when Publish.Auth.Enable is false, otherwise any publish reader account. An administrator who has used the client normally, closing some document tabs and running a search.

    POST http://127.0.0.1:6808/api/storage/getLocalStorage
    {}
    
    → 200. The returned map includes, among others:
         local-closed-tabs   entries carrying title, docIcon, rootId, notebookId,
                             blockId for recently closed documents, including
                             documents the reader has no publish access to
         local-searchdata    k, r, hPath, idPath for the administrator's current
                             document search
         local-filespaths    notebookId and openPaths for expanded private folders
         local-fileposition  keys are private document identifiers
    

    getLocalStorageVal returns any single key by name, so a caller who only wants the closed-tab history can request it directly.

    For contrast, requesting the same underlying data through the sibling endpoint shows the filtering that is applied elsewhere:

    POST http://127.0.0.1:6808/api/storage/getRecentDocs
    {}
    
    → 200, filtered by FilterRecentDocsByPublishAccess
    

    while local-recent-docs inside the storage response is not.

    Impact

    An anonymous reader in publish mode, or any publish RoleReader, obtains the administrator's working context: the titles and identifiers of documents they recently had open, what they have been searching for and where, which private folders they have expanded, and per-document identifiers across several other keys. Titles and search keywords are author-written text and routinely describe the subject of the documents they refer to.

    The document and notebook identifiers disclosed here are also the input required by identifier-to-path and path-to-content resolution, so the value is not limited to the metadata itself.

    No document bodies are returned, and nothing is written. Confidentiality only.

    Suggested fix

    Invert the filter into an actual allowlist. Return only the keys a publish reader needs to render published content, which is a short list along the lines of local-images, local-emojis, local-pdftheme, local-fontstyles and the theme and zoom preferences, and drop everything else by default. New keys added in future then default to withheld rather than exposed.

    Additionally, pass *gin.Context into FilterLocalStorageByPublishAccess so that any retained per-document entry can be gated with the full CheckBlockIdAccessableByPublishAccess including the password tier, matching the sibling filters in the same file.

    Source: OSV (Open Source Vulnerabilities)