mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
Phase 2 (a587231b) advertised SSRF hardening for the http hook handler but the supporting `internal/security` package was never created, so the production HTTPHandler fell back to a bare http.Client and admin-config webhooks could probe loopback / link-local / RFC1918 / cloud-metadata. - internal/security/ssrf.go: Validate(rawURL) parses + resolves once, rejects loopback/link-local/private/multicast/unspecified + 169.254.169.254; NewSafeClient(timeout) returns an http.Client whose DialContext pins the resolved IP from context (defense-in-depth re-checks the dialed IP) and refuses redirects (CheckRedirect = ErrUseLastResponse) - internal/hooks/handlers/http_handler.go: call Validate before each request, attach pinned IP via security.WithPinnedIP(ctx, ip) - cmd/gateway_managed.go: construct HTTPHandler with Client: security.NewSafeClient(10*time.Second) - tests: 13 new ssrf_test.go cases (every block category + redirect refused + dial pinned); existing http_test.go retrofitted with security.SetAllowLoopbackForTest helper for httptest.NewServer - plan: phase-02 Step 2a marked done with reference to this commit Refs: GitHub Issue #875
152 lines
4.8 KiB
Go
152 lines
4.8 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
|
"github.com/nextlevelbuilder/goclaw/internal/security"
|
|
)
|
|
|
|
// HTTPHandler posts event data to an HTTP endpoint and interprets the JSON
|
|
// response as a hook decision. Implements hooks.Handler.
|
|
//
|
|
// SSRF hardening: caller must supply a Client built with a DialContext that
|
|
// pins the resolved IP and blocks loopback/link-local/private ranges.
|
|
// NewSSRFSafeClient() returns a preconfigured client for production use.
|
|
type HTTPHandler struct {
|
|
// EncryptKey is the AES-256-GCM key used to decrypt Authorization header
|
|
// values stored encrypted in cfg.Config["headers"]. Empty = no decryption.
|
|
EncryptKey string
|
|
// Client is the HTTP client used for outbound requests. Must not be nil.
|
|
// Use NewSSRFSafeClient() for production. Tests may supply a mock.
|
|
Client *http.Client
|
|
}
|
|
|
|
// httpResponse is the expected JSON body from a webhook endpoint.
|
|
type httpResponse struct {
|
|
Decision *string `json:"decision"`
|
|
AdditionalCtx string `json:"additionalContext"`
|
|
UpdatedInput map[string]any `json:"updatedInput"`
|
|
Continue *bool `json:"continue"`
|
|
}
|
|
|
|
// Execute implements hooks.Handler.
|
|
func (h *HTTPHandler) Execute(ctx context.Context, cfg hooks.HookConfig, ev hooks.Event) (hooks.Decision, error) {
|
|
urlStr, _ := cfg.Config["url"].(string)
|
|
if urlStr == "" {
|
|
return hooks.DecisionError, fmt.Errorf("hook: http handler: missing 'url' in config")
|
|
}
|
|
|
|
// SSRF validation: resolve host once and pin the IP.
|
|
// When a custom Client is set (e.g. in tests via httptest), the client's
|
|
// own transport handles routing; Validate is still called so that
|
|
// production-path URLs are always vetted. Tests that use httptest must
|
|
// call security.SetAllowLoopbackForTest(true) to bypass the loopback block.
|
|
parsedURL, pinnedIP, err := security.Validate(urlStr)
|
|
if err != nil {
|
|
return hooks.DecisionError, fmt.Errorf("hook: http handler: ssrf check: %w", err)
|
|
}
|
|
_ = parsedURL // URL string already validated; we keep urlStr for the request.
|
|
|
|
// Stash pinned IP into context so NewSafeClient's DialContext can read it.
|
|
ctx = security.WithPinnedIP(ctx, pinnedIP)
|
|
|
|
body, err := json.Marshal(ev)
|
|
if err != nil {
|
|
return hooks.DecisionError, fmt.Errorf("hook: http handler: marshal event: %w", err)
|
|
}
|
|
|
|
client := h.Client
|
|
if client == nil {
|
|
client = &http.Client{Timeout: 10 * time.Second}
|
|
}
|
|
|
|
decision, err := h.doRequest(ctx, client, urlStr, cfg.Config, body)
|
|
if err != nil {
|
|
// Retry once on 5xx / network error with 1s backoff.
|
|
select {
|
|
case <-ctx.Done():
|
|
return hooks.DecisionError, ctx.Err()
|
|
case <-time.After(1 * time.Second):
|
|
}
|
|
decision, err = h.doRequest(ctx, client, urlStr, cfg.Config, body)
|
|
if err != nil {
|
|
return hooks.DecisionError, fmt.Errorf("hook: http handler: %w", err)
|
|
}
|
|
}
|
|
return decision, nil
|
|
}
|
|
|
|
func (h *HTTPHandler) doRequest(ctx context.Context, client *http.Client, urlStr string, cfgMap map[string]any, body []byte) (hooks.Decision, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, bytes.NewReader(body))
|
|
if err != nil {
|
|
return hooks.DecisionError, fmt.Errorf("build request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
// Apply configured headers; decrypt Authorization values if key is set.
|
|
if raw, ok := cfgMap["headers"]; ok {
|
|
if headers, ok := raw.(map[string]any); ok {
|
|
for k, v := range headers {
|
|
val, _ := v.(string)
|
|
if k == "Authorization" && h.EncryptKey != "" && val != "" {
|
|
if dec, err := crypto.Decrypt(val, h.EncryptKey); err == nil {
|
|
val = dec
|
|
}
|
|
}
|
|
req.Header.Set(k, val)
|
|
}
|
|
}
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return hooks.DecisionError, fmt.Errorf("request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 500 {
|
|
return hooks.DecisionError, fmt.Errorf("server error %d", resp.StatusCode)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
return hooks.DecisionError, fmt.Errorf("client error %d (no retry)", resp.StatusCode)
|
|
}
|
|
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 MiB cap
|
|
if err != nil {
|
|
return hooks.DecisionError, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
if len(respBody) == 0 {
|
|
return hooks.DecisionAllow, nil
|
|
}
|
|
|
|
var hr httpResponse
|
|
if err := json.Unmarshal(respBody, &hr); err != nil {
|
|
// Non-JSON 2xx is treated as allow.
|
|
return hooks.DecisionAllow, nil
|
|
}
|
|
|
|
// Explicit "decision" field takes priority.
|
|
if hr.Decision != nil {
|
|
switch hooks.Decision(*hr.Decision) {
|
|
case hooks.DecisionBlock:
|
|
return hooks.DecisionBlock, nil
|
|
default:
|
|
return hooks.DecisionAllow, nil
|
|
}
|
|
}
|
|
// Fallback: "continue": false → block.
|
|
if hr.Continue != nil && !*hr.Continue {
|
|
return hooks.DecisionBlock, nil
|
|
}
|
|
return hooks.DecisionAllow, nil
|
|
}
|