Files
Cyrene_v2/plugins/tools/io/http.go
T
AskaEth 0f9251b983 fix(channel): 渠道适配器与出站工具的上游响应体/带凭据 URL 不再进日志与错误(M41)
接 M38 的 18 条真泄漏里「渠道适配器 + 出站工具」这一族(#10 #11 #12 #13 #15 #16)。
只动日志与 error 文字:发送/接收、解析口径、重试次数一个都没改。

- httpclient.do:非 2xx 不再把 512B 响应体原文拼进 error,改 trace.UpstreamError
  (状态/体积/sha256 前缀)+ 信封 code/message(RedactText + 200 rune);error 里的
  target 改 trace.HostOf,并剥掉 *url.Error 外壳(它带整条地址)
- telegram doJSON:4xx 只解 error_code/description(清洗 + 截断),体其余字段不拼;
  传输错误只留来源 —— Bot API 把 token 写在地址路径里
- weixin_ilink doJSON:4096B 体 ⇒ trace.UpstreamError,rawURL ⇒ mediafetch.HostOf;
  uploadOnce 剥掉预签名上传地址(encrypted_query_param),重试日志补来源主机
- qq_obv11 connectClient 成功 INFO:改走同包已有的 maskAccessTokenQuery
  (OneBot 惯例把 ?access_token= 写进 remote_url)
- plugins/tools/io http 拦截日志:改记 trace.HostOf + 原因码,不记原 URL、也不记错误
  原文(解析失败时它包着 *url.Error)

每条都有能变异打红的用例:QQ/iLink 预签名地址的哨兵、三处 4xx 体哨兵、
user:pass@ 与 query 凭据;八处变异逐条验证变红。
2026-10-26 13:47:56 +08:00

146 lines
4.5 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 io
import (
"context"
"errors"
"fmt"
stdio "io"
"log/slog"
"mime"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// Compile-time assertion that httpTool implements sdk.Tool.
var _ sdk.Tool = (*httpTool)(nil)
// httpTool performs a single GET or POST request with headers, body, a timeout
// and SSRF protection, truncating the response to a size cap.
type httpTool struct {
client *http.Client
allowedHosts []string
maxBytes int64
log *slog.Logger
}
func newHTTPTool(t *tools) *httpTool {
return &httpTool{
client: t.client,
allowedHosts: t.allowedHosts,
maxBytes: t.maxHTTPBytes,
log: t.log,
}
}
// Name implements sdk.Tool.
func (h *httpTool) Name() string { return "http" }
// Description implements sdk.Tool.
func (h *httpTool) Description() string {
return "Perform an HTTP GET or POST request with headers, body, timeout and SSRF protection."
}
// Parameters implements sdk.Tool.
func (h *httpTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"method": map[string]any{"type": "string", "enum": []any{"GET", "POST"}, "description": "HTTP method."},
"url": map[string]any{"type": "string", "description": "Absolute http(s) URL."},
"headers": map[string]any{"type": "object", "description": "Request headers."},
"body": map[string]any{"type": "string", "description": "Request body for POST."},
"timeout_ms": map[string]any{"type": "integer", "description": "Per-request timeout in milliseconds."},
"max_bytes": map[string]any{"type": "integer", "description": "Response size cap in bytes."},
},
"required": []any{"url"},
}, nil
}
// blockReason 把一次拦截归类成稳定原因码,供审计日志使用。日志只记这个码与来源主机:
// 拦截原因的具体文本(哪个 scheme、解析失败原话)会经错误返回给调用方,而错误文本本身
// 可能带着原 URL —— 那是**不进日志**的那一侧。
func blockReason(err error) string {
switch {
case errors.Is(err, ErrSSRF):
return "ssrf_blocked"
default:
return "url_rejected"
}
}
// Execute implements sdk.Tool.
func (h *httpTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("io: http: args must be an object")
}
method := strings.ToUpper(argString(m, "method"))
if method == "" {
method = http.MethodGet
}
if method != http.MethodGet && method != http.MethodPost {
return nil, fmt.Errorf("io: http: unsupported method %q", method)
}
rawURL := argString(m, "url")
if strings.TrimSpace(rawURL) == "" {
return nil, fmt.Errorf("io: http: url is required")
}
u, err := validateRequestURL(ctx, rawURL, h.allowedHosts)
if err != nil {
// 只记来源与原因码:rawURL 由模型/用户给,可能带 user:pass@ 或 ?api_key=,
// 而日志会被采集与转贴;错误原文也不进日志 —— 解析失败时它包着 *url.Error,
// 里面就是整条原串。
h.log.Warn("io http: blocked request",
"host", trace.HostOf(rawURL), "reason", blockReason(err))
return nil, err
}
body := argString(m, "body")
var reqBody stdio.Reader
if body != "" {
reqBody = strings.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody)
if err != nil {
return nil, fmt.Errorf("io: http: build request: %w", err)
}
for k, v := range argStringMap(m, "headers") {
req.Header.Set(k, v)
}
ct := req.Header.Get("Content-Type")
if ct != "" {
if _, err := validateContentType(ct); err != nil {
return nil, err
}
} else if body != "" {
req.Header.Set("Content-Type", "application/json")
}
if ms, ok := argInt(m, "timeout_ms"); ok && ms > 0 {
ctx, cancel := context.WithTimeout(ctx, time.Duration(ms)*time.Millisecond)
defer cancel()
req = req.WithContext(ctx)
}
if h.client == nil {
return nil, fmt.Errorf("io: http: no underlying client")
}
resp, err := h.client.Do(req)
if err != nil {
return nil, fmt.Errorf("io: http: do: %w", err)
}
defer resp.Body.Close()
data, truncated, err := readLimited(resp.Body, h.maxBytes)
if err != nil {
return nil, fmt.Errorf("io: http: read: %w", err)
}
ctOut, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type"))
return map[string]any{
"status": resp.StatusCode,
"content_type": ctOut,
"body": string(data),
"truncated": truncated,
}, nil
}