|
| 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