Skip to content

Commit 29c6a0f

Browse files
committed
harden http_post: ssrf-safe client, scheme check, redacted logs, bounded drain
1 parent 0fa3d04 commit 29c6a0f

4 files changed

Lines changed: 85 additions & 18 deletions

File tree

pkg/hooks/builtins/export_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package builtins
2+
3+
import (
4+
"time"
5+
6+
"github.com/docker/docker-agent/pkg/httpclient"
7+
)
8+
9+
// SetHTTPPostClientUnsafeForTest swaps the httpPost client for one that
10+
// bypasses SSRF dial-time protection so tests can talk to
11+
// httptest.NewServer (which binds to 127.0.0.1). Returns a restore
12+
// function. Test-only — this file is *_test.go so it never compiles
13+
// into release binaries.
14+
func SetHTTPPostClientUnsafeForTest() func() {
15+
prev := httpPostClient
16+
httpPostClient = httpclient.NewSafeClient(30*time.Second, true)
17+
return func() { httpPostClient = prev }
18+
}

pkg/hooks/builtins/http_post.go

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,48 +2,68 @@ package builtins
22

33
import (
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.
1519
const 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.
2136
func 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
}

pkg/hooks/builtins/http_post_test.go

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,18 +117,29 @@ func TestHTTPPostSwallowsErrors(t *testing.T) {
117117
}
118118
}
119119

120-
// TestHTTPPostMalformedURLReturnsError: an unparseable URL is the
121-
// one error path that surfaces, so `on_error: warn` flags the
122-
// misconfig.
123-
func TestHTTPPostMalformedURLReturnsError(t *testing.T) {
120+
// TestHTTPPostRejectsNonHTTPSchemes: file://, ftp://, javascript: and
121+
// scheme-less or host-less inputs all surface as a config error
122+
// rather than being silently dispatched to a transport.
123+
func TestHTTPPostRejectsNonHTTPSchemes(t *testing.T) {
124124
t.Parallel()
125125

126126
fn := lookup(t, builtins.HTTPPost)
127127

128-
out, err := fn(t.Context(), &hooks.Input{SessionID: "s"}, []string{"http://\x7f\x00.example", "{}"})
129-
require.Error(t, err)
130-
assert.Nil(t, out)
131-
assert.Contains(t, err.Error(), "http_post:")
128+
cases := []string{
129+
"file:///etc/passwd",
130+
"ftp://example.com/",
131+
"javascript:alert(1)",
132+
"not-a-url",
133+
"http://",
134+
"http://\x7f\x00.example",
135+
}
136+
for _, raw := range cases {
137+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s"}, []string{raw, "{}"})
138+
require.Errorf(t, err, "input %q must be rejected", raw)
139+
assert.Nil(t, out)
140+
assert.Contains(t, err.Error(), "http_post:")
141+
assert.Contains(t, err.Error(), "http(s)")
142+
}
132143
}
133144

134145
// TestHTTPPostHonoursContextCancellation: the request returns

pkg/hooks/builtins/main_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package builtins_test
2+
3+
import (
4+
"os"
5+
"testing"
6+
7+
"github.com/docker/docker-agent/pkg/hooks/builtins"
8+
)
9+
10+
// TestMain flips the http_post client to its unsafe variant for the
11+
// duration of this test binary, since httptest.NewServer binds to
12+
// 127.0.0.1 and is otherwise rejected by the production SSRF dialer.
13+
// Production callers always go through the safe client wired in
14+
// http_post.go.
15+
func TestMain(m *testing.M) {
16+
builtins.SetHTTPPostClientUnsafeForTest()
17+
os.Exit(m.Run())
18+
}

0 commit comments

Comments
 (0)