Browse Source

Optionally decode generic HTTP payloads

tags/v0.4.6
J.P. Neverwas 2 years ago
parent
commit
2e430eace4

+ 4
- 1
examples/irccat.json View File

@@ -8,7 +8,10 @@
8 8
     "tls_key": "",
9 9
     "tls_cert": "",
10 10
     "listeners": {
11
-      "generic": true,
11
+      "generic": {
12
+        "secret": "",
13
+        "strict": false
14
+      },
12 15
       "grafana": "#channel",
13 16
       "github": {
14 17
 	"secret": "my_secret",

+ 83
- 5
httplistener/generic.go View File

@@ -2,17 +2,78 @@ package httplistener
2 2
 
3 3
 import (
4 4
 	"bytes"
5
+	"encoding/base64"
5 6
 	"fmt"
7
+	"io"
8
+	"net/http"
9
+	"strings"
10
+
6 11
 	"github.com/irccloud/irccat/dispatcher"
7 12
 	"github.com/spf13/viper"
8
-	"net/http"
9 13
 )
10 14
 
15
+func handleUrlEncodedPostForm(request *http.Request) string {
16
+	request.ParseForm()
17
+	parts := []string{}
18
+	for key, val := range request.PostForm {
19
+		if key != "" {
20
+			parts = append(parts, key)
21
+		}
22
+		for _, v := range val {
23
+			if v != "" {
24
+				parts = append(parts, v)
25
+			}
26
+		}
27
+	}
28
+	return strings.Join(parts, " ")
29
+}
30
+
31
+// handleMixed blindly concatenates all bodies in a mixed message.
32
+//
33
+// Headers are discarded. Binary data, including illegal IRC chars, are
34
+// retained as is. Quoted-printable and base64 encodings are recognized.
35
+func handleMixed(request *http.Request) (string, error) {
36
+	mr, err := request.MultipartReader()
37
+	if err != nil {
38
+		return "", err
39
+	}
40
+	parts := []string{}
41
+	for {
42
+		p, err := mr.NextPart()
43
+		if err == io.EOF {
44
+			break
45
+		}
46
+		if err != nil {
47
+			return "", err
48
+		}
49
+		b, err := io.ReadAll(p)
50
+		if err != nil {
51
+			return "", err
52
+		}
53
+		if len(b) != 0 {
54
+			if p.Header.Get("content-transfer-encoding") == "base64" {
55
+				encoder := base64.StdEncoding
56
+				if decoded, err := encoder.DecodeString(string(b)); err != nil {
57
+					return "", err
58
+				} else if len(decoded) > 0 {
59
+					b = decoded
60
+				}
61
+			}
62
+			parts = append(parts, string(b))
63
+		}
64
+	}
65
+	return strings.Join(parts, " "), nil
66
+}
67
+
68
+var genericSender = dispatcher.Send
69
+
11 70
 // Examples of using curl to post to /send.
12 71
 //
13 72
 // echo "Hello, world" | curl -d @- http://irccat.example.com/send
14 73
 // echo "#test,@alice Hello, world" | curl -d @- http://irccat.example.com/send
15 74
 //
75
+// See httplistener/generic_tests.go for info on strict mode, which behaves
76
+// differently and is enabled by config option http.listeners.generic.strict
16 77
 func (hl *HTTPListener) genericHandler(w http.ResponseWriter, request *http.Request) {
17 78
 	if request.Method != "POST" {
18 79
 		http.NotFound(w, request)
@@ -30,14 +91,31 @@ func (hl *HTTPListener) genericHandler(w http.ResponseWriter, request *http.Requ
30 91
 			return
31 92
 		}
32 93
 	}
94
+	var message string
95
+	strict := viper.GetBool("http.listeners.generic.strict")
96
+	if strict {
97
+		contentType := request.Header.Get("Content-Type")
98
+		if strings.HasPrefix(contentType, "application/x-www-form-urlencoded") {
99
+			message = handleUrlEncodedPostForm(request)
100
+		} else if strings.HasPrefix(contentType, "multipart/") {
101
+			if msg, err := handleMixed(request); err == nil {
102
+				message = msg // otherwise message is "", which triggers 400
103
+			}
104
+		}
105
+	}
33 106
 
34
-	body := new(bytes.Buffer)
35
-	body.ReadFrom(request.Body)
36
-	message := body.String()
107
+	if message == "" {
108
+		body := new(bytes.Buffer)
109
+		body.ReadFrom(request.Body)
110
+		message = body.String()
111
+	}
37 112
 
38 113
 	if message == "" {
114
+		if strict {
115
+			http.Error(w, "Bad Request", http.StatusBadRequest)
116
+		}
39 117
 		log.Warningf("%s - No message body in POST request", request.RemoteAddr)
40 118
 		return
41 119
 	}
42
-	dispatcher.Send(hl.irc, message, log, request.RemoteAddr)
120
+	genericSender(hl.irc, message, log, request.RemoteAddr)
43 121
 }

+ 222
- 0
httplistener/generic_test.go View File

@@ -0,0 +1,222 @@
1
+// Some clients refuse to send payloads that don't match the Content-Type. In
2
+// other words, trying to send "%BOLD hw" or "\x02 hw", as written, wouldn't
3
+// be allowed for Content-Type "application/x-www-form-urlencoded" without
4
+// first being encoded. But irccat by default doesn't do any decoding. Thus,
5
+// when faced with one of these problematic clients, specify config option
6
+// http.listeners.generic.strict to get the behavior shown here:
7
+//
8
+// mismatch
9
+//
10
+//  $ echo "%BOLDhw" | curl -d @- http://localhost/send
11
+//  400 Bad Request
12
+//
13
+// urlencoded
14
+//
15
+//  $ echo "%BOLDhw" | curl --data-urlencode  @- http://localhost/send
16
+//  200 OK
17
+//
18
+// urlencoded non-printable
19
+//
20
+//  $ printf "\x02hw" | curl --data-urlencode  @- http://localhost/send
21
+//  200 OK
22
+//
23
+// octetstream
24
+//
25
+//  $ echo "%BOLDhw" | curl --data-binary @- \
26
+//    -H 'Content-Type: application/octet-stream' http://localhost/send
27
+//  200 OK
28
+//
29
+// multipart quoted-printable
30
+//
31
+//  $ echo '%BOLDhw' | curl -F 'foo=@-;encoder=quoted-printable' \
32
+//    http://localhost/send
33
+//  200 OK
34
+//
35
+// multipart 8bit
36
+//
37
+//  $ echo '%BOLDhw' | curl -F 'foo=@-;encoder=8bit' http://localhost/send
38
+//  200 OK
39
+//
40
+// multipart base64
41
+//
42
+//  $ echo '%BOLDhw' | curl -F 'foo=@-;encoder=base64' http://localhost/send
43
+//  200 OK
44
+//
45
+// The gist is that when strict mode is active, popular encodings will work
46
+// while mismatches won't, even though they may still appear to at times.
47
+//
48
+package httplistener
49
+
50
+import (
51
+	"context"
52
+	"io"
53
+	"net"
54
+	"net/http"
55
+	"os"
56
+	"path"
57
+	"strings"
58
+	"testing"
59
+	"time"
60
+
61
+	"github.com/juju/loggo"
62
+	"github.com/spf13/viper"
63
+	irc "github.com/thoj/go-ircevent"
64
+)
65
+
66
+var genericTestListen = "localhost:18045"
67
+
68
+func genericTestStartHTTPServer(t *testing.T, endpoint string) {
69
+	hl := HTTPListener{
70
+		http: http.Server{Addr: genericTestListen},
71
+	}
72
+
73
+	http.HandleFunc(endpoint, hl.genericHandler)
74
+	go hl.http.ListenAndServe()
75
+	t.Cleanup(func() {hl.http.Shutdown(context.Background());})
76
+	time.Sleep(time.Millisecond)
77
+}
78
+
79
+func genericTestSendOutput(message []byte) ([]byte, error) {
80
+	conn, err := net.Dial("tcp", genericTestListen)
81
+	if err != nil {
82
+		return nil, err
83
+	}
84
+	_, err = conn.Write(message)
85
+	if err != nil {
86
+		return nil, err
87
+	}
88
+	b := make([]byte, 1024)
89
+	_ , err = io.ReadAtLeast(conn, b, 24)
90
+	if err != nil {
91
+		return nil, err
92
+	}
93
+	return b, nil
94
+}
95
+
96
+func runGeneric(t *testing.T, reqFileName string) (string, string) {
97
+	var message string
98
+	origSender := genericSender
99
+	genericSender = func(_ *irc.Connection, m string, _ loggo.Logger, _ string) {
100
+		message = m
101
+	}
102
+	t.Cleanup(func(){genericSender = origSender})
103
+
104
+	src, err := os.ReadFile(path.Join("testdata", reqFileName))
105
+	if err != nil {
106
+		t.Fatal(err)
107
+	}
108
+	resp, err := genericTestSendOutput(src)
109
+	if err != nil {
110
+		t.Fatal(err)
111
+	}
112
+	return message, string(resp)
113
+}
114
+
115
+// Non-strict
116
+
117
+func testGenericBaseline(t *testing.T) {
118
+	message, resp := runGeneric(t, "mismatch")
119
+	if message != "%BOLDhw" {
120
+		t.Fatalf("Expected %q, got: %q", "%BOLDhw", message)
121
+	}
122
+	if !strings.HasPrefix(string(resp), "HTTP/1.1 200 OK") {
123
+		t.Fatalf("Unexpected message: %s", resp)
124
+	}
125
+}
126
+
127
+// Strict
128
+
129
+func testGenericStrict(t *testing.T) {
130
+	message, resp := runGeneric(t, "mismatch")
131
+	if message != "" {
132
+		t.Fatalf("Expected %q, got: %q", "", message)
133
+	}
134
+	if !strings.HasPrefix(string(resp), "HTTP/1.1 400 Bad Request") {
135
+		t.Fatalf("Unexpected message: %s", resp)
136
+	}
137
+}
138
+
139
+func testURLEncoded(t *testing.T) {
140
+	message, resp := runGeneric(t, "urlencoded")
141
+	if message != "%BOLDhw\n" { // Note the linefeed
142
+		t.Fatalf("Expected %q, got: %q", "%BOLDhw\n", message)
143
+	}
144
+	if !strings.HasPrefix(string(resp), "HTTP/1.1 200 OK") {
145
+		t.Fatalf("Unexpected message: %s", resp)
146
+	}
147
+}
148
+
149
+func testURLEncodedNonPrintable(t *testing.T) {
150
+	message, resp := runGeneric(t, "urlencoded_npc")
151
+	if message != "\x02hw" {
152
+		t.Fatalf("Expected %q, got: %q", "\x02hw", message)
153
+	}
154
+	if !strings.HasPrefix(string(resp), "HTTP/1.1 200 OK") {
155
+		t.Fatalf("Unexpected message: %s", resp)
156
+	}
157
+}
158
+
159
+func testOctetStream(t *testing.T) {
160
+	message, resp := runGeneric(t, "octetstream")
161
+	if message != "%BOLDhw\n" {
162
+		t.Fatalf("Expected %q, got: %q", "%BOLDhw\n", message)
163
+	}
164
+	if !strings.HasPrefix(string(resp), "HTTP/1.1 200 OK") {
165
+		t.Fatalf("Unexpected message: %s", resp)
166
+	}
167
+}
168
+
169
+func testMultipartQP(t *testing.T) {
170
+	message, resp := runGeneric(t, "multipart_qp")
171
+	if message != "%BOLDhw\n" {
172
+		t.Fatalf("Expected %q, got: %q", "%BOLDhw\n", message)
173
+	}
174
+	if !strings.HasPrefix(string(resp), "HTTP/1.1 200 OK") {
175
+		t.Fatalf("Unexpected message: %s", resp)
176
+	}
177
+}
178
+
179
+func testMultipart8bit(t *testing.T) {
180
+	message, resp := runGeneric(t, "multipart_8bit")
181
+	if message != "%BOLDhw\n" {
182
+		t.Fatalf("Expected %q, got: %q", "%BOLDhw\n", message)
183
+	}
184
+	if !strings.HasPrefix(string(resp), "HTTP/1.1 200 OK") {
185
+		t.Fatalf("Unexpected message: %s", resp)
186
+	}
187
+}
188
+
189
+func testMultipartBase64(t *testing.T) {
190
+	message, resp := runGeneric(t, "multipart_base64")
191
+	if message != "%BOLDhw\n" {
192
+		t.Fatalf("Expected %q, got: %q", "%BOLDhw\n", message)
193
+	}
194
+	if !strings.HasPrefix(string(resp), "HTTP/1.1 200 OK") {
195
+		t.Fatalf("Unexpected message: %s", resp)
196
+	}
197
+}
198
+
199
+func TestAll(t *testing.T) {
200
+	writer, err := loggo.RemoveWriter("default")
201
+	if err != nil {
202
+		t.Error(err)
203
+	}
204
+	t.Cleanup(func() {loggo.DefaultContext().AddWriter("default", writer)})
205
+	genericTestStartHTTPServer(t, "/send")
206
+
207
+	t.Run("Baseline", testGenericBaseline)
208
+
209
+	// Turn on strict for the rest of these
210
+	viper.Set("http.listeners.generic.strict", true)
211
+
212
+	t.Run("Strict Mismatch", testGenericStrict)
213
+	t.Run("Strict URL Encoded", testURLEncoded)
214
+	t.Run("Strict URL Encoded Non-printable", testURLEncodedNonPrintable)
215
+	t.Run("Strict Octet Stream", testOctetStream)
216
+	t.Run("Strict Multipart Quoted Printable", testMultipartQP)
217
+	t.Run("Strict Multipart 8bit", testMultipart8bit)
218
+	t.Run("Strict Multipart Base 64", testMultipartBase64)
219
+
220
+	// Restore to zero value
221
+	viper.Set("http.listeners.generic.strict", false)
222
+}

+ 8
- 0
httplistener/testdata/mismatch View File

@@ -0,0 +1,8 @@
1
+POST /send HTTP/1.1
2
+Host: localhost:18045
3
+User-Agent: curl/7.76.1
4
+Accept: */*
5
+Content-Length: 7
6
+Content-Type: application/x-www-form-urlencoded
7
+
8
+%BOLDhw

+ 14
- 0
httplistener/testdata/multipart_8bit View File

@@ -0,0 +1,14 @@
1
+POST /send HTTP/1.1
2
+Host: localhost:18045
3
+User-Agent: curl/7.76.1
4
+Accept: */*
5
+Content-Length: 193
6
+Content-Type: multipart/form-data; boundary=------------------------3e056db8b78cdf1d
7
+
8
+--------------------------3e056db8b78cdf1d
9
+Content-Disposition: form-data; name="foo"; filename="-"
10
+Content-Transfer-Encoding: 8bit
11
+
12
+%BOLDhw
13
+
14
+--------------------------3e056db8b78cdf1d--

+ 13
- 0
httplistener/testdata/multipart_base64 View File

@@ -0,0 +1,13 @@
1
+POST /send HTTP/1.1
2
+Host: localhost:18045
3
+User-Agent: curl/7.76.1
4
+Accept: */*
5
+Content-Length: 199
6
+Content-Type: multipart/form-data; boundary=------------------------53b3d5d529be2ef6
7
+
8
+--------------------------53b3d5d529be2ef6
9
+Content-Disposition: form-data; name="foo"; filename="-"
10
+Content-Transfer-Encoding: base64
11
+
12
+JUJPTERodwo=
13
+--------------------------53b3d5d529be2ef6--

+ 13
- 0
httplistener/testdata/multipart_qp View File

@@ -0,0 +1,13 @@
1
+POST /send HTTP/1.1
2
+Host: localhost:18045
3
+User-Agent: curl/7.76.1
4
+Accept: */*
5
+Content-Length: 207
6
+Content-Type: multipart/form-data; boundary=------------------------5cbcc75e58188254
7
+
8
+--------------------------5cbcc75e58188254
9
+Content-Disposition: form-data; name="foo"; filename="-"
10
+Content-Transfer-Encoding: quoted-printable
11
+
12
+%BOLDhw=0A
13
+--------------------------5cbcc75e58188254--

+ 8
- 0
httplistener/testdata/octetstream View File

@@ -0,0 +1,8 @@
1
+POST /send HTTP/1.1
2
+Host: localhost:18045
3
+User-Agent: curl/7.76.1
4
+Accept: */*
5
+Content-Type: application/octet-stream
6
+Content-Length: 8
7
+
8
+%BOLDhw

+ 8
- 0
httplistener/testdata/urlencoded View File

@@ -0,0 +1,8 @@
1
+POST /send HTTP/1.1
2
+Host: localhost:18045
3
+User-Agent: curl/7.76.1
4
+Accept: */*
5
+Content-Length: 12
6
+Content-Type: application/x-www-form-urlencoded
7
+
8
+%25BOLDhw%0A

+ 8
- 0
httplistener/testdata/urlencoded_npc View File

@@ -0,0 +1,8 @@
1
+POST /send HTTP/1.1
2
+Host: localhost:18045
3
+User-Agent: curl/7.76.1
4
+Accept: */*
5
+Content-Length: 5
6
+Content-Type: application/x-www-form-urlencoded
7
+
8
+%02hw

Loading…
Cancel
Save