Skip to content

Commit 19630e1

Browse files
authored
Merge pull request #463 from hookdeck/feat/listen-binary-bodies
feat(listen): forward binary request bodies byte-exact
2 parents 98cc142 + 2c1b9f2 commit 19630e1

8 files changed

Lines changed: 789 additions & 5 deletions

File tree

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package proxy
2+
3+
import (
4+
"bytes"
5+
"encoding/base64"
6+
"encoding/json"
7+
"io"
8+
"net/http"
9+
"net/http/httptest"
10+
"net/url"
11+
"testing"
12+
"time"
13+
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
17+
"github.com/hookdeck/hookdeck-cli/pkg/websocket"
18+
)
19+
20+
type receivedRequest struct {
21+
body []byte
22+
contentType string
23+
contentLength int64
24+
}
25+
26+
// forwardAttempt runs one attempt through processAttempt against a local
27+
// server and returns what that server received.
28+
func forwardAttempt(t *testing.T, request websocket.AttemptRequest) receivedRequest {
29+
t.Helper()
30+
31+
received := make(chan receivedRequest, 1)
32+
local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+
body, _ := io.ReadAll(r.Body)
34+
received <- receivedRequest{body: body, contentType: r.Header.Get("Content-Type"), contentLength: r.ContentLength}
35+
w.WriteHeader(http.StatusOK)
36+
}))
37+
t.Cleanup(local.Close)
38+
39+
target, err := url.Parse(local.URL)
40+
require.NoError(t, err)
41+
p := New(&Config{URL: target, NoHealthcheck: true}, nil, newRecordingRenderer())
42+
43+
p.processAttempt(websocket.IncomingMessage{Attempt: &websocket.Attempt{
44+
Body: websocket.AttemptBody{Path: "/webhooks", EventID: "evt_test", AttemptId: "evt_test", Request: request},
45+
}})
46+
47+
select {
48+
case r := <-received:
49+
return r
50+
case <-time.After(5 * time.Second):
51+
t.Fatal("local server never received the request")
52+
return receivedRequest{}
53+
}
54+
}
55+
56+
func headersJSON(t *testing.T, headers map[string]string) json.RawMessage {
57+
t.Helper()
58+
raw, err := json.Marshal(headers)
59+
require.NoError(t, err)
60+
return raw
61+
}
62+
63+
// Every byte value, so any UTF-8 round trip along the way would show.
64+
func allBytes() []byte {
65+
b := make([]byte, 256)
66+
for i := range b {
67+
b[i] = byte(i)
68+
}
69+
return b
70+
}
71+
72+
func TestProcessAttemptForwardsBinaryBodyByteExact(t *testing.T) {
73+
body := allBytes()
74+
75+
got := forwardAttempt(t, websocket.AttemptRequest{
76+
Method: http.MethodPost,
77+
BodyFormat: websocket.BodyFormatBinary,
78+
DataBase64: base64.StdEncoding.EncodeToString(body),
79+
// A stale Content-Length must not win over the decoded body's length.
80+
Headers: headersJSON(t, map[string]string{"content-type": "application/octet-stream", "content-length": "1"}),
81+
})
82+
83+
assert.Equal(t, body, got.body)
84+
assert.Equal(t, int64(len(body)), got.contentLength)
85+
assert.Equal(t, "application/octet-stream", got.contentType)
86+
}
87+
88+
func TestProcessAttemptForwardsBinaryMultipartUnparsed(t *testing.T) {
89+
contentType := "multipart/form-data; boundary=hookdeck-boundary"
90+
var body bytes.Buffer
91+
body.WriteString("--hookdeck-boundary\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nvalue\r\n")
92+
body.WriteString("--hookdeck-boundary\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f.bin\"\r\nContent-Type: application/octet-stream\r\n\r\n")
93+
body.Write(allBytes())
94+
body.WriteString("\r\n--hookdeck-boundary--\r\n")
95+
96+
got := forwardAttempt(t, websocket.AttemptRequest{
97+
Method: http.MethodPost,
98+
BodyFormat: websocket.BodyFormatBinary,
99+
DataBase64: base64.StdEncoding.EncodeToString(body.Bytes()),
100+
Headers: headersJSON(t, map[string]string{"content-type": contentType}),
101+
})
102+
103+
assert.Equal(t, body.Bytes(), got.body, "boundary and file part bytes must match the original")
104+
assert.Equal(t, contentType, got.contentType, "the boundary travels in the original Content-Type")
105+
}
106+
107+
func TestProcessAttemptForwardsTextBodyFromDataString(t *testing.T) {
108+
// The shape every server sends today, and the only one older servers send.
109+
got := forwardAttempt(t, websocket.AttemptRequest{
110+
Method: http.MethodPost,
111+
DataString: `{"hello":"wörld"}`,
112+
Headers: headersJSON(t, map[string]string{"content-type": "application/json"}),
113+
})
114+
115+
assert.Equal(t, `{"hello":"wörld"}`, string(got.body))
116+
assert.Equal(t, int64(len(`{"hello":"wörld"}`)), got.contentLength)
117+
}
118+
119+
func TestAttemptRequestDecodesFromTheServerWireFormat(t *testing.T) {
120+
var msg websocket.IncomingMessage
121+
require.NoError(t, json.Unmarshal([]byte(`{"event":"attempt","body":{"cli_path":"/","request":{"method":"POST","headers":{},"body_format":"binary","data_base64":"AP+A"}}}`), &msg))
122+
require.NotNil(t, msg.Attempt)
123+
124+
body, err := msg.Attempt.Body.Request.Body()
125+
require.NoError(t, err)
126+
assert.Equal(t, []byte{0x00, 0xff, 0x80}, body)
127+
assert.True(t, msg.Attempt.Body.Request.IsBinary())
128+
}
129+
130+
func TestAttemptRequestRejectsInvalidBase64(t *testing.T) {
131+
_, err := websocket.AttemptRequest{BodyFormat: websocket.BodyFormatBinary, DataBase64: "not base64!"}.Body()
132+
assert.Error(t, err)
133+
}

‎pkg/listen/proxy/proxy.go‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package proxy
22

33
import (
4+
"bytes"
45
"context"
56
"crypto/tls"
67
"encoding/json"
@@ -13,7 +14,6 @@ import (
1314
"os"
1415
"os/signal"
1516
"strconv"
16-
"strings"
1717
"sync"
1818
"sync/atomic"
1919
"syscall"
@@ -440,8 +440,27 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) {
440440
req.Header.Set(key, unquoted_value)
441441
}
442442

443-
req.Body = ioutil.NopCloser(strings.NewReader(webhookEvent.Body.Request.DataString))
444-
req.ContentLength = int64(len(webhookEvent.Body.Request.DataString))
443+
// Binary bodies (data_base64) are forwarded as the original bytes, with the
444+
// original Content-Type (multipart boundary included) already set above.
445+
// net/http ignores Content-Length in req.Header and uses ContentLength.
446+
body, err := webhookEvent.Body.Request.Body()
447+
if err != nil {
448+
p.renderer.OnEventError(eventID, webhookEvent, fmt.Errorf("decoding binary request body: %w", err), time.Now())
449+
// Fail the attempt now rather than leave Hookdeck waiting for its timeout.
450+
if wsClient := p.currentWebSocketClient(); wsClient != nil {
451+
wsClient.SendMessage(&websocket.OutgoingMessage{
452+
ErrorAttemptResponse: &websocket.ErrorAttemptResponse{
453+
Event: "attempt_response",
454+
Body: websocket.ErrorAttemptBody{
455+
AttemptId: webhookEvent.Body.AttemptId,
456+
Error: true,
457+
},
458+
}})
459+
}
460+
return
461+
}
462+
req.Body = ioutil.NopCloser(bytes.NewReader(body))
463+
req.ContentLength = int64(len(body))
445464

446465
// For interactive mode: start 100ms timer and HTTP request concurrently
447466
requestStartTime := time.Now()

‎pkg/listen/tui/model.go‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,8 +366,11 @@ func (m *Model) buildEventDetailsContent(event *EventInfo) (string, requestCopyC
366366
requestCopy.headers = strings.TrimSuffix(requestCopy.headers, "\n")
367367
content.WriteString("\n")
368368

369-
// Request body
370-
if event.Data.Body.Request.DataString != "" {
369+
// Request body. Binary bodies are summarised rather than printed: they
370+
// are not text, and the copied request would not reproduce them.
371+
if event.Data.Body.Request.IsBinary() {
372+
content.WriteString(faintStyle.Render(binaryBodySummary(event.Data.Body.Request)) + "\n")
373+
} else if event.Data.Body.Request.DataString != "" {
371374
// Try to pretty print JSON
372375
requestCopy.body = m.prettyPrintJSON(event.Data.Body.Request.DataString)
373376
content.WriteString(requestCopy.body + "\n")
@@ -438,6 +441,16 @@ func (c *requestCopyContent) buildRequest() {
438441
c.request = strings.Join(parts, "\n\n")
439442
}
440443

444+
// binaryBodySummary describes a binary request body by size, since its bytes
445+
// cannot be shown as text.
446+
func binaryBodySummary(req websocket.AttemptRequest) string {
447+
body, err := req.Body()
448+
if err != nil {
449+
return "(binary body could not be decoded)"
450+
}
451+
return fmt.Sprintf("(binary body, %d bytes)", len(body))
452+
}
453+
441454
// prettyPrintJSON attempts to pretty print JSON, returns original if not valid JSON.
442455
// It uses json.Indent so object key order is preserved exactly as received rather
443456
// than being sorted (which json.Marshal would do).

‎pkg/websocket/attempt_messages.go‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,47 @@
11
package websocket
22

33
import (
4+
"encoding/base64"
45
"encoding/json"
56
)
67

8+
// CapabilitiesHeader advertises, on every websocket connect, which attempt
9+
// formats this CLI understands. The server only sends a binary body
10+
// (data_base64) to a session that advertised CapabilityBinaryBody; without it,
11+
// binary events fail on the server instead of reaching an older CLI that would
12+
// forward an empty body.
13+
const CapabilitiesHeader = "X-Hookdeck-CLI-Capabilities"
14+
15+
// CapabilityBinaryBody means the CLI forwards request.data_base64 as raw bytes.
16+
const CapabilityBinaryBody = "binary"
17+
18+
// BodyFormatBinary marks an attempt whose body is carried in DataBase64.
19+
const BodyFormatBinary = "binary"
20+
721
type AttemptRequest struct {
822
Method string `json:"method"`
923
Timeout int64 `json:"timeout"`
1024
DataString string `json:"data_string"`
25+
BodyFormat string `json:"body_format,omitempty"`
26+
DataBase64 string `json:"data_base64,omitempty"`
1127
Headers json.RawMessage `json:"headers"`
1228
}
1329

30+
// IsBinary reports whether the body travels as base64 rather than as DataString.
31+
func (r AttemptRequest) IsBinary() bool {
32+
return r.BodyFormat == BodyFormatBinary || r.DataBase64 != ""
33+
}
34+
35+
// Body returns the exact bytes to forward to the local server. Binary bodies
36+
// are decoded from DataBase64; text bodies are DataString as sent, which keeps
37+
// working against servers that predate data_base64.
38+
func (r AttemptRequest) Body() ([]byte, error) {
39+
if r.IsBinary() {
40+
return base64.StdEncoding.DecodeString(r.DataBase64)
41+
}
42+
return []byte(r.DataString), nil
43+
}
44+
1445
type AttemptBody struct {
1546
Path string `json:"cli_path"`
1647
EventID string `json:"event_id"`

‎pkg/websocket/client.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@ func (c *Client) connect(ctx context.Context) error {
303303
header.Set("Accept-Encoding", "identity")
304304
header.Set("User-Agent", useragent.GetEncodedUserAgent())
305305
header.Set("X-Hookdeck-Client-User-Agent", useragent.GetEncodedHookdeckUserAgent())
306+
header.Set(CapabilitiesHeader, CapabilityBinaryBody)
306307
header.Set("Websocket-Id", c.WebSocketID)
307308
header.Set("X-Team-Id", c.TeamID)
308309
header.Set("Authorization", "Basic "+basicAuth(c.CLIKey, ""))

‎pkg/websocket/client_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ func TestConnectSendsSessionRecreationHeaders(t *testing.T) {
7272
if got := captured.Get("Websocket-Id"); got != "cses_test" {
7373
t.Errorf("Websocket-Id = %q, want %q", got, "cses_test")
7474
}
75+
if got := captured.Get(CapabilitiesHeader); got != CapabilityBinaryBody {
76+
t.Errorf("%s = %q, want %q", CapabilitiesHeader, got, CapabilityBinaryBody)
77+
}
7578
if got := captured.Get("X-Webhook-Ids"); got != "web_abc,web_def" {
7679
t.Errorf("X-Webhook-Ids = %q, want %q", got, "web_abc,web_def")
7780
}

0 commit comments

Comments
 (0)