An open API service providing security vulnerability metadata for many open source software ecosystems.

GSA_kwCzR0hTQS14OGd2LWcyZzMtNjVmas4AB7fe

High CVSS: 8.2

SiYuan Agent Tools SSRF via DNS-Rebinding TOCTOU (Bypass of CheckHostSSRF)

Affected Packages Affected Versions Fixed Versions
go:github.com/siyuan-note/siyuan/kernel
PURL: pkg:go/github.com%2Fsiyuan-note%2Fsiyuan%2Fkernel
< 0.0.0-20260813142806-dd2778b70d02 0.0.0-20260813142806-dd2778b70d02
0 Dependent packages
0 Dependent repositories

Affected Version Ranges

All affected versions

v0.0.0-20220905142016-d4334c773dad, v0.0.0-20221027152605-fe60b22d536d, v0.0.0-20230103113146-145243e0583b, v0.0.0-20230111025530-cdb6077c3f00, v0.0.0-20230117023040-d0f011b1a5b1, v0.0.0-20230321012606-1a6fddc44111, v0.0.0-20230321035213-f83a07fb0626, v0.0.0-20230404073044-cbddfb196259, v0.0.0-20230411020541-41873799c846, v0.0.0-20230411032044-a1e389df19df, v0.0.0-20230418060053-0929e98dee27, v0.0.0-20230425032235-9e9b43392e30, v0.0.0-20230509095923-c7b43df2d829, v0.0.0-20230704012107-073e73838942, v0.0.0-20230725120217-1c2422cf6d73, v0.0.0-20230801023826-ae576633c12e, v0.0.0-20230808040815-95c095573538, v0.0.0-20230815124756-a516f8da2cf1, v0.0.0-20230821131106-e08133ea88ff, v0.0.0-20230829032438-2349b080db59, v0.0.0-20230905014358-830c8b55cf1f, v0.0.0-20230908022656-147d08377047, v0.0.0-20230912012204-38bb73810b5a, v0.0.0-20230919025405-cd94ce9fb132, v0.0.0-20231003053625-642d04151389, v0.0.0-20231004050336-811bac942ddb, v0.0.0-20231011065714-eb93255cf327, v0.0.0-20231115012049-99b3c7e1920a, v0.0.0-20231205010704-20881abfe2f8, v0.0.0-20231214085135-4d5f5380088e, v0.0.0-20231214121959-554b1f77694c, v0.0.0-20231219004102-fd0e44fbf0ef, v0.0.0-20231226025913-171b91513423, v0.0.0-20240102022946-cb6a843cd957, v0.0.0-20240109001922-343c7679e74b, v0.0.0-20240110090555-2b6dc096a8e7, v0.0.0-20240116030803-f6651fbc0ffd, v0.0.0-20260210054043-1ad1f14c7af8, v0.0.0-20260211160845-652a9eab7397, v0.0.0-20260213070947-7d82e296ba6c, v0.0.0-20260214003352-56e3ae82bbf3, v0.0.0-20260224025438-733725fd568f, v0.0.0-20260304034809-d68bd5a79391, v0.0.0-20260304035530-d03ebdec8279, v0.0.0-20260310025236-297bd526708f, v0.0.0-20260313024916-fd6526133bb3, v0.0.0-20260313090242-dd42efdc23df, v0.0.0-20260314111550-b382f50e1880, v0.0.0-20260317012524-fe4523fff2c8, v0.0.0-20260329142331-918d1bd9f967, v0.0.0-20260330031106-f09953afc57a, v0.0.0-20260331020529-29b104f338f9, v0.0.0-20260402075333-571d070de666, v0.0.0-20260407035653-2f416e5253f1, v0.0.0-20260414013942-62eed37a3263, v0.0.0-20260421031503-96dfe0bea474, v0.0.0-20260512140701-d7b77d945e0d, v0.0.0-20260611143138-57ddb73f88f7

All unaffected versions

Security Advisory — SiYuan Agent Tools SSRF via DNS-Rebinding TOCTOU (Bypass of CheckHostSSRF)

Field Value
Disclosed by joysinleung (joysinleung@gmail.com)
Report date 2026-08-13
Product SiYuan (思源笔记) — siyuan-note/siyuan
Go module github.com/siyuan-note/siyuan/kernel
Affected versions <= 3.8.0 (latest release at report time; dynamically verified on v3.8.0)
Patched versions 3.8.1
Component kernel/util/httprequest.go (CheckHostSSRF), kernel/mcp/tools/http_request.go, kernel/util/webfetch.go, kernel/util/net.go (SSRFSafeDialer)
Relationship to prior advisory Incomplete-fix variant of GHSA-rg26-cg95-gq6p (SSRF main-vector remediation). See §Relationship.
EPSS (exploitation probability) Low–Moderate. Requires the attacker to influence an AI Agent / MCP client into fetching an attacker-controlled domain (prompt-injection scenario documented by the tool itself).
KEV (CISA Known Exploited) No (not listed in CISA KEV at report time).
Default-config reachable Yes — exploitable under both SafeMode on and off; only requires the agent http_request / web_fetch tool to be reachable (default AI tooling).

Summary

SiYuan's AI Agent tools http_request (util.HTTPRequest) and web_fetch (util.WebFetch) are the only SSRF gate for outbound requests from the kernel. That gate is CheckHostSSRF, which performs a single DNS resolution at guard time and checks whether any returned IP is private/loopback/link-local. The actual connection, however, performs a second, independent DNS resolution through the default net.Dialer — and no connect-time private-IP check is mounted on this path.

Because the two resolutions are not pinned to the same result, an attacker-controlled domain can answer the guard-resolution with a public IP (passing CheckHostSSRF) and the connect-resolution with a private/loopback/metadata IP (e.g. 169.254.169.254). This is a classic DNS-rebinding TOCTOU that bypasses the SSRF defense entirely. It reaches cloud instance metadata and internal services that the guard was specifically added to block.

Relationship to Prior Advisories

  • GHSA-rg26-cg95-gq6p remediated the SSRF main vector by adding CheckHostSSRF (parse-time) and SSRFSafeDialer (connect-time). However, the agent tool paths (http_request / web_fetch) only received the parse-time half: they call CheckHostSSRF but then connect via httpclient.NewBrowserRequest(), whose transport does not mount SSRFSafeDialer. Even where SSRFSafeDialer is mounted, it only blocks private IPs when SafeMode == true (default false), so it would not help here regardless. The sibling path openai.go:generatedImageDialer does mount a connect-time Control hook (blocking private/loopback/link-local/100.64/198.18), proving the project knows the technique — the agent path is a clear omission. We report this as an incomplete-fix variant with a concrete v3.8.0 reproduction.
  • CVE-2026-32110 (GHSA-56cv-c5p2-j2wg) covered the older forwardProxy endpoint and is unrelated to the agent tool path.

Affected Version

Dynamically verified on v3.8.0 (tag v3.8.0, commit 251596fc0). A process-level DNS hijack was installed so that the attacker domain rebind.local returns a public IP (203.0.113.1) on odd (guard) resolutions and 127.0.0.1 on even (connect) resolutions; a loopback "victim" service returned F9_REBIND_PROOF=reached-internal-only-service-via-TOCTOU. Both util.HTTPRequest("GET", "http://rebind.local:<port>/secret") and util.WebFetch(...) returned the internal-only proof body, while CheckHostSSRF("127.0.0.1") directly blocked and the legit public domain pub.local passed — confirming the guard passed but the connection hit the internal address. All versions <= 3.8.0 are affected.

Component

  • kernel/util/httprequest.go:42 CheckHostSSRF — single net.LookupIP + isPrivateIP at guard time only.
  • kernel/mcp/tools/http_request.go:90 → util.HTTPRequest; kernel/util/webfetch.go:50 → CheckHostSSRF + httpclient.NewBrowserRequest().
  • github.com/siyuan-note/httpclient client.go:92 NewBrowserRequest uses the default http.Transport → default net.Dialer with no SSRFSafeDialer.
  • kernel/util/net.go:151 SSRFSafeDialer exists but is (a) not mounted on the agent path and (b) only active under SafeMode.
  • Correctly-defended sibling: kernel/util/openai.go:829 generatedImageDialer mounts a connect-time Control hook.

Attack Vector

Network + AI Agent. The url of http_request / web_fetch is fully controlled by the agent / MCP client. In SiYuan's documented red-team scenario ("prompt-inject the agent → induce it to visit an attacker domain"), the attacker needs only a rebinding domain (own authoritative DNS, first answer public, later 169.254.169.254 / internal). No auth, no special position beyond prompting the agent. Real targets: cloud metadata 169.254.169.254 (IMDSv1 IAM creds) and same-host/internal unauthenticated services.

Proof of Concept

# Attacker authoritative DNS for rebind.local:
#   odd  query (guard)  -> 203.0.113.1   (public, passes CheckHostSSRF)
#   even query (dial)   -> 127.0.0.1     (loopback internal victim)
#
# Directly invoke the real agent-tool functions (v3.8.0 code path):
util.HTTPRequest("GET", "http://rebind.local:15353/secret", ...)
util.WebFetch("http://rebind.local:15353/secret", ...)

# Result (evidence):
#   body = "F9_REBIND_PROOF=reached-internal-only-service-via-TOCTOU"
# Negative control: CheckHostSSRF("127.0.0.1") -> blocked.
# Positive control: CheckHostSSRF("pub.local") -> 203.0.113.1, allowed.

The loopback victim is a faithful stand-in for 169.254.169.254 / any internal address: the guard allowed a public IP while the connection reached a private one.

Impact

Confidentiality breach via SSRF: an attacker who can steer the agent can read cloud instance metadata (IAM temporary credentials), internal service responses, and anything reachable from the SiYuan kernel's network position. Scope is changed (S:C) because the kernel often runs with cloud-instance privileges. Exploitable under default config (SafeMode on or off).

Scope

Reachable whenever the agent http_request / web_fetch tool is usable (default AI tooling). Independent of Publish/auth configuration. Not gated by SafeMode.

Remediation

  1. Connect-time enforcement (preferred): mount SSRFSafeDialer (or a dedicated always-on private-IP Control hook) on the transport used by http_request / web_fetch, independent of SafeMode — matching the already-correct generatedImageDialer.
  2. Pin resolution: after CheckHostSSRF passes, reuse the same resolved IP for the connection (or short-TTL cache) so guard and dial cannot diverge.
  3. Minimum change: replace httpclient.NewBrowserRequest() in http_request.go / webfetch.go with a custom client whose DialContext is util.SSRFSafeDialer(timeout).DialContext (not SafeMode-gated).

Note: patch authored against v3.8.0 source; regression-tested in the researcher's environment for the PoC path but not compiled into a full SiYuan release build. Provided for the maintainer to validate in CI.


Appendix: Suggested Patch (F9)

diff --git a/kernel/util/httprequest.go b/kernel/util/httprequest.go
index aaa..bbb 100644
--- a/kernel/util/httprequest.go
+++ b/kernel/util/httprequest.go
@@ -40,6 +40,18 @@ func CheckHostSSRF(host string) error {
 	return nil
 }
 
+// SSRFSafeClient returns an *http.Client whose transport enforces the
+// private/loopback/link-local IP block at CONNECT time (independent of SafeMode),
+// closing the DNS-rebinding TOCTOU left by parse-time-only CheckHostSSRF.
+func SSRFSafeClient(timeout time.Duration) *http.Client {
+	return &http.Client{
+		Timeout: timeout,
+		Transport: &http.Transport{
+			DialContext: util.SSRFSafeDialer(timeout).DialContext,
+		},
+	}
+}
+
 diff --git a/kernel/mcp/tools/http_request.go b/kernel/mcp/tools/http_request.go
 index ccc..ddd 100644
--- a/kernel/mcp/tools/http_request.go
+++ b/kernel/mcp/tools/http_request.go
@@ -90,7 +90,7 @@ func httpRequest(args map[string]any) (CallToolResult, error) {
 	if serr := util.CheckHostSSRF(u.Hostname()); serr != nil {
 		return CallToolResult{}, serr
 	}
-	resp, err := httpclient.NewBrowserRequest().Get(rawURL)
+	resp, err := util.SSRFSafeClient(30 * time.Second).Get(rawURL)
 	...
 }
 diff --git a/kernel/util/webfetch.go b/kernel/util/webfetch.go
 index eee..fff 100644
--- a/kernel/util/webfetch.go
+++ b/kernel/util/webfetch.go
@@ -50,7 +50,7 @@ func WebFetch(rawURL string, ...) (string, error) {
 	if serr := util.CheckHostSSRF(u.Hostname()); serr != nil {
 		return "", serr
 	}
-	resp, err := httpclient.NewBrowserRequest().Get(rawURL)
+	resp, err := util.SSRFSafeClient(30 * time.Second).Get(rawURL)
 	...
 }
References: