# 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** |...
Full CISO analysis pending enrichment.
What systems are affected?
| Package | Ecosystem | Vulnerable Range | Patched |
|---|---|---|---|
| OpenAI Node | go | < 0.0.0-20260813142806-dd2778b70d02 | 0.0.0-20260813142806-dd2778b70d02 |
Do you use OpenAI Node? You're affected.
How severe is it?
What is the attack surface?
What should I do?
Patch available
Update OpenAI Node to version 0.0.0-20260813142806-dd2778b70d02
Which compliance frameworks are affected?
Compliance analysis pending. Sign in for full compliance mapping when available.
Frequently Asked Questions
What is GHSA-x8gv-g2g3-65fj?
# 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 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) ... } ```
Is GHSA-x8gv-g2g3-65fj actively exploited?
No confirmed active exploitation of GHSA-x8gv-g2g3-65fj has been reported, but organizations should still patch proactively.
How to fix GHSA-x8gv-g2g3-65fj?
Update to patched version: OpenAI Node 0.0.0-20260813142806-dd2778b70d02.
What is the CVSS score for GHSA-x8gv-g2g3-65fj?
GHSA-x8gv-g2g3-65fj has a CVSS v3.1 base score of 8.2 (HIGH).
What are the technical details?
Original Advisory
# 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 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) ... } ```
Weaknesses (CWE)
CWE-918 — Server-Side Request Forgery (SSRF): The web server receives a URL or similar request from an upstream component and retrieves the contents of this URL, but it does not sufficiently ensure that the request is being sent to the expected destination.
Source: MITRE CWE corpus.
CVSS Vector
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:L/A:N References
Timeline
Related Vulnerabilities
CVE-2026-61539 10.0 Xinference: eval() on LLM output enables RCE
Same package: openai GHSA-vjc7-jrh9-9j86 10.0 9Router: no-auth API leaks keys, chats, provider control
Same package: openai CVE-2025-61260 9.8 OpenAI Codex CLI: RCE via malicious MCP config files
Same package: openai CVE-2024-23827 9.8 Nginx-UI: arbitrary file write via cert import leads to RCE
Same package: openai CVE-2026-19593 9.8 Codex Desktop: Git config filter triggers sandbox-escape RCE
Same package: openai