Skip to content

Commit 9d110ae

Browse files
authored
Merge pull request docker#2705 from dgageot/board/18211a338ad4b59e
feat(hooks): add http_post builtin
2 parents f10e291 + 29c6a0f commit 9d110ae

6 files changed

Lines changed: 278 additions & 0 deletions

File tree

pkg/hooks/builtins/builtins.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
// tool output. Same builtin, dispatches on
2424
// event so a single name covers all three
2525
// legs of the feature.
26+
// - http_post (any event) — POST args[1] to args[0]
2627
//
2728
// Reference any of them from a hook YAML entry as
2829
// `{type: builtin, command: "<name>"}`. The runtime additionally
@@ -118,6 +119,7 @@ func Register(r *hooks.Registry) (*State, error) {
118119
r.RegisterBuiltin(MaxIterations, state.maxIterations.hook),
119120
r.RegisterBuiltin(Snapshot, state.snapshot.hook),
120121
r.RegisterBuiltin(RedactSecrets, redactSecrets),
122+
r.RegisterBuiltin(HTTPPost, httpPost),
121123
); err != nil {
122124
return nil, err
123125
}

pkg/hooks/builtins/builtins_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func TestRegisterInstallsAllBuiltins(t *testing.T) {
3636
builtins.MaxIterations,
3737
builtins.Snapshot,
3838
builtins.RedactSecrets,
39+
builtins.HTTPPost,
3940
} {
4041
fn, ok := r.LookupBuiltin(name)
4142
assert.True(t, ok, "builtin %q must be registered", name)

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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package builtins
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
"log/slog"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
"time"
13+
14+
"github.com/docker/docker-agent/pkg/hooks"
15+
"github.com/docker/docker-agent/pkg/httpclient"
16+
)
17+
18+
// HTTPPost is the registered name of the http_post builtin.
19+
const HTTPPost = "http_post"
20+
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+
29+
// httpPost POSTs args[1] to args[0] with Content-Type: application/json.
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.
36+
func httpPost(ctx context.Context, _ *hooks.Input, args []string) (*hooks.Output, error) {
37+
if len(args) == 0 || args[0] == "" {
38+
return nil, nil
39+
}
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+
}
44+
var body string
45+
if len(args) >= 2 {
46+
body = args[1]
47+
}
48+
redacted := target.Redacted()
49+
50+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), strings.NewReader(body))
51+
if err != nil {
52+
return nil, fmt.Errorf("http_post: build request: %w", err)
53+
}
54+
req.Header.Set("Content-Type", "application/json")
55+
56+
resp, err := httpPostClient.Do(req)
57+
if err != nil {
58+
slog.WarnContext(ctx, "http_post: request failed", "url", redacted, "error", err)
59+
return nil, nil
60+
}
61+
defer resp.Body.Close()
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))
65+
if resp.StatusCode >= 400 {
66+
slog.WarnContext(ctx, "http_post: non-success response", "url", redacted, "status", resp.StatusCode)
67+
}
68+
return nil, nil
69+
}
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
package builtins_test
2+
3+
import (
4+
"context"
5+
"io"
6+
"net/http"
7+
"net/http/httptest"
8+
"testing"
9+
"time"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/docker/docker-agent/pkg/hooks"
15+
"github.com/docker/docker-agent/pkg/hooks/builtins"
16+
)
17+
18+
// TestHTTPPostSendsBodyToURL pins the happy path: POST with body
19+
// and Content-Type: application/json, and a nil Output.
20+
func TestHTTPPostSendsBodyToURL(t *testing.T) {
21+
t.Parallel()
22+
23+
const payload = `{"event":"turn_start"}`
24+
25+
var (
26+
gotMethod string
27+
gotContentType string
28+
gotBody []byte
29+
)
30+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
31+
gotMethod = r.Method
32+
gotContentType = r.Header.Get("Content-Type")
33+
gotBody, _ = io.ReadAll(r.Body)
34+
w.WriteHeader(http.StatusNoContent)
35+
}))
36+
t.Cleanup(srv.Close)
37+
38+
fn := lookup(t, builtins.HTTPPost)
39+
40+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s"}, []string{srv.URL, payload})
41+
require.NoError(t, err)
42+
assert.Nil(t, out)
43+
44+
assert.Equal(t, http.MethodPost, gotMethod)
45+
assert.Equal(t, "application/json", gotContentType)
46+
assert.JSONEq(t, payload, string(gotBody))
47+
}
48+
49+
// TestHTTPPostEmptyBodyIsAllowed: omitting the second arg sends an
50+
// empty body — useful for ping-style webhooks.
51+
func TestHTTPPostEmptyBodyIsAllowed(t *testing.T) {
52+
t.Parallel()
53+
54+
var gotBody []byte
55+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
56+
gotBody, _ = io.ReadAll(r.Body)
57+
w.WriteHeader(http.StatusOK)
58+
}))
59+
t.Cleanup(srv.Close)
60+
61+
fn := lookup(t, builtins.HTTPPost)
62+
63+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s"}, []string{srv.URL})
64+
require.NoError(t, err)
65+
assert.Nil(t, out)
66+
assert.Empty(t, gotBody)
67+
}
68+
69+
// TestHTTPPostNoOpWithoutURL: a missing or empty URL is a no-op so
70+
// a misconfigured YAML doesn't break the run loop.
71+
func TestHTTPPostNoOpWithoutURL(t *testing.T) {
72+
t.Parallel()
73+
74+
fn := lookup(t, builtins.HTTPPost)
75+
76+
cases := [][]string{
77+
nil,
78+
{},
79+
{""},
80+
{"", "body"},
81+
}
82+
for _, args := range cases {
83+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s"}, args)
84+
require.NoErrorf(t, err, "args=%v: must not error", args)
85+
assert.Nilf(t, out, "args=%v: must be a no-op", args)
86+
}
87+
}
88+
89+
// TestHTTPPostSwallowsErrors: neither a non-2xx response nor an
90+
// unreachable receiver propagates as a hook error.
91+
func TestHTTPPostSwallowsErrors(t *testing.T) {
92+
t.Parallel()
93+
94+
serverError := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
95+
w.WriteHeader(http.StatusInternalServerError)
96+
}))
97+
t.Cleanup(serverError.Close)
98+
99+
// Bind, capture URL, then close: the port is now guaranteed-unreachable.
100+
unreachable := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
101+
unreachableURL := unreachable.URL
102+
unreachable.Close()
103+
104+
cases := map[string]string{
105+
"non-2xx response": serverError.URL,
106+
"unreachable receiver": unreachableURL,
107+
}
108+
109+
fn := lookup(t, builtins.HTTPPost)
110+
for name, url := range cases {
111+
t.Run(name, func(t *testing.T) {
112+
t.Parallel()
113+
out, err := fn(t.Context(), &hooks.Input{SessionID: "s"}, []string{url, "{}"})
114+
require.NoError(t, err)
115+
assert.Nil(t, out)
116+
})
117+
}
118+
}
119+
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) {
124+
t.Parallel()
125+
126+
fn := lookup(t, builtins.HTTPPost)
127+
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+
}
143+
}
144+
145+
// TestHTTPPostHonoursContextCancellation: the request returns
146+
// promptly after ctx deadline instead of waiting for the handler.
147+
func TestHTTPPostHonoursContextCancellation(t *testing.T) {
148+
t.Parallel()
149+
150+
// Bounded sleep so the client must abandon before the response,
151+
// while keeping httptest.Server.Close() cleanup quick.
152+
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
153+
time.Sleep(300 * time.Millisecond)
154+
}))
155+
t.Cleanup(srv.Close)
156+
157+
fn := lookup(t, builtins.HTTPPost)
158+
159+
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Millisecond)
160+
defer cancel()
161+
162+
start := time.Now()
163+
out, err := fn(ctx, &hooks.Input{SessionID: "s"}, []string{srv.URL, "{}"})
164+
elapsed := time.Since(start)
165+
166+
// Network errors (incl. cancellation) are swallowed by design.
167+
require.NoError(t, err)
168+
assert.Nil(t, out)
169+
assert.Less(t, elapsed, 250*time.Millisecond)
170+
}

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)