为隔离插件增加按权限校验的本地出站通道
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-30 22:33:27 +08:00
parent be097cc109
commit ceacd99a74
8 changed files with 488 additions and 7 deletions

View File

@ -269,10 +269,10 @@ func (h *HookSender) SettingsHtml(ctx *context.Context, url string, requestData
func NewHookSender(socketPath string, name string, serverVersion string, authToken ...string) *HookSender {
httpc := http.Client{
Timeout: time.Second * 10,
Timeout: time.Second * 35,
Transport: &http.Transport{
DialContext: func(ctx oContext.Context, network, addr string) (net.Conn, error) {
return net.Dial("unix", socketPath)
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
},
},
}
@ -349,6 +349,7 @@ func Init(serverVersion string) {
}
env := pluginEnvironment(root, logicalID)
env = appendPluginEnv(env, "SMAIL_PLUGIN_RPC_TOKEN", authToken)
env = appendPluginEnv(env, "SMAIL_PLUGIN_ID", logicalID)
launchSpec, launchErr := newPluginLaunchSpec(pluginPath, root, executableName, socketName, env)
if launchErr != nil {
if !config.DevelopmentMode {
@ -411,8 +412,18 @@ func Init(serverVersion string) {
}
if instance, generation, ok := pluginInstanceForStartup(logicalID); ok {
payload, payloadErr := pluginRuntimeConfig(logicalID, generation, instance)
if payloadErr != nil || hook.ValidateConfig(payload) != nil || hook.Configure(payload) != nil {
log.Warnf("[%s] Plugin runtime configuration failed", logicalID)
if payloadErr != nil {
log.Warnf("[%s] 插件运行配置构建失败", logicalID)
_ = process.Kill()
continue
}
if hook.ValidateConfig(payload) != nil {
log.Warnf("[%s] 插件运行配置校验失败", logicalID)
_ = process.Kill()
continue
}
if hook.Configure(payload) != nil {
log.Warnf("[%s] 插件运行配置应用失败", logicalID)
_ = process.Kill()
continue
}

View File

@ -0,0 +1,86 @@
//go:build linux
package hooks
import (
"context"
"errors"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"git.pcux.cn/xuans/Smail/utils/credential"
)
func startPluginEgress(spec *pluginLaunchSpec, uid, gid int) error {
logicalID := ""
for _, item := range spec.procAttr.Env {
if strings.HasPrefix(item, "SMAIL_PLUGIN_ID=") {
logicalID = strings.TrimPrefix(item, "SMAIL_PLUGIN_ID=")
}
}
if logicalID == "" {
return errors.New("plugin egress identity is unavailable")
}
token, err := newPluginAuthToken()
if err != nil {
return errors.New("plugin egress authorization generation failed")
}
socketPath := filepath.Join(spec.runtimeDir, filepath.Base(credential.PluginEgressSocket))
listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
if err != nil {
return errors.New("plugin egress listener creation failed")
}
if err := os.Chmod(socketPath, 0600); err != nil {
_ = listener.Close()
return errors.New("plugin egress socket permissions failed")
}
if err := os.Chown(socketPath, uid, gid); err != nil {
_ = listener.Close()
return errors.New("plugin egress socket identity failed")
}
ctx, cancel := context.WithCancel(context.Background())
server := &http.Server{
Handler: credential.NewPluginEgressHandler(token, func() []credential.NetworkTarget {
instance, _, ok := pluginInstanceForStartup(logicalID)
if !ok || validatePluginRuntimeBinding(logicalID, instance) != nil {
return nil
}
canNetwork := false
for _, capability := range instance.Permissions.Capabilities {
canNetwork = canNetwork || capability == "network"
}
if !canNetwork {
return nil
}
targets := make([]credential.NetworkTarget, 0, len(instance.Permissions.Network))
for _, target := range instance.Permissions.Network {
if target.Scheme == "https" {
targets = append(targets, credential.NetworkTarget{Scheme: target.Scheme, Host: target.Host, Port: target.Port})
}
}
return targets
}),
BaseContext: func(net.Listener) context.Context { return ctx },
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 65 * time.Second,
IdleTimeout: 5 * time.Second,
MaxHeaderBytes: 32 << 10,
ErrorLog: log.New(io.Discard, "", 0),
}
server.SetKeepAlivesEnabled(false)
spec.egressStop = func() {
cancel()
_ = listener.Close()
_ = server.Close()
}
spec.procAttr.Env = appendPluginEnv(spec.procAttr.Env, "SMAIL_PLUGIN_EGRESS_TOKEN", token)
go func() { _ = server.Serve(listener) }()
return nil
}

View File

@ -15,6 +15,7 @@ type pluginLaunchSpec struct {
socketPath string
runtimeDir string
parentFiles []*os.File
egressStop func()
}
func compatibilityPluginLaunchSpec(pluginPath, root, name, socketName string, env []string) *pluginLaunchSpec {
@ -43,6 +44,9 @@ func (s *pluginLaunchSpec) cleanup() {
return
}
s.closeParentFiles()
if s.egressStop != nil {
s.egressStop()
}
if s.runtimeDir != "" {
_ = os.RemoveAll(s.runtimeDir)
}

View File

@ -77,7 +77,7 @@ func newPluginLaunchSpec(pluginPath, root, name, socketName string, env []string
return nil, fmt.Errorf("prepare plugin listener: %w", err)
}
cloneFlags := uintptr(unix.CLONE_NEWNET)
return &pluginLaunchSpec{
spec := &pluginLaunchSpec{
path: "/" + name,
args: []string{name, "fd:3"},
socketPath: abstractSocket,
@ -104,7 +104,12 @@ func newPluginLaunchSpec(pluginPath, root, name, socketName string, env []string
Setsid: true,
},
},
}, nil
}
if err := startPluginEgress(spec, uid, gid); err != nil {
spec.cleanup()
return nil, err
}
return spec, nil
}
func copyPluginConfig(root, runtimeDir, name string, uid, gid int) error {

View File

@ -100,3 +100,35 @@ func TestInheritedAbstractSocketRemainsReachableAcrossListenerCopy(t *testing.T)
t.Fatal(err)
}
}
func TestPluginEgressSocketLifecycle(t *testing.T) {
spec := &pluginLaunchSpec{
runtimeDir: t.TempDir(),
procAttr: &os.ProcAttr{Env: []string{
"SMAIL_PLUGIN_ID=wechat_push",
"SMAIL_PLUGIN_RPC_TOKEN=" + strings.Repeat("b", 64),
}},
}
if err := startPluginEgress(spec, os.Getuid(), os.Getgid()); err != nil {
t.Fatal(err)
}
defer spec.cleanup()
socketPath := filepath.Join(spec.runtimeDir, "egress.socket")
info, err := os.Stat(socketPath)
if err != nil || info.Mode()&os.ModeSocket == 0 || info.Mode().Perm() != 0600 {
t.Fatal("egress socket is missing or permissions are unsafe")
}
token := ""
for _, value := range spec.procAttr.Env {
if strings.HasPrefix(value, "SMAIL_PLUGIN_EGRESS_TOKEN=") {
token = strings.TrimPrefix(value, "SMAIL_PLUGIN_EGRESS_TOKEN=")
}
}
if len(token) != 64 || token == strings.Repeat("b", 64) {
t.Fatal("egress token is missing or reuses the RPC token")
}
spec.egressStop()
if _, err := os.Stat(socketPath); !os.IsNotExist(err) {
t.Fatal("egress socket survived shutdown")
}
}

View File

@ -8,6 +8,7 @@ import (
"net/http"
"net/netip"
"net/url"
"os"
"strconv"
"strings"
"time"
@ -101,6 +102,10 @@ type NetworkTarget struct {
// NewRestrictedHTTPClientForTargets 创建仅允许访问授权目标的 HTTP 客户端。
func NewRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget) *http.Client {
return newRestrictedHTTPClientForTargets(timeoutMS, targets, os.Getenv("SMAIL_PLUGIN_SANDBOX") == "1")
}
func newRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget, sandbox bool) *http.Client {
timeout := time.Duration(NormalizeTimeoutMS(timeoutMS)) * time.Millisecond
allowedTargets := normalizeNetworkTargets(targets)
transport := &http.Transport{
@ -113,9 +118,13 @@ func NewRestrictedHTTPClientForTargets(timeoutMS int, targets []NetworkTarget) *
ResponseHeaderTimeout: timeout,
IdleConnTimeout: 30 * time.Second,
}
var outbound http.RoundTripper = transport
if sandbox {
outbound = newPluginEgressTransport(timeout)
}
return &http.Client{
Timeout: timeout,
Transport: restrictedRoundTripper{transport: transport, targets: allowedTargets},
Transport: restrictedRoundTripper{transport: outbound, targets: allowedTargets},
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("too many redirects")
@ -132,11 +141,24 @@ type restrictedRoundTripper struct {
func (r restrictedRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
if err := validateRestrictedRequest(request, r.targets, "remote target is not authorized"); err != nil {
closeRequestBody(request)
return nil, err
}
return r.transport.RoundTrip(request)
}
func (r restrictedRoundTripper) CloseIdleConnections() {
if transport, ok := r.transport.(interface{ CloseIdleConnections() }); ok {
transport.CloseIdleConnections()
}
}
func closeRequestBody(request *http.Request) {
if request != nil && request.Body != nil {
_ = request.Body.Close()
}
}
func validateRestrictedRequest(request *http.Request, targets []NetworkTarget, targetError string) error {
if request == nil || request.URL == nil {
return errors.New("invalid endpoint")

View File

@ -1,9 +1,11 @@
package credential
import (
"io"
"net/http"
"net/http/httptest"
"net/netip"
"strings"
"testing"
)
@ -130,3 +132,131 @@ func TestNormalizeTimeoutMS(t *testing.T) {
t.Fatal("timeout limit was not normalized")
}
}
func TestPluginEgressRejectsInvalidRequests(t *testing.T) {
token := strings.Repeat("a", 64)
allowed := []NetworkTarget{{Scheme: "https", Host: "allowed.example.com", Port: 443}}
cases := []struct {
name string
change func(*http.Request)
status int
}{
{"missing_token", func(r *http.Request) { r.Header.Del(egressTokenHeader) }, http.StatusForbidden},
{"wrong_token", func(r *http.Request) { r.Header.Set(egressTokenHeader, strings.Repeat("b", 64)) }, http.StatusForbidden},
{"duplicate_token", func(r *http.Request) { r.Header.Add(egressTokenHeader, token) }, http.StatusForbidden},
{"wrong_host", func(r *http.Request) { r.Host = "other" }, http.StatusBadRequest},
{"wrong_path", func(r *http.Request) { r.URL.Path = "/other" }, http.StatusBadRequest},
{"query", func(r *http.Request) { r.URL.RawQuery = "x=1" }, http.StatusBadRequest},
{"connect", func(r *http.Request) { r.Method = http.MethodConnect }, http.StatusBadRequest},
{"upgrade", func(r *http.Request) { r.Header.Set("Upgrade", "websocket") }, http.StatusBadRequest},
{"duplicate_target", func(r *http.Request) { r.Header.Add(egressTargetHeader, "https://allowed.example.com") }, http.StatusForbidden},
{"http_target", func(r *http.Request) { r.Header.Set(egressTargetHeader, "http://allowed.example.com") }, http.StatusForbidden},
{"private_target", func(r *http.Request) { r.Header.Set(egressTargetHeader, "https://127.0.0.1") }, http.StatusForbidden},
{"other_target", func(r *http.Request) { r.Header.Set(egressTargetHeader, "https://other.example.com") }, http.StatusForbidden},
{"other_port", func(r *http.Request) { r.Header.Set(egressTargetHeader, "https://allowed.example.com:8443") }, http.StatusForbidden},
{"oversized_body", func(r *http.Request) { r.ContentLength = maxEgressRequestBytes + 1 }, http.StatusRequestEntityTooLarge},
}
for _, item := range cases {
t.Run(item.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/relay", nil)
request.Host = "plugin-egress"
request.Header.Set(egressTokenHeader, token)
request.Header.Set(egressTargetHeader, "https://allowed.example.com/send")
item.change(request)
response := httptest.NewRecorder()
NewPluginEgressHandler(token, func() []NetworkTarget { return allowed }).ServeHTTP(response, request)
if response.Code != item.status {
t.Fatalf("unexpected rejection status: %d", response.Code)
}
})
}
}
func TestPluginEgressEmptyAuthorizationFailsClosed(t *testing.T) {
for _, authorize := range []func() []NetworkTarget{
nil,
func() []NetworkTarget { return nil },
func() []NetworkTarget { return []NetworkTarget{{Scheme: "invalid", Host: "allowed.example.com"}} },
} {
token := strings.Repeat("a", 64)
request := httptest.NewRequest(http.MethodPost, "/relay", nil)
request.Host = "plugin-egress"
request.Header.Set(egressTokenHeader, token)
request.Header.Set(egressTargetHeader, "https://allowed.example.com/send")
response := httptest.NewRecorder()
NewPluginEgressHandler(token, authorize).ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("empty authorization was accepted: %d", response.Code)
}
}
}
func TestPluginEgressRechecksAuthorizationBeforeOutbound(t *testing.T) {
token := strings.Repeat("a", 64)
calls := 0
handler := NewPluginEgressHandler(token, func() []NetworkTarget {
calls++
if calls > 1 {
return nil
}
return []NetworkTarget{{Scheme: "https", Host: "allowed.example.com", Port: 443}}
})
request := httptest.NewRequest(http.MethodPost, "/relay", strings.NewReader("payload"))
request.Host = "plugin-egress"
request.Header.Set(egressTokenHeader, token)
request.Header.Set(egressTargetHeader, "https://allowed.example.com/send")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden || calls != 2 {
t.Fatalf("authorization was not rechecked: status=%d calls=%d", response.Code, calls)
}
}
func TestStripEgressHeaders(t *testing.T) {
header := make(http.Header)
for _, name := range []string{egressTokenHeader, egressTargetHeader, "Proxy-Authorization", "X-Hop", "Upgrade"} {
header.Set(name, "sensitive")
}
header.Set("Connection", "X-Hop, Upgrade")
header.Set("Content-Type", "application/json")
stripEgressHeaders(header)
if len(header) != 1 || header.Get("Content-Type") != "application/json" {
t.Fatal("internal or hop-by-hop headers survived filtering")
}
}
type egressTrackedBody struct {
io.Reader
closed bool
}
func (b *egressTrackedBody) Close() error {
b.closed = true
return nil
}
func TestRestrictedTransportsCloseRejectedRequestBody(t *testing.T) {
for _, transport := range []http.RoundTripper{restrictedRoundTripper{}, &pluginEgressTransport{}} {
body := &egressTrackedBody{Reader: strings.NewReader("payload")}
request := httptest.NewRequest(http.MethodPost, "http://127.0.0.1/", body)
if _, err := transport.RoundTrip(request); err == nil || !body.closed {
t.Fatal("rejected request body was not closed")
}
}
}
type egressIdleTransport struct {
http.RoundTripper
closed bool
}
func (t *egressIdleTransport) CloseIdleConnections() { t.closed = true }
func TestRestrictedTransportForwardsIdleConnectionCleanup(t *testing.T) {
transport := &egressIdleTransport{}
client := &http.Client{Transport: restrictedRoundTripper{transport: &pluginEgressTransport{transport: transport}}}
client.CloseIdleConnections()
if !transport.closed {
t.Fatal("idle connection cleanup was not forwarded")
}
}

View File

@ -0,0 +1,191 @@
package credential
import (
"bytes"
"context"
"crypto/subtle"
"errors"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const (
PluginEgressSocket = "/egress.socket"
egressTokenHeader = "X-Smail-Egress-Token"
egressTargetHeader = "X-Smail-Egress-Target"
maxEgressRequestBytes int64 = 32 << 20
maxEgressResponseBytes int64 = 8 << 20
)
type pluginEgressTransport struct {
transport http.RoundTripper
token string
}
func newPluginEgressTransport(timeout time.Duration) http.RoundTripper {
return &pluginEgressTransport{
token: os.Getenv("SMAIL_PLUGIN_EGRESS_TOKEN"),
transport: &http.Transport{
Proxy: nil,
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return (&net.Dialer{Timeout: timeout}).DialContext(ctx, "unix", PluginEgressSocket)
},
DisableKeepAlives: true,
ResponseHeaderTimeout: timeout,
MaxResponseHeaderBytes: 32 << 10,
},
}
}
func (t *pluginEgressTransport) RoundTrip(request *http.Request) (*http.Response, error) {
if len(t.token) != 64 {
closeRequestBody(request)
return nil, errors.New("plugin egress authorization is unavailable")
}
if request == nil || request.URL == nil {
closeRequestBody(request)
return nil, errors.New("invalid plugin egress request")
}
// 目标仅在本地通道传递,主进程仍独立校验,不信任插件侧校验结果。
envelope := request.Clone(request.Context())
envelope.URL = &url.URL{Scheme: "http", Host: "plugin-egress", Path: "/relay"}
envelope.Host = "plugin-egress"
envelope.RequestURI = ""
if envelope.Header == nil {
envelope.Header = make(http.Header)
}
envelope.Header.Set(egressTokenHeader, t.token)
envelope.Header.Set(egressTargetHeader, request.URL.String())
response, err := t.transport.RoundTrip(envelope)
if err != nil {
return nil, errors.New("plugin egress channel is unavailable")
}
response.Request = request
return response, nil
}
func (t *pluginEgressTransport) CloseIdleConnections() {
if transport, ok := t.transport.(interface{ CloseIdleConnections() }); ok {
transport.CloseIdleConnections()
}
}
// NewPluginEgressHandler 只供沙箱专属本地通道使用,不注册到公网路由。
// 每次重新取权限;空权限拒绝访问,禁止继承通用客户端的空名单语义。
func NewPluginEgressHandler(token string, authorize func() []NetworkTarget) http.Handler {
slots := make(chan struct{}, 2)
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if len(token) != 64 || len(request.Header.Values(egressTokenHeader)) != 1 ||
subtle.ConstantTimeCompare([]byte(request.Header.Get(egressTokenHeader)), []byte(token)) != 1 {
http.Error(writer, "出站认证失败", http.StatusForbidden)
return
}
if request.URL.Path != "/relay" || request.URL.RawQuery != "" || request.URL.IsAbs() ||
request.Host != "plugin-egress" || request.Header.Get("Upgrade") != "" ||
(request.Method != http.MethodGet && request.Method != http.MethodPost && request.Method != http.MethodHead) {
http.Error(writer, "不支持的出站请求", http.StatusBadRequest)
return
}
select {
case slots <- struct{}{}:
defer func() { <-slots }()
default:
http.Error(writer, "出站请求繁忙", http.StatusTooManyRequests)
return
}
var targets []NetworkTarget
if authorize != nil {
targets = normalizeNetworkTargets(authorize())
}
if len(targets) == 0 || len(request.Header.Values(egressTargetHeader)) != 1 {
http.Error(writer, "出站目标未获授权", http.StatusForbidden)
return
}
rawTarget := request.Header.Get(egressTargetHeader)
if _, err := NormalizeHTTPSURL(rawTarget); err != nil {
http.Error(writer, "出站目标无效", http.StatusForbidden)
return
}
endpoint, err := url.Parse(rawTarget)
if err != nil || !matchesNetworkTarget(endpoint, targets) {
http.Error(writer, "出站目标未获授权", http.StatusForbidden)
return
}
if request.ContentLength > maxEgressRequestBytes {
http.Error(writer, "出站请求过大", http.StatusRequestEntityTooLarge)
return
}
// 先读取有界请求体,防止未知长度请求在越限前已部分提交给上游。
body, err := io.ReadAll(http.MaxBytesReader(writer, request.Body, maxEgressRequestBytes))
if err != nil {
http.Error(writer, "出站请求体无效或过大", http.StatusRequestEntityTooLarge)
return
}
// 请求体读取期间权限可能被撤销,连接上游前再次按当前配置核对。
targets = normalizeNetworkTargets(authorize())
if len(targets) == 0 || !matchesNetworkTarget(endpoint, targets) {
http.Error(writer, "出站目标未获授权", http.StatusForbidden)
return
}
ctx, cancel := context.WithTimeout(request.Context(), 30*time.Second)
defer cancel()
outbound, err := http.NewRequestWithContext(ctx, request.Method, endpoint.String(), bytes.NewReader(body))
if err != nil {
http.Error(writer, "出站请求无效", http.StatusBadRequest)
return
}
outbound.Header = request.Header.Clone()
stripEgressHeaders(outbound.Header)
client := newRestrictedHTTPClientForTargets(30_000, targets, false)
// 每次请求使用独立连接,避免配置撤销后继续复用已有上游连接。
if restricted, ok := client.Transport.(restrictedRoundTripper); ok {
if transport, ok := restricted.transport.(*http.Transport); ok {
transport.DisableKeepAlives = true
transport.MaxResponseHeaderBytes = 32 << 10
}
}
defer client.CloseIdleConnections()
// 重定向交还调用方,每一跳重新通过身份、权限与公网地址检查。
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
response, err := client.Do(outbound)
if err != nil {
// 原始错误可能包含上游 URL 中的令牌,绝不回传或记录。
http.Error(writer, "获准目标连接失败", http.StatusBadGateway)
return
}
defer response.Body.Close()
payload, err := io.ReadAll(io.LimitReader(response.Body, maxEgressResponseBytes+1))
if err != nil || int64(len(payload)) > maxEgressResponseBytes {
http.Error(writer, "上游响应无效或过大", http.StatusBadGateway)
return
}
stripEgressHeaders(response.Header)
response.Header.Del("Content-Length")
for name, values := range response.Header {
for _, value := range values {
writer.Header().Add(name, value)
}
}
writer.WriteHeader(response.StatusCode)
_, _ = writer.Write(payload)
})
}
func stripEgressHeaders(header http.Header) {
for _, value := range header.Values("Connection") {
for _, name := range strings.Split(value, ",") {
header.Del(strings.TrimSpace(name))
}
}
for _, name := range []string{
"Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization",
"TE", "Trailer", "Transfer-Encoding", "Upgrade", egressTokenHeader, egressTargetHeader,
} {
header.Del(name)
}
}