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 }