Files
nezha_domains/cmd/dashboard/controller/mcp_origin.go
T
naibaandcloudcode ab25662ddd feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.

Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.

Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.

Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-30 15:56:44 +00:00

118 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"net"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
func mcpOriginGuard() gin.HandlerFunc {
return func(c *gin.Context) {
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin == "" {
c.Next()
return
}
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
abortOrigin(c)
return
}
if !strings.EqualFold(u.Host, c.Request.Host) {
abortOrigin(c)
return
}
// DNS rebinding 防线:当 dashboard 明确仅暴露在 loopback 上时,浏览器只
// 应通过 loopback hostname 命中本服务。任何带 Origin 的请求若 Host 不是
// loopback 字面量,说明它解析自一个对外 DNS 名(典型攻击:evil.example
// 解析到 127.0.0.1Host == Origin == evil.example 等式成立但实际打的是
// 用户本机 dashboard)。绑公网 IP / 未指定地址 / 公网 hostname 的部署
// 跳过此检查 —— 那种部署下 Host 本来就是公网域名,再要求 loopback
// 会把合法的同源前端请求一刀切。
if dashboardListensOnLoopback() && !isLoopbackHostname(hostnameOnly(c.Request.Host)) {
abortOrigin(c)
return
}
c.Next()
}
}
// dashboardListensOnLoopback 判定 dashboard 是否仅暴露在 loopback 上。
//
// 之前把未指定地址(空字符串、`0.0.0.0`、`::`、`*`)也当 loopback 部署,理由是
// 这些场景下浏览器仍可能通过 127.0.0.1 访问,rebinding 风险存在。问题是绝大多数
// 生产部署就是 `listen_host=""` / `0.0.0.0`,配合公网域名访问;那种部署下浏览器
// 同源 POST 的 Host 是公网域名而不是 loopback,旧逻辑会把合法的同源前端请求一刀切。
//
// 现在只在 ListenHost 明确写成 loopback 字面量时才开启 loopback 严格化:
// - 显式 loopback IP127.0.0.1 / ::1)或 localhost → 视为 loopback 部署,
// 此时任何带 Origin 的请求 Host 不是 loopback 都拒掉(DNS rebinding 防线);
// - 空 / `0.0.0.0` / `::` / `*` / 公网 IP / hostname → 不当 loopback 部署,
// 仅做 Origin == Host 的同源校验,不再额外要求 Host 必须是 loopback。
//
// 调用方仍然在配额(Origin == Host)外用 isLoopbackHostname 校验 Host,所以
// 当本机用户拿 127.0.0.1 访问绑公网 IP 的 dashboard 时这条防线依然生效(Host
// 是 loopbackOrigin 是公网域名,hostnameOnly 比较会失败)。
func dashboardListensOnLoopback() bool {
if singleton.Conf == nil {
return false
}
host := strings.TrimSpace(singleton.Conf.ListenHost)
if host == "" {
return false
}
host = strings.Trim(host, "[]")
switch host {
case "0.0.0.0", "::", "*":
return false
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return strings.EqualFold(host, "localhost")
}
func hostnameOnly(hostport string) string {
if i := strings.LastIndex(hostport, ":"); i >= 0 {
if !strings.Contains(hostport[i:], "]") {
return hostport[:i]
}
}
return hostport
}
func isLoopbackHostname(host string) bool {
host = strings.Trim(host, "[]")
if host == "" {
return false
}
if strings.EqualFold(host, "localhost") {
return true
}
if ip := net.ParseIP(host); ip != nil {
return ip.IsLoopback()
}
return false
}
func abortOrigin(c *gin.Context) {
c.AbortWithStatusJSON(http.StatusForbidden, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorForbidden: origin not allowed",
})
}
func mcpMethodNotAllowed(c *gin.Context) {
c.Header("Allow", "POST")
c.JSON(http.StatusMethodNotAllowed, model.CommonResponse[any]{
Success: false,
Error: "MCP endpoint only accepts POST (Streamable HTTP without standalone SSE / sessions)",
})
}