@@ -2,48 +2,68 @@ package builtins
22
33import (
44 "context"
5+ "errors"
56 "fmt"
67 "io"
78 "log/slog"
89 "net/http"
10+ "net/url"
911 "strings"
12+ "time"
1013
1114 "github.com/docker/docker-agent/pkg/hooks"
15+ "github.com/docker/docker-agent/pkg/httpclient"
1216)
1317
1418// HTTPPost is the registered name of the http_post builtin.
1519const HTTPPost = "http_post"
1620
21+ // httpPostClient is the HTTP client used by httpPost. It refuses
22+ // connections to non-public IPs at dial time (defeating DNS rebinding
23+ // to loopback / RFC1918 / link-local incl. cloud metadata at
24+ // 169.254.169.254) and bounds redirects at 10 hops. Tests swap it for
25+ // an unsafe variant via export_test.go since httptest.NewServer binds
26+ // to 127.0.0.1.
27+ var httpPostClient = httpclient .NewSafeClient (30 * time .Second , false )
28+
1729// httpPost POSTs args[1] to args[0] with Content-Type: application/json.
18- // Empty URL is a no-op; network errors and non-2xx responses are
19- // logged and swallowed so the dispatch verdict stays nil. The hook
20- // executor already wraps ctx with [Hook.GetTimeout].
30+ // An empty URL is a no-op (lenient args contract). A non-http(s) or
31+ // otherwise unparseable URL surfaces as an error so on_error: warn
32+ // flags the misconfig. Network errors and non-2xx responses are
33+ // logged (with credentials redacted) and swallowed so a bad webhook
34+ // never breaks the run loop. The hook executor already wraps ctx with
35+ // [Hook.GetTimeout]; the client's Timeout is a backstop.
2136func httpPost (ctx context.Context , _ * hooks.Input , args []string ) (* hooks.Output , error ) {
2237 if len (args ) == 0 || args [0 ] == "" {
2338 return nil , nil
2439 }
25- url := args [0 ]
40+ target , err := url .Parse (args [0 ])
41+ if err != nil || target .Host == "" || (target .Scheme != "http" && target .Scheme != "https" ) {
42+ return nil , errors .New ("http_post: only http(s) URLs are supported" )
43+ }
2644 var body string
2745 if len (args ) >= 2 {
2846 body = args [1 ]
2947 }
48+ redacted := target .Redacted ()
3049
31- req , err := http .NewRequestWithContext (ctx , http .MethodPost , url , strings .NewReader (body ))
50+ req , err := http .NewRequestWithContext (ctx , http .MethodPost , target . String () , strings .NewReader (body ))
3251 if err != nil {
3352 return nil , fmt .Errorf ("http_post: build request: %w" , err )
3453 }
3554 req .Header .Set ("Content-Type" , "application/json" )
3655
37- resp , err := http . DefaultClient .Do (req )
56+ resp , err := httpPostClient .Do (req )
3857 if err != nil {
39- slog .WarnContext (ctx , "http_post: request failed" , "url" , url , "error" , err )
58+ slog .WarnContext (ctx , "http_post: request failed" , "url" , redacted , "error" , err )
4059 return nil , nil
4160 }
4261 defer resp .Body .Close ()
43- // Drain so the connection can be reused by keep-alive.
44- _ , _ = io .Copy (io .Discard , resp .Body )
62+ // Cap the drain so a malicious receiver can't pin the goroutine on
63+ // an unbounded read; 64 KiB is plenty for a webhook ack.
64+ _ , _ = io .Copy (io .Discard , io .LimitReader (resp .Body , 64 << 10 ))
4565 if resp .StatusCode >= 400 {
46- slog .WarnContext (ctx , "http_post: non-success response" , "url" , url , "status" , resp .StatusCode )
66+ slog .WarnContext (ctx , "http_post: non-success response" , "url" , redacted , "status" , resp .StatusCode )
4767 }
4868 return nil , nil
4969}
0 commit comments