Skip to content

Commit 943862b

Browse files
committed
Request: Representation construct for HTTP requests
1 parent 9d7029c commit 943862b

File tree

5 files changed

+1019
-1
lines changed

5 files changed

+1019
-1
lines changed

go.mod

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
module github.com/bountysecurity/gbounty
22

3-
go 1.22.5
3+
go 1.21
4+
5+
toolchain go1.21.13
46

57
require (
68
github.com/go-logfmt/logfmt v0.6.0

internal/request/request.go

+376
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,376 @@
1+
package request
2+
3+
import (
4+
"bufio"
5+
"bytes"
6+
"encoding/json"
7+
"encoding/xml"
8+
"errors"
9+
"fmt"
10+
"io"
11+
"mime/multipart"
12+
"net/http"
13+
"net/textproto"
14+
"net/url"
15+
"sort"
16+
"strconv"
17+
"strings"
18+
"time"
19+
20+
"github.com/bountysecurity/gbounty/internal/profile"
21+
internalurl "github.com/bountysecurity/gbounty/kit/url"
22+
)
23+
24+
var (
25+
// ErrInvalidHost is returned when building/parsing a request
26+
// from plain text and the Host line is invalid.
27+
ErrInvalidHost = errors.New("invalid host line")
28+
// ErrInvalidPayload is returned when building/parsing a request
29+
// from plain text and the payload is invalid.
30+
ErrInvalidPayload = errors.New("invalid payload")
31+
)
32+
33+
// Request is a representation of an HTTP request, complementary
34+
// to the standard [http.Request] and used here and there for scans.
35+
type Request struct {
36+
UID string // Used to detect interactions
37+
URL string
38+
Method string
39+
Path string
40+
Proto string
41+
Headers map[string][]string
42+
Body []byte
43+
Timeout time.Duration
44+
RedirectType profile.Redirect
45+
MaxRedirects int
46+
FollowedRedirects int
47+
Modifications map[string]string
48+
}
49+
50+
// IsEmpty returns whether the request is empty.
51+
func (r *Request) IsEmpty() bool {
52+
return r.URL == "" && r.Method == "" && r.Path == "" && r.Proto == "" && r.Headers == nil && r.Body == nil
53+
}
54+
55+
// SetBody sets the request body and updates the Content-Length header
56+
// accordingly.
57+
func (r *Request) SetBody(body []byte) {
58+
r.Body = body
59+
if len(r.Body) > 0 {
60+
r.Headers["Content-Length"] = []string{strconv.Itoa(len(r.Body))}
61+
}
62+
}
63+
64+
// HasJSONBody returns whether the request body is a valid JSON.
65+
func (r *Request) HasJSONBody() bool {
66+
var js map[string]interface{}
67+
return json.Unmarshal(r.Body, &js) == nil
68+
}
69+
70+
// HasXMLBody returns whether the request body is a valid XML.
71+
func (r *Request) HasXMLBody() bool {
72+
if r.HasJSONBody() {
73+
return false
74+
}
75+
76+
if r.HasMultipartBody() {
77+
return false
78+
}
79+
80+
decoder := xml.NewDecoder(bytes.NewReader(r.Body))
81+
82+
for {
83+
err := decoder.Decode(new(interface{}))
84+
if err != nil {
85+
return errors.Is(err, io.EOF)
86+
}
87+
}
88+
}
89+
90+
// HasMultipartBody returns whether the request body is a valid multipart form.
91+
func (r *Request) HasMultipartBody() bool {
92+
form, err := r.MultipartForm()
93+
return err == nil && form != nil
94+
}
95+
96+
// ContentType returns the value of the Content-Type header.
97+
func (r *Request) ContentType() string {
98+
return r.Header("Content-Type")
99+
}
100+
101+
// Cookies returns the cookies (i.e. [*http.Cookie]) from the request headers.
102+
func (r *Request) Cookies() []*http.Cookie {
103+
if r.Headers == nil {
104+
return nil
105+
}
106+
107+
return (&http.Request{Header: http.Header{
108+
"Cookie": r.Headers["Cookie"],
109+
}}).Cookies()
110+
}
111+
112+
// Header returns the value of the given header.
113+
// If the header is not present, an empty string is returned.
114+
// If the header has multiple values, the values are joined with a space.
115+
func (r *Request) Header(header string) string {
116+
if r.Headers == nil {
117+
return ""
118+
}
119+
return strings.Join(r.Headers[header], " ")
120+
}
121+
122+
// HeaderBytes returns the headers section as a byte slice.
123+
func (r *Request) HeaderBytes() []byte {
124+
var ret string
125+
for k, values := range r.Headers {
126+
ret += k + ": " + strings.Join(values, ", ") + "\r\n"
127+
}
128+
return []byte(ret)
129+
}
130+
131+
// MultipartForm returns the request body as a multipart form.
132+
func (r *Request) MultipartForm() (*multipart.Form, error) {
133+
if len(r.Body) == 0 {
134+
return nil, nil
135+
}
136+
137+
reader := textproto.NewReader(bufio.NewReader(bytes.NewBuffer(r.Body)))
138+
139+
line, err := reader.ReadLine()
140+
if err != nil || len(line) < 2 {
141+
return nil, err
142+
}
143+
144+
req := &http.Request{
145+
Header: http.Header{
146+
"Content-Type": {fmt.Sprintf("multipart/form-data; boundary=%s", line[2:])},
147+
},
148+
Body: io.NopCloser(bytes.NewReader(r.Body)),
149+
}
150+
151+
const maxBodySize = int64(10 << 20) // 10MB
152+
if err = req.ParseMultipartForm(maxBodySize); err != nil {
153+
return nil, err
154+
}
155+
156+
return req.MultipartForm, nil
157+
}
158+
159+
// Clone returns a deep copy (e.g. headers' map, and body's byte slice
160+
// are also copied) of the request.
161+
func (r *Request) Clone() Request {
162+
return Request{
163+
UID: r.UID,
164+
URL: r.URL,
165+
Method: r.Method,
166+
Path: r.Path,
167+
Proto: r.Proto,
168+
Headers: copyHeaders(r.Headers),
169+
Body: copyBody(r.Body),
170+
Timeout: r.Timeout,
171+
RedirectType: r.RedirectType,
172+
MaxRedirects: r.MaxRedirects,
173+
Modifications: copyModifications(r.Modifications),
174+
}
175+
}
176+
177+
func copyHeaders(headers map[string][]string) map[string][]string {
178+
result := make(map[string][]string, len(headers))
179+
180+
for key, values := range headers {
181+
copyValues := make([]string, len(values))
182+
copy(copyValues, values)
183+
184+
result[key] = copyValues
185+
}
186+
187+
return result
188+
}
189+
190+
func copyBody(body []byte) []byte {
191+
if body == nil {
192+
return nil
193+
}
194+
195+
result := make([]byte, len(body))
196+
copy(result, body)
197+
return result
198+
}
199+
200+
func copyModifications(modifications map[string]string) map[string]string {
201+
if modifications == nil {
202+
return nil
203+
}
204+
205+
result := make(map[string]string, len(modifications))
206+
for key, value := range modifications {
207+
result[key] = value
208+
}
209+
return result
210+
}
211+
212+
// RequestFromJSON creates a request from a JSON byte slice.
213+
func RequestFromJSON(data []byte) (Request, error) {
214+
var req Request
215+
err := json.Unmarshal(data, &req)
216+
return req, err
217+
}
218+
219+
// ToJSON returns the request as a JSON byte slice, with headers
220+
// on its canonical form (i.e. [textproto.CanonicalMIMEHeaderKey]).
221+
func (r *Request) ToJSON() ([]byte, error) {
222+
return json.Marshal(&r)
223+
}
224+
225+
// Bytes returns the request as a byte slice.
226+
func (r *Request) Bytes() []byte {
227+
ret := r.Method + " " + r.Path + " " + r.Proto + "\n"
228+
229+
keys := make([]string, 0, len(r.Headers))
230+
for key := range r.Headers {
231+
keys = append(keys, key)
232+
}
233+
sort.Strings(keys)
234+
235+
for _, key := range keys {
236+
ret += textproto.CanonicalMIMEHeaderKey(key) + ": " + strings.Join(r.Headers[key], ", ") + "\n"
237+
}
238+
239+
ret += "\n"
240+
ret += string(r.Body)
241+
242+
return []byte(ret)
243+
}
244+
245+
// EscapedBytes returns the request as a byte slice, with the body
246+
// escaped (i.e. JSON encoded).
247+
func (r *Request) EscapedBytes() []byte {
248+
raw := string(r.Bytes())
249+
escaped, err := json.Marshal(raw)
250+
if err != nil {
251+
// Open questions:
252+
// - Should we log errors? (Maybe on verbose)
253+
return nil
254+
}
255+
return escaped
256+
}
257+
258+
// ParseRequest parses a request from a byte slice.
259+
// If a host is given (variadic arg), it is used as the request URL.
260+
func ParseRequest(b []byte, hh ...string) (Request, error) {
261+
var hostStr string
262+
263+
// If there is any given host, then we use it straight away.
264+
if len(hh) > 1 {
265+
panic("ParseRequest: invalid function args: len(hh) > 1")
266+
}
267+
if len(hh) == 1 {
268+
hostStr = hh[0]
269+
}
270+
271+
// If the first line can be interpreted as a URL, we should validate it,
272+
// and if valid, use it as the [Request.URL].
273+
firstNextLine := bytes.Index(b, []byte("\n"))
274+
firstLine := strings.TrimSpace(string(b[:firstNextLine]))
275+
if strings.HasPrefix(firstLine, "http") {
276+
if err := internalurl.Validate(&firstLine); err != nil {
277+
return Request{}, fmt.Errorf("%w - %s", ErrInvalidHost, err)
278+
}
279+
280+
host, err := url.Parse(firstLine)
281+
if err != nil {
282+
return Request{}, fmt.Errorf("%w - %s", ErrInvalidHost, err)
283+
}
284+
285+
hostStr = host.String()
286+
b = b[firstNextLine+len("\n"):]
287+
}
288+
289+
bytesReader := bytes.NewReader(b)
290+
tp := textproto.NewReader(bufio.NewReader(bytesReader))
291+
292+
first, err := tp.ReadLine()
293+
if err != nil {
294+
return Request{}, fmt.Errorf("%w: %s", ErrInvalidPayload, err)
295+
}
296+
297+
method, path, proto, ok := parseRequestLine(first)
298+
if !ok {
299+
return Request{}, fmt.Errorf("%w: %s", ErrInvalidPayload, "wrong format")
300+
}
301+
302+
headers, err := tp.ReadMIMEHeader()
303+
if err != nil && !errors.Is(err, io.EOF) {
304+
return Request{}, fmt.Errorf("%w: %s", ErrInvalidPayload, err)
305+
}
306+
307+
// If [hostStr] remains empty, we should try to get the host from the headers.
308+
if len(hostStr) == 0 {
309+
hh := headers.Get("Host")
310+
if hh == "" {
311+
return Request{}, fmt.Errorf("%w: %s", ErrInvalidPayload, "missing host header")
312+
}
313+
314+
if err := internalurl.Validate(&hh); err != nil {
315+
return Request{}, fmt.Errorf("%w - %s", ErrInvalidHost, err)
316+
}
317+
318+
host, err := url.Parse(strings.TrimSpace(hh))
319+
if err != nil {
320+
return Request{}, fmt.Errorf("%w - %s", ErrInvalidHost, err)
321+
}
322+
323+
hostStr = host.String()
324+
}
325+
326+
body, err := readBody(tp)
327+
if err != nil && !errors.Is(err, io.EOF) {
328+
return Request{}, fmt.Errorf("%w: %s", ErrInvalidPayload, err)
329+
}
330+
331+
return Request{
332+
URL: hostStr,
333+
Method: method,
334+
Path: path,
335+
Proto: proto,
336+
Headers: headers,
337+
Body: body,
338+
// Default values
339+
Timeout: 20 * time.Second,
340+
RedirectType: profile.RedirectNever,
341+
}, nil
342+
}
343+
344+
func parseRequestLine(line string) (method, requestURI, proto string, ok bool) {
345+
s1 := strings.Index(line, " ")
346+
s2 := strings.Index(line[s1+1:], " ")
347+
348+
if s1 < 0 || s2 < 0 {
349+
return
350+
}
351+
352+
s2 += s1 + 1
353+
354+
return line[:s1], line[s1+1 : s2], line[s2+1:], true
355+
}
356+
357+
func readBody(tp *textproto.Reader) ([]byte, error) {
358+
var reqBytes []byte
359+
360+
for {
361+
recvBuf := make([]byte, 1024)
362+
363+
n, err := tp.R.Read(recvBuf)
364+
if err != nil {
365+
return nil, err
366+
}
367+
368+
reqBytes = append(reqBytes, recvBuf[:n]...)
369+
370+
if n < len(recvBuf) {
371+
break
372+
}
373+
}
374+
375+
return reqBytes, nil
376+
}

0 commit comments

Comments
 (0)