Skip to content

Unthrottled brute-force of Publish Service Basic Auth accounts in `PublishServiceTransport.RoundTrip()`, allowing unlimited automated guessing of named publish-viewer passwords on a separate, unauthenticated-by-default port

High
88250 published GHSA-phg7-xcr4-q5wg Aug 3, 2026

Package

gomod github.com/siyuan-note/siyuan/kernel (Go)

Affected versions

3.7.3

Patched versions

v3.7.4

Description

Relationship to GHSA-w3xh-mmmh-r54v and GHSA-m6w6-p7pc-fpg2

Not a duplicate of either. All three share the missing-throttle root cause, but this one lives in an entirely separate code path (kernel/server/proxy/publish.go's RoundTrip, not kernel/model/session.go's CheckAuth), on a separate network listener (Publish Service port, default 6808, vs. the main kernel API port), guarding a separate credential store (Conf.Publish.Auth.Accounts, arbitrarily many named username/password pairs, vs. the single AccessAuthCode or Api.Token). Fixing CheckAuth() does not touch this file at all. Recommend the same remediation pattern across all three: a shared, per-account (not just global) failed-attempt counter and lockout/backoff, crypto/subtle.ConstantTimeCompare for every credential comparison in the codebase, and minimum length/complexity enforcement wherever a secret is set (setAccessAuthCode, setAPIToken, and the Publish Service accounts settings-save path).

Severity: High

Package

gomod github.com/siyuan-note/siyuan/kernel (Go)

Affected versions

<= 3.7.3 (confirmed present in 3.7.3 by source review; architectural, not a recent regression — maintainers should confirm lower bound)

Description

Summary

SiYuan's Publish Service (Conf.Publish) is a distinct feature from the "lock screen" (AccessAuthCode) and API token (Conf.Api.Token) covered by GHSA-w3xh-mmmh-r54v and GHSA-m6w6-p7pc-fpg2. It runs its own reverse-proxy HTTP(S) listener on a separate TCP port (default 6808), entirely independent of the main kernel API port and its CheckAuth() middleware, specifically so an owner can publish notes/notebooks to the internet and optionally gate them behind named "publish viewer" accounts (Conf.Publish.Auth.Accounts, each with its own username/password, intended for sharing with specific named people such as family or colleagues). The Basic Auth credential check that gates this entire service — PublishServiceTransport.RoundTrip() in kernel/server/proxy/publish.go has no connection whatsoever to WrongAuthCount/NeedCaptcha(), any per-account lockout, or any rate limiter. This is a third, structurally separate instance of the same missing-throttle root cause already reported twice for the main kernel port, but in a subsystem whose entire purpose is unauthenticated internet exposure, and whose passwords are realistically the weakest of the three (chosen by non-technical operators for named collaborators, with no length/complexity enforcement).

Details

The Publish Service is started independently of the main kernel gin server:

// kernel/server/proxy/publish.go
func initPublishListener() (err error) {
    listener, err = net.Listen("tcp", fmt.Sprintf("%s:%d", Host, model.Conf.Publish.Port))
    ...
}

It serves traffic through a raw httputil.ReverseProxy with a custom RoundTripper — this code path never touches kernel/model/session.go's CheckAuth(), WrongAuthCount, or NeedCaptcha() at all; it is a completely independent authentication implementation:

func (PublishServiceTransport) RoundTrip(request *http.Request) (response *http.Response, err error) {
    if model.Conf.Publish.Auth.Enable {
        // Session Auth
        sessionIdCookie, cookieErr := request.Cookie(model.SessionIdCookieName)
        if cookieErr == nil {
            sessionID := sessionIdCookie.Value
            if username := model.GetBasicAuthUsernameBySessionID(sessionID); username != "" {
                if account := model.GetBasicAuthAccount(username); account != nil {
                    request.Header.Set(model.XAuthTokenKey, account.Token)
                    response, err = publishRoundTripper.RoundTrip(request)
                    return
                }
                model.DeleteSession(sessionID)
            }
        }

        // Basic Auth
        username, password, ok := request.BasicAuth()
        account := model.GetBasicAuthAccount(username)
        if !ok ||
            account == nil ||
            account.Username == "" || // 匿名用户
            account.Password != password {

            return &http.Response{
                StatusCode: http.StatusUnauthorized,
                ...
            }, nil
        }

        // set session cookie
        sessionID := model.GetNewSessionID()
        cookie := &http.Cookie{Name: model.SessionIdCookieName, Value: sessionID, Path: "/", HttpOnly: true}
        model.AddSession(sessionID, username)

        request.Header.Set(model.XAuthTokenKey, account.Token)
        response, err = publishRoundTripper.RoundTrip(request)
        response.Header.Add("Set-Cookie", cookie.String())
        return
    }

    request.Header.Set(model.XAuthTokenKey, model.GetBasicAuthAccount("").Token)
    response, err = publishRoundTripper.RoundTrip(request)
    return
}

A search of kernel/server/proxy/publish.go and its backing kernel/model/auth.go for any of WrongAuthCount, NeedCaptcha, Captcha, RateLimit, or Throttle returns zero matches. There is no attempt counter, no lockout, no delay of any kind — every failed guess returns instantly with a plain 401, and the next request can be tried immediately, with no upper bound on attempts per second beyond raw network throughput.

The credential comparison account.Password != password is also a plain Go string comparison rather than crypto/subtle.ConstantTimeCompare — the same secondary timing-side-channel pattern (CWE-208) present in the two previously reported issues.

Why this is realistically exploitable

  • Conf.Publish.Auth.Accounts (kernel/conf/publish.go, BasicAuthAccount{Username, Password, Memo}) stores plain operator-chosen strings with no server-side minimum length or complexity check anywhere in the settings-save path for this struct (consistent with the same gap already flagged for setAccessAuthCode/setAPIToken).
  • The intended audience for this feature is explicitly non-technical: sharing a personal knowledge base with named family members/collaborators. This is a materially weaker-password population than the "workspace admin sets a lock-screen PIN" or "developer wires up an API token" populations covered by the other two reports.
  • The Publish Service's entire reason for existing is to be reachable from the open internet (unlike the main kernel API, which is more often bound to localhost/LAN). An attacker doesn't need any special network position — this is the service's designed public entry point.
  • A successful guess doesn't just leak the published content — it obtains a persistent session cookie (publish-visitor-session-id) plus the account's signed JWT (X-Auth-Token, role RoleReader), which is then honored by the downstream kernel per kernel/mcp/server.go's own comment acknowledging this JWT can reach kernel tool-call routes if not carefully scoped (否则 Publish 匿名模式注入的 RoleReader JWT 可经此链路越权调用全部工具 — "otherwise the RoleReader JWT injected by Publish anonymous mode could improperly call every tool through this path").

PoC

Same sandbox constraint as the previous two reports (no Go toolchain/proxy.golang.org access), so the control-flow logic was extracted verbatim into a minimal harness and exercised with live HTTP traffic:

=== 300 wrong password guesses against account 'family', tight loop ===
300 guesses sent, hits: 0, no lockout/captcha encountered at any point

=== Correct password ===
status: 200

No 429/lockout/captcha response was ever observed, at any point in the 300-attempt run, exactly mirroring the real code's absence of any throttle reference.

To reproduce against a real build (Go ≥1.26), targeting a workspace with Publish Service enabled and Basic Auth accounts configured:

# Default publish port is 6808
for pw in sunflower123 password1 family2024 letmein ...; do
  curl -s -o /dev/null -w "%{http_code} $pw\n" \
    -u "family:$pw" "https://<target>:6808/"
done
# Expected if vulnerable: unlimited 401s with no lockout/captcha at any
# volume, then 200 (plus a Set-Cookie) on the correct password.

Impact

Unauthenticated remote brute-force of any Publish Service account's password, on a service whose default deployment model is direct internet exposure. A successful guess grants:

  • Full read access to whatever notebooks/documents were published to that account (potentially far more sensitive than a link the owner intended for one or two trusted people), and
  • A RoleReader-scoped session (cookie + JWT) forwarded to the underlying kernel, which per the maintainers' own code comment in kernel/mcp/server.go has previously required care to avoid being escalated into broader tool-call access through the MCP bridge — meaning this isn't necessarily contained to read-only document viewing depending on what else is reachable under RoleReader at the time of testing.

Affected products

Field Value
Ecosystem Go
Package name github.com/siyuan-note/siyuan/kernel
Affected versions <= 3.7.3
Patched versions (none yet — leave blank until a fix is released)

Severity

Field Value
Vector string CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N
Score High — read-access impact on published content and the associated reader session/JWT; not scored Critical since it doesn't directly hand over full kernel administrator access the way the AccessAuthCode/API-token issues do, but the population of realistically weak passwords here is larger than either of those.

Credits

  • alhamrizvi-cloud — Reporter

Severity

High

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
High
Integrity
None
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

CVE ID

No known CVE

Weaknesses

Observable Timing Discrepancy

Two separate operations in a product require different amounts of time to complete, in a way that is observable to an actor and reveals security-relevant information about the state of the product, such as whether a particular operation was successful or not. Learn more on MITRE.

Improper Restriction of Excessive Authentication Attempts

The product does not implement sufficient measures to prevent multiple failed authentication attempts within a short time frame. Learn more on MITRE.

Weak Password Requirements

The product does not require that users should have strong passwords, which makes it easier for attackers to compromise user accounts. Learn more on MITRE.

Credits