Skip to content

Commit e3509eb

Browse files
committed
Response: Representation construct for HTTP responses
1 parent 943862b commit e3509eb

File tree

3 files changed

+378
-1
lines changed

3 files changed

+378
-1
lines changed

.github/workflows/golangci-lint.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
golangci:
1414
strategy:
1515
matrix:
16-
go: [ stable ]
16+
go: [ '1.21.13' ]
1717
os: [ ubuntu-latest, macos-latest, windows-latest ]
1818
name: lint
1919
runs-on: ${{ matrix.os }}

internal/response/response.go

+271
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
package response
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net/textproto"
11+
"sort"
12+
"strconv"
13+
"strings"
14+
"time"
15+
)
16+
17+
var (
18+
// ErrInvalidStatusLine is returned when the status line is invalid.
19+
ErrInvalidStatusLine = errors.New("invalid status line")
20+
// ErrInvalidStatusCode is returned when the status code is invalid.
21+
ErrInvalidStatusCode = errors.New("invalid status code")
22+
// ErrInvalidHeaders is returned when the headers are invalid.
23+
ErrInvalidHeaders = errors.New("invalid headers")
24+
// ErrUnreadableBody is returned when the body is unreadable.
25+
ErrUnreadableBody = errors.New("unreadable body")
26+
)
27+
28+
// Response is a representation of an HTTP response, similar
29+
// to the equivalent [request.Request] and used here and there for scans.
30+
type Response struct {
31+
Proto string
32+
Code int
33+
Status string
34+
Headers map[string][]string
35+
Body []byte
36+
Time time.Duration
37+
}
38+
39+
// Location returns the Location header value.
40+
// It concatenates multiple values with a space.
41+
func (r Response) Location() string {
42+
return strings.Join(r.Headers["Location"], " ")
43+
}
44+
45+
// Bytes returns the response as a byte slice.
46+
func (r Response) Bytes() []byte {
47+
if r.IsEmpty() {
48+
return []byte{}
49+
}
50+
51+
var ret string
52+
53+
ret += r.Proto + " " + strconv.Itoa(r.Code) + " " + r.Status + "\r\n"
54+
55+
keys := make([]string, 0, len(r.Headers))
56+
for key := range r.Headers {
57+
keys = append(keys, key)
58+
}
59+
sort.Strings(keys)
60+
61+
for _, k := range keys {
62+
ret += k + ": " + strings.Join(r.Headers[k], ", ") + "\r\n"
63+
}
64+
65+
ret += "\r\n"
66+
ret += string(r.Body)
67+
68+
return []byte(ret)
69+
}
70+
71+
// EscapedBytes returns the response as a byte slice, with the body
72+
// escaped (i.e. JSON encoded).
73+
func (r Response) EscapedBytes() []byte {
74+
raw := string(r.Bytes())
75+
76+
escaped, err := json.Marshal(raw)
77+
if err != nil {
78+
// Open questions:
79+
// - Should we log errors? (Maybe on verbose)
80+
return nil
81+
}
82+
83+
return escaped
84+
}
85+
86+
// BytesWithoutHeaders returns the response as a byte slice, without headers.
87+
func (r Response) BytesWithoutHeaders() []byte {
88+
if r.IsEmpty() {
89+
return []byte{}
90+
}
91+
return []byte(r.Proto + " " + strconv.Itoa(r.Code) + " " + r.Status + "\r\n" + string(r.Body))
92+
}
93+
94+
// BytesOnlyHeaders returns the response headers as a byte slice.
95+
func (r Response) BytesOnlyHeaders() []byte {
96+
if r.IsEmpty() {
97+
return []byte{}
98+
}
99+
100+
var ret string
101+
for k, values := range r.Headers {
102+
ret += k + ": " + strings.Join(values, ", ") + "\r\n"
103+
}
104+
return []byte(ret)
105+
}
106+
107+
// ContentLength returns the length of the response.
108+
// It tries to parse the Content-Length header.
109+
// If the response is empty, it returns 0.
110+
func (r Response) ContentLength() int {
111+
const empty = 0
112+
if r.IsEmpty() {
113+
return empty
114+
}
115+
116+
h, ok := r.Headers["Content-Length"]
117+
if !ok {
118+
return empty
119+
}
120+
121+
if length, err := strconv.ParseInt(strings.Join(h, ", "), 10, 64); err == nil {
122+
return int(length)
123+
}
124+
125+
return empty
126+
}
127+
128+
// Length returns the length of the response.
129+
// If the response is empty, it returns 0.
130+
// If the Content-Length header is set, it returns its value.
131+
// Otherwise, it returns the length of the body.
132+
func (r Response) Length() int {
133+
if r.IsEmpty() {
134+
return 0
135+
}
136+
137+
if cl := r.ContentLength(); cl > 0 {
138+
return cl
139+
}
140+
141+
return len(r.Body)
142+
}
143+
144+
// ContentType returns the value of the Content-Type header.
145+
// If the response is empty or the header is not set, it returns an empty string.
146+
// It also removes any parameters from the header value.
147+
func (r Response) ContentType() string {
148+
if r.IsEmpty() {
149+
return ""
150+
}
151+
152+
h, ok := r.Headers["Content-Type"]
153+
if !ok || len(h) == 0 {
154+
return ""
155+
}
156+
157+
return strings.Split(h[0], ";")[0]
158+
}
159+
160+
// InferredType returns the inferred type of the response.
161+
// It uses the Content-Type header to determine the type.
162+
// Some types are inferred are:
163+
// - HTML (text/html)
164+
// - CSS (text/css)
165+
// - CSV (text/csv)
166+
// and many more (see [Response.mimeTypes()]).
167+
func (r Response) InferredType() string {
168+
if r.IsEmpty() {
169+
return ""
170+
}
171+
172+
if _, ok := r.mimeTypes()[r.ContentType()]; !ok {
173+
return ""
174+
}
175+
176+
return r.mimeTypes()[r.ContentType()]
177+
}
178+
179+
func (r Response) mimeTypes() map[string]string {
180+
return map[string]string{
181+
"text/html": "HTML",
182+
"text/css": "CSS",
183+
"text/csv": "CSV",
184+
"text/calendar": "ICS",
185+
"image/gif": "GIF",
186+
"image/jpeg": "JPEG",
187+
"image/png": "PNG",
188+
"application/json": "JSON",
189+
"application/x-httpd-php": "PHP",
190+
"application/xml": "XML",
191+
"application/pdf": "PDF",
192+
"application/gzip": "GZIP",
193+
"application/ogg": "OGG",
194+
"audio/mpeg": "MP3",
195+
"audio/ogg": "OGG",
196+
"video/mp4": "MP4",
197+
"video/mpeg": "MPEG",
198+
"video/ogg": "OGG",
199+
"font/ttf": "TTF",
200+
"font/woff": "WOFF",
201+
"font/woff2": "WOFF",
202+
}
203+
}
204+
205+
// IsEmpty returns whether the response is empty.
206+
func (r Response) IsEmpty() bool {
207+
return r.Proto == "" && r.Code == 0 && r.Status == "" && r.Headers == nil && r.Body == nil
208+
}
209+
210+
// FromJSON returns a response from a JSON byte slice.
211+
func FromJSON(data []byte) (Response, error) {
212+
var res Response
213+
err := json.Unmarshal(data, &res)
214+
return res, err
215+
}
216+
217+
// ToJSON returns the response as a JSON byte slice.
218+
func (r Response) ToJSON() ([]byte, error) {
219+
return json.Marshal(&r)
220+
}
221+
222+
// ParseResponse parses a byte slice into a response.
223+
func ParseResponse(b []byte) (*Response, error) {
224+
bytesReader := bytes.NewReader(b)
225+
tp := textproto.NewReader(bufio.NewReader(bytesReader))
226+
227+
// Read the status line
228+
statusLine, err := tp.ReadLine()
229+
if err != nil {
230+
return nil, fmt.Errorf("%w: %s", ErrInvalidStatusLine, err)
231+
}
232+
233+
// Parse status line
234+
parts := strings.SplitN(statusLine, " ", 3)
235+
if len(parts) < 3 {
236+
return nil, fmt.Errorf("%w: %s", ErrInvalidStatusLine, err)
237+
}
238+
239+
proto := parts[0]
240+
code, err := strconv.Atoi(parts[1])
241+
if err != nil {
242+
return nil, fmt.Errorf("%w: %s", ErrInvalidStatusCode, err)
243+
}
244+
status := parts[2]
245+
246+
// Read headers
247+
headers, err := tp.ReadMIMEHeader()
248+
if err != nil {
249+
return nil, fmt.Errorf("%w: %s", ErrInvalidHeaders, err)
250+
}
251+
252+
// Convert MIMEHeader to a map[string][]string
253+
headerMap := map[string][]string(headers)
254+
255+
// Read body
256+
var body []byte
257+
if tp.R.Buffered() > 0 {
258+
body, err = io.ReadAll(tp.R)
259+
if err != nil {
260+
return nil, fmt.Errorf("%w: %s", ErrUnreadableBody, err)
261+
}
262+
}
263+
264+
return &Response{
265+
Proto: proto,
266+
Code: code,
267+
Status: status,
268+
Headers: headerMap,
269+
Body: body,
270+
}, nil
271+
}

internal/response/response_test.go

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package response_test
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/bountysecurity/gbounty/internal/response"
11+
)
12+
13+
func TestResponse_JSON(t *testing.T) {
14+
t.Parallel()
15+
16+
res := response.Response{
17+
Proto: "HTTP/1.1",
18+
Code: 404,
19+
Status: "Not Found",
20+
Headers: map[string][]string{
21+
"Server": {"nginx/1.19.0"},
22+
"Date": {"Sun, 07 Feb 2021 23:44:49 GMT"},
23+
"Content-Type": {"text/html; charset=utf-8"},
24+
"Connection": {"close"},
25+
"Content-Length": {"150"},
26+
},
27+
Body: []byte(`<html>
28+
<head><title>404 Not Found</title></head>
29+
<body>
30+
<center><h1>404 Not Found</h1></center>
31+
<hr><center>nginx/1.19.0</center>
32+
</body>
33+
</html>
34+
`),
35+
}
36+
37+
data, err := res.ToJSON()
38+
require.NoError(t, err)
39+
40+
res2, err := response.FromJSON(data)
41+
require.NoError(t, err)
42+
43+
assert.True(t, reflect.DeepEqual(res, res2))
44+
}
45+
46+
func TestParseResponse(t *testing.T) {
47+
t.Parallel()
48+
49+
t.Run("valid response", func(t *testing.T) {
50+
t.Parallel()
51+
52+
res, err := response.ParseResponse([]byte(`HTTP/1.1 200 OK
53+
Content-Type: text/plain
54+
Content-Length: 13
55+
56+
Hello, world!`))
57+
58+
require.NoError(t, err)
59+
assert.Equal(t, &response.Response{
60+
Proto: "HTTP/1.1",
61+
Code: 200,
62+
Status: "OK",
63+
Headers: map[string][]string{
64+
"Content-Type": {"text/plain"},
65+
"Content-Length": {"13"},
66+
},
67+
Body: []byte("Hello, world!"),
68+
}, res)
69+
})
70+
71+
t.Run("invalid status line", func(t *testing.T) {
72+
t.Parallel()
73+
74+
res, err := response.ParseResponse([]byte(`HTTP/1.1 200
75+
Content-Type: text/plain
76+
Content-Length: 13
77+
78+
Hello, world!`))
79+
80+
assert.Nil(t, res)
81+
assert.Error(t, err)
82+
assert.ErrorIs(t, err, response.ErrInvalidStatusLine)
83+
})
84+
85+
t.Run("no body", func(t *testing.T) {
86+
t.Parallel()
87+
88+
res, err := response.ParseResponse([]byte(`HTTP/1.1 204 No Content
89+
Content-Type: text/plain
90+
Content-Length: 0
91+
92+
`))
93+
94+
require.NoError(t, err)
95+
assert.Equal(t, &response.Response{
96+
Proto: "HTTP/1.1",
97+
Code: 204,
98+
Status: "No Content",
99+
Headers: map[string][]string{
100+
"Content-Type": {"text/plain"},
101+
"Content-Length": {"0"},
102+
},
103+
Body: nil,
104+
}, res)
105+
})
106+
}

0 commit comments

Comments
 (0)