CheckHostSSRF)| Field | Value |
|---|---|
| Disclosed by | joysinleung ([email protected]) |
| 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). |
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.
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.forwardProxy endpoint and is unrelated to the agent tool path.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.
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.kernel/util/openai.go:829 generatedImageDialer mounts a connect-time Control hook.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.
# 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.
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).
Reachable whenever the agent http_request / web_fetch tool is usable (default AI tooling). Independent of Publish/auth configuration. Not gated by SafeMode.
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.CheckHostSSRF passes, reuse the same resolved IP for the connection (or short-TTL cache) so guard and dial cannot diverge.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.
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)
...
}
| Software | Affected versions |
|---|---|
github.com/siyuan-note/siyuan/kernel
|
< 0.0.0-20260813142806-dd2778b70d02 |
A vulnerability database tells you what is broken. Attack surface management tells you where it is running: it discovers your internet-facing assets and flags the ones affected by issues like this one.
What is attack surface management? →A security vulnerability is a weakness in software, hardware, or configuration that can be exploited to compromise confidentiality, integrity, or availability. Many vulnerabilities are tracked as CVEs (Common Vulnerabilities and Exposures), which provide a standardized identifier so teams can coordinate patching, mitigation, and risk assessment across tools and vendors.
CVSS (Common Vulnerability Scoring System) estimates technical severity, but it doesn't automatically equal business risk. Prioritize using context like internet exposure, affected asset criticality, known exploitation (proof-of-concept or in-the-wild), and whether compensating controls exist. A "Medium" CVSS on an exposed, production system can be more urgent than a "Critical" on an isolated, non-production host.
A vulnerability is the underlying weakness. An exploit is the method or code used to take advantage of it. A zero-day is a vulnerability that is unknown to the vendor or has no publicly available fix when attackers begin using it. In practice, risk increases sharply when exploitation becomes reliable or widespread.
Recurring findings usually come from incomplete Asset Discovery, inconsistent patch management, inherited images, and configuration drift. In modern environments, you also need to watch the software supply chain: dependencies, containers, build pipelines, and third-party services can reintroduce the same weakness even after you patch a single host. Unknown or unmanaged assets (often called Shadow IT) are a common reason the same issues resurface.
Use a simple, repeatable triage model: focus first on externally exposed assets, high-value systems (identity, VPN, email, production), vulnerabilities with known exploits, and issues that enable remote code execution or privilege escalation. Then enforce patch SLAs and track progress using consistent metrics so remediation is steady, not reactive.
SynScan combines attack surface monitoring and continuous security auditing to keep your inventory current, flag high-impact vulnerabilities early, and help you turn raw findings into a practical remediation plan.