logo

Database

Server-side request forgery (SSRF) In github.com/siyuan-note/siyuan/kernel

Description

SiYuan Agent Tools SSRF via DNS-Rebinding TOCTOU (Bypass of CheckHostSSRF) # 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 # 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", ...) # 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 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

Mitigation

Update Impact

Minimal update. May introduce new vulnerabilities or breaking changes.

Ecosystem
Component
Affected version
Patched versions