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 |
Not yet patched at time of report (2026-08-13) |
| Severity |
High — CVSS 3.1 Base Score 8.5 (AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N) |
| CWE |
CWE-918 Server-Side Request Forgery (primary); CWE-350 Reliance on Reverse DNS Resolution (secondary) |
| 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.
CWE
- CWE-918 Server-Side Request Forgery (primary): the SSRF guard is bypassable via rebinding.
- CWE-350 Reliance on Reverse/Repeated DNS Resolution (secondary): guard-time and connect-time resolutions are inconsistent (TOCTOU).
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.
CVSS 3.1
- Vector:
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N
- Base Score: 8.5 (High)
- Rationale: network attack, low complexity, no privileges, but requires the agent to be induced to fetch the attacker domain (UI:R). High confidentiality impact, changed scope (kernel network position). If delivered via an auto-synced shared workspace, practical exposure approaches "agent-auto-fetch".
Remediation
- 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.
- Pin resolution: after
CheckHostSSRF passes, reuse the same resolved IP for the connection (or short-TTL cache) so guard and dial cannot diverge.
- 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)
...
}
Security Advisory — SiYuan Agent Tools SSRF via DNS-Rebinding TOCTOU (Bypass of
CheckHostSSRF)joysinleung@gmail.com)siyuan-note/siyuangithub.qkg1.top/siyuan-note/siyuan/kernel<= 3.8.0(latest release at report time; dynamically verified on v3.8.0)AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N)kernel/util/httprequest.go(CheckHostSSRF),kernel/mcp/tools/http_request.go,kernel/util/webfetch.go,kernel/util/net.go(SSRFSafeDialer)SafeModeon and off; only requires the agenthttp_request/web_fetchtool to be reachable (default AI tooling).Summary
SiYuan's AI Agent tools
http_request(util.HTTPRequest) andweb_fetch(util.WebFetch) are the only SSRF gate for outbound requests from the kernel. That gate isCheckHostSSRF, 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 defaultnet.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
CheckHostSSRF(parse-time) andSSRFSafeDialer(connect-time). However, the agent tool paths (http_request/web_fetch) only received the parse-time half: they callCheckHostSSRFbut then connect viahttpclient.NewBrowserRequest(), whose transport does not mountSSRFSafeDialer. Even whereSSRFSafeDialeris mounted, it only blocks private IPs whenSafeMode == true(default false), so it would not help here regardless. The sibling pathopenai.go:generatedImageDialerdoes mount a connect-timeControlhook (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.forwardProxyendpoint and is unrelated to the agent tool path.Affected Version
Dynamically verified on v3.8.0 (tag
v3.8.0, commit251596fc0). A process-level DNS hijack was installed so that the attacker domainrebind.localreturns a public IP (203.0.113.1) on odd (guard) resolutions and127.0.0.1on even (connect) resolutions; a loopback "victim" service returnedF9_REBIND_PROOF=reached-internal-only-service-via-TOCTOU. Bothutil.HTTPRequest("GET", "http://rebind.local:<port>/secret")andutil.WebFetch(...)returned the internal-only proof body, whileCheckHostSSRF("127.0.0.1")directly blocked and the legit public domainpub.localpassed — confirming the guard passed but the connection hit the internal address. All versions<= 3.8.0are affected.Component
kernel/util/httprequest.go:42CheckHostSSRF— singlenet.LookupIP+isPrivateIPat guard time only.kernel/mcp/tools/http_request.go:90→util.HTTPRequest;kernel/util/webfetch.go:50→CheckHostSSRF+httpclient.NewBrowserRequest().github.qkg1.top/siyuan-note/httpclientclient.go:92NewBrowserRequestuses the defaulthttp.Transport→ defaultnet.Dialerwith noSSRFSafeDialer.kernel/util/net.go:151SSRFSafeDialerexists but is (a) not mounted on the agent path and (b) only active underSafeMode.kernel/util/openai.go:829generatedImageDialermounts a connect-timeControlhook.CWE
Attack Vector
Network + AI Agent. The
urlofhttp_request/web_fetchis 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, later169.254.169.254/ internal). No auth, no special position beyond prompting the agent. Real targets: cloud metadata169.254.169.254(IMDSv1 IAM creds) and same-host/internal unauthenticated services.Proof of Concept
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_fetchtool is usable (default AI tooling). Independent of Publish/auth configuration. Not gated bySafeMode.CVSS 3.1
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:NRemediation
SSRFSafeDialer(or a dedicated always-on private-IPControlhook) on the transport used byhttp_request/web_fetch, independent ofSafeMode— matching the already-correctgeneratedImageDialer.CheckHostSSRFpasses, reuse the same resolved IP for the connection (or short-TTL cache) so guard and dial cannot diverge.httpclient.NewBrowserRequest()inhttp_request.go/webfetch.gowith a custom client whoseDialContextisutil.SSRFSafeDialer(timeout).DialContext(notSafeMode-gated).Appendix: Suggested Patch (F9)