Files
viettranx fb3552f7e5 fix(hooks/security): add SSRF-safe http client missed by phase 2 commit
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
2026-04-15 21:12:01 +07:00

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
}