Repository navigation
Expand file tree
/
Copy pathrequest.go
More file actions
583 lines (530 loc) · 19.3 KB
/
Copy pathrequest.go
File metadata and controls
583 lines (530 loc) · 19.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptrace"
"net/url"
"os"
"sort"
"strconv"
"strings"
"time"
log "charm.land/log/v2"
)
const defaultTimeout = 30 * time.Second
// maxBodyBytes caps how much of a response body is buffered. Everything weeb
// does with a body (pretty-print, tree views, TUI render) needs it in memory,
// so an unbounded read would let a hostile or runaway server stream gigabytes
// within the timeout window and OOM the process. A var so tests can lower it
// and applyMaxBody can override it (--max-body / WEEB_MAX_BODY). Bodies
// streamed to a BodySink are never capped.
var maxBodyBytes int64 = 64 << 20 // 64 MiB
// applyMaxBody sets the buffered-body cap: the --max-body flag value wins,
// then WEEB_MAX_BODY, then the 64 MiB default stands. A value of 0 disables
// the cap entirely.
func applyMaxBody(flag string) error {
src := flag
if src == "" {
src = os.Getenv("WEEB_MAX_BODY")
}
if src == "" {
return nil
}
n, err := parseSize(src)
if err != nil {
return fmt.Errorf("bad body size limit %q (bytes, or k/m/g suffix, e.g. 256m; 0 = no cap)", src)
}
if n == 0 {
n = 1 << 62 // effectively uncapped, without overflowing the +1 probe read
}
maxBodyBytes = n
return nil
}
// parseSize parses a human byte size: a plain integer is bytes; k/m/g
// suffixes are binary multiples, case-insensitive, with b/ib tolerated
// ("64m", "1G", "512KiB", "1048576").
func parseSize(s string) (int64, error) {
t := strings.ToLower(strings.TrimSpace(s))
num, mult := t, int64(1)
switch {
case strings.HasSuffix(t, "kib"):
num, mult = t[:len(t)-3], 1<<10
case strings.HasSuffix(t, "mib"):
num, mult = t[:len(t)-3], 1<<20
case strings.HasSuffix(t, "gib"):
num, mult = t[:len(t)-3], 1<<30
case strings.HasSuffix(t, "kb"):
num, mult = t[:len(t)-2], 1<<10
case strings.HasSuffix(t, "mb"):
num, mult = t[:len(t)-2], 1<<20
case strings.HasSuffix(t, "gb"):
num, mult = t[:len(t)-2], 1<<30
case strings.HasSuffix(t, "k"):
num, mult = t[:len(t)-1], 1<<10
case strings.HasSuffix(t, "m"):
num, mult = t[:len(t)-1], 1<<20
case strings.HasSuffix(t, "g"):
num, mult = t[:len(t)-1], 1<<30
case strings.HasSuffix(t, "b"):
num = t[:len(t)-1]
}
n, err := strconv.ParseInt(strings.TrimSpace(num), 10, 64)
if err != nil || n < 0 || n > (1<<62)/mult {
return 0, fmt.Errorf("bad size %q", s)
}
return n * mult, nil
}
// Header is a single request header row.
type Header struct {
Key string
Value string
}
// RequestSpec describes one HTTP request as entered by the user, before env
// prefills are resolved.
type RequestSpec struct {
Method string
URL string
Headers []Header
Body []byte
// HeadersResolved marks Headers as the user's final say: env header
// prefills (WEEB_HEADERS/WEEB_TOKEN) were already surfaced to the user,
// who may have deliberately removed them, so resolveSpec must not add
// them back. The TUI form sets this; one-shot CLI specs leave it false.
HeadersResolved bool
// BodySink, when non-nil, receives the response body as it arrives
// instead of it being buffered into Result.Body. Streaming bypasses
// maxBodyBytes — the caller owns the destination (a pipe, a file), so
// memory stays constant no matter the size, like curl.
BodySink io.Writer
// NoFollow stops redirect following: the 3xx response itself comes back
// (status, Location header, body) instead of whatever it points at.
NoFollow bool
// envHeaderKeys holds the lowercased names of the headers resolveSpec
// injected from the environment (WEEB_HEADERS, WEEB_TOKEN), so
// redirectPolicy can strip exactly those on a cross-origin hop without
// touching same-named headers the user typed themselves.
envHeaderKeys []string
}
// Result is the outcome of handling one request. Body always holds the raw
// response bytes (even for 4xx/5xx) — except when the spec carried a BodySink,
// which received them instead; BodySize counts the bytes either way.
// DisplayErr, when non-empty, is the human-facing weeb error string produced
// by the ErrorChan seam. Err is the underlying Go error used for CLI exit
// codes; it is nil on a 2xx/3xx success.
type Result struct {
Method string
URL string
Status int
StatusText string
Proto string
Headers http.Header
Body []byte
BodySize int64
ContentType string
Timing Timing
TLS *connTLS
DisplayErr string
Err error
// Redirects is the URL chain when redirects were followed: the original
// URL first, then every hop taken, ending at the final URL (which is also
// what URL above holds). Empty when the request wasn't redirected.
Redirects []string
}
// OK reports whether the request fully succeeded (sent, received, status < 400).
func (r Result) OK() bool { return r.Err == nil }
// bodySize is the response size in bytes, valid both for buffered bodies and
// for ones streamed to a BodySink (where Body itself stays empty). The len
// fallback keeps hand-built Results (tests, TUI fixtures) honest.
func (r Result) bodySize() int64 {
if r.BodySize != 0 {
return r.BodySize
}
return int64(len(r.Body))
}
// Client is the single component that executes requests and handles their
// results. Construct it once per process with the chosen logger and ErrorChan.
type Client struct {
http *http.Client
stream *http.Client // BodySink requests: bounds time-to-headers, not the body copy
log *log.Logger
voice ErrorChan
// explicitTimeout records that the user passed --timeout. An explicit
// value is a total cap, so Do keeps it on streamed transfers too; only
// the DEFAULT timeout steps aside for a BodySink.
explicitTimeout bool
}
func newClient(logger *log.Logger, voice ErrorChan) *Client {
// http.Client.Timeout covers reading the entire body, which would kill a
// streamed download (-o / piped) at 30s mid-transfer — and streaming
// exists precisely for arbitrarily large bodies. The stream client moves
// the default timeout to the response headers instead (the cloned default
// transport already carries a 30s dial timeout) and leaves the body copy
// unbounded, like curl -o.
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.ResponseHeaderTimeout = defaultTimeout
return &Client{
http: &http.Client{Timeout: defaultTimeout, CheckRedirect: redirectPolicy},
stream: &http.Client{Transport: tr, CheckRedirect: redirectPolicy},
log: logger,
voice: voice,
}
}
// SetTimeout applies a user-supplied --timeout. Explicit means "cap the whole
// request", so unlike the default it also bounds streamed (BodySink) transfers.
func (c *Client) SetTimeout(d time.Duration) {
c.http.Timeout = d
c.explicitTimeout = true
}
// noFollowKey and redirectChainKey are unexported request-context keys. The
// http.Client (and so its CheckRedirect) is shared across requests, so Do
// passes per-request redirect signals to redirectPolicy on the request
// context instead: noFollowKey carries a bool, redirectChainKey a *[]string
// that collects the URL of every hop followed.
type (
noFollowKey struct{}
redirectChainKey struct{}
)
// envHeaderKeysCtx carries spec.envHeaderKeys on the request context so
// redirectPolicy can see which headers were env-injected — CheckRedirect
// requests inherit the original request's context.
type envHeaderKeysCtx struct{}
// redirectPolicy follows up to 10 redirects (the stdlib limit) but strips
// credential headers when a redirect leaves the original origin. Go's default
// policy only drops Authorization/Cookie on cross-DOMAIN hops — subdomains
// are allowed, the scheme is ignored, and custom headers are never touched —
// so the ambient env credentials weeb injects (WEEB_HEADERS, WEEB_TOKEN)
// would otherwise follow a redirect to an attacker origin, and a bearer token
// would survive a same-host https→http downgrade in cleartext. Only headers
// resolveSpec actually injected (recorded on the context at build time, not
// re-read from the env) are stripped: a user-typed header that merely shares
// a name with a WEEB_HEADERS key is the user's own and follows redirects
// like any custom header, matching curl.
func redirectPolicy(req *http.Request, via []*http.Request) error {
// A NoFollow spec wants the 3xx itself: ErrUseLastResponse hands back the
// redirect response, body and all, with no error.
if nf, _ := req.Context().Value(noFollowKey{}).(bool); nf {
return http.ErrUseLastResponse
}
if len(via) >= 10 {
return fmt.Errorf("stopped after 10 redirects")
}
// Record the hop for Result.Redirects (req is the upcoming request).
if chain, ok := req.Context().Value(redirectChainKey{}).(*[]string); ok {
*chain = append(*chain, req.URL.String())
}
if keepsCredentials(via[0].URL, req.URL) {
return nil
}
req.Header.Del("Authorization")
req.Header.Del("Proxy-Authorization")
req.Header.Del("Cookie")
if keys, ok := req.Context().Value(envHeaderKeysCtx{}).([]string); ok {
for _, k := range keys {
req.Header.Del(k)
}
}
return nil
}
// keepsCredentials reports whether a redirect from orig to next stays within
// the original origin closely enough to keep sending credentials: the same
// host, no TLS downgrade, and the same effective port — compared regardless
// of scheme, so http://host:8080 → https://host:8443 can't reach a different
// service on the host with credentials attached. The one allowed port change
// is the default-port http(80) → https(443) upgrade.
func keepsCredentials(orig, next *url.URL) bool {
if !strings.EqualFold(orig.Hostname(), next.Hostname()) {
return false
}
if orig.Scheme == "https" && next.Scheme == "http" {
return false // TLS downgrade: never send credentials in cleartext
}
op, np := effectivePort(orig), effectivePort(next)
if op == np {
return true
}
// Another port is a different service — except the standard TLS upgrade.
return orig.Scheme == "http" && next.Scheme == "https" && op == "80" && np == "443"
}
func effectivePort(u *url.URL) string {
if p := u.Port(); p != "" {
return p
}
switch u.Scheme {
case "https":
return "443"
case "http":
return "80"
}
return ""
}
// Do is THE chokepoint. It resolves env prefills, builds and executes the
// request, and is the ONLY place that drives both seams: charm/log records the
// structured diagnostic line, and on failure the ErrorChan produces the human
// display string. No other code logs request-lifecycle events or formats a
// failure for display.
func (c *Client) Do(spec RequestSpec) Result {
spec = resolveSpec(spec)
res := Result{Method: strings.ToUpper(strings.TrimSpace(spec.Method)), URL: spec.URL}
req, err := buildRequest(spec)
if err != nil {
c.log.Error("request failed", "kind", KindBadRequest.String(), "err", err)
res.Err = err
res.DisplayErr = c.voice.Render(KindBadRequest, 0, err)
return res
}
res.Method = req.Method
res.URL = req.URL.String()
rlog := c.log.With("method", req.Method, "url", res.URL)
rlog.Info("request")
// Redirect signals travel on the request context — see redirectPolicy.
var hops []string
ctx := context.WithValue(req.Context(), redirectChainKey{}, &hops)
if spec.NoFollow {
ctx = context.WithValue(ctx, noFollowKey{}, true)
}
tr := &reqTrace{}
req = req.WithContext(httptrace.WithClientTrace(ctx, tr.clientTrace()))
// Streaming with the default timeout: bound the wait for headers, never
// the body copy. An explicit --timeout keeps the total cap even here.
httpc := c.http
if spec.BodySink != nil && !c.explicitTimeout {
httpc = c.stream
}
start := time.Now()
tr.start = start
resp, err := httpc.Do(req)
if err != nil {
dur := time.Since(start)
res.Timing.Total = dur // no trace events fired; record the wall time at least
rlog.Error("request failed",
"kind", KindTransport.String(), "duration_ms", dur.Milliseconds(), "err", err)
res.Err = err
res.DisplayErr = c.voice.Render(KindTransport, 0, err)
return res
}
defer resp.Body.Close()
var body []byte
var bodySize int64
var readErr error
if spec.BodySink != nil {
// Streaming: bytes go to the sink as they arrive, uncapped — the
// caller owns the destination, memory stays constant.
bodySize, readErr = io.Copy(spec.BodySink, resp.Body)
} else {
body, readErr = io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes+1))
if readErr == nil && int64(len(body)) > maxBodyBytes {
body = body[:maxBodyBytes]
capStr := humanSize(int(maxBodyBytes))
readErr = fmt.Errorf("response body exceeds %s; keeping the first %s", capStr, capStr)
}
bodySize = int64(len(body))
}
done := time.Now()
dur := done.Sub(start)
// Redirects followed: prefix the original URL so the chain reads
// start → … → final, and point URL at where the request ended up.
if len(hops) > 0 {
res.Redirects = append([]string{res.URL}, hops...)
res.URL = resp.Request.URL.String()
}
res.Timing = tr.timing(done)
res.TLS = tlsSummary(resp.TLS)
res.Status = resp.StatusCode
res.StatusText = http.StatusText(resp.StatusCode)
res.Proto = resp.Proto
res.Headers = resp.Header
res.ContentType = resp.Header.Get("Content-Type")
res.Body = body
res.BodySize = bodySize
if readErr != nil {
rlog.Error("request failed",
"kind", KindBadBody.String(), "status", resp.StatusCode,
"duration_ms", dur.Milliseconds(), "err", readErr)
res.Err = readErr
res.DisplayErr = c.voice.Render(KindBadBody, resp.StatusCode, readErr)
return res
}
rlog.Info("response",
"status", resp.StatusCode, "duration_ms", dur.Milliseconds(), "bytes", bodySize,
"dns_ms", res.Timing.DNS.Milliseconds(), "tcp_ms", res.Timing.TCP.Milliseconds(),
"tls_ms", res.Timing.TLS.Milliseconds(), "send_ms", res.Timing.Send.Milliseconds(),
"server_ms", res.Timing.Server.Milliseconds(),
"transfer_ms", res.Timing.Transfer.Milliseconds(), "reused", res.Timing.Reused)
if resp.StatusCode >= 400 {
statusErr := fmt.Errorf("server returned %d %s", resp.StatusCode, res.StatusText)
rlog.Error("request failed",
"kind", KindHTTPStatus.String(), "status", resp.StatusCode,
"duration_ms", dur.Milliseconds())
res.Err = statusErr
res.DisplayErr = c.voice.Render(KindHTTPStatus, resp.StatusCode, statusErr)
}
return res
}
// resolveSpec applies the env prefills. None of them override a value the user
// already supplied, so it is safe to call even on a form that already shows the
// defaults (it stays idempotent).
//
// WEEB_BASE_URL : relative URLs ("/me") resolve against it
// WEEB_HEADERS : default headers ("K:V;K2:V2"), added only if absent
// WEEB_TOKEN : "Authorization: Bearer $WEEB_TOKEN" unless an Authorization
// header is already present
//
// Header prefills are skipped when spec.HeadersResolved is set — the user has
// already seen (and possibly deleted) them in the form, and a deleted auth
// header must stay deleted. URL resolution always applies.
//
// Each header actually injected here is recorded in spec.envHeaderKeys so
// redirectPolicy strips exactly those on a cross-origin hop — a user-supplied
// header that happens to share a name with a WEEB_HEADERS key is never
// injected, and so never stripped.
func resolveSpec(spec RequestSpec) RequestSpec {
if base := envOr("WEEB_BASE_URL", ""); base != "" {
if u := strings.TrimSpace(spec.URL); u != "" && !hasScheme(u) {
spec.URL = joinURL(base, u)
}
}
// A schemeless host defaults to http (port 80) — type "example.com:8080" and
// it just works. (TLS cert inspection keeps its own https/443 default.) The
// leading-slash guard leaves a bare relative path alone for WEEB_BASE_URL.
if u := strings.TrimSpace(spec.URL); u != "" && !hasScheme(u) && !strings.HasPrefix(u, "/") {
spec.URL = "http://" + u
}
if spec.HeadersResolved {
return spec
}
have := map[string]bool{}
for _, h := range spec.Headers {
if k := strings.TrimSpace(h.Key); k != "" {
have[strings.ToLower(k)] = true
}
}
for _, h := range parseHeaderList(os.Getenv("WEEB_HEADERS")) {
lk := strings.ToLower(h.Key)
if !have[lk] {
spec.Headers = append(spec.Headers, h)
spec.envHeaderKeys = append(spec.envHeaderKeys, lk)
have[lk] = true
}
}
if tok := os.Getenv("WEEB_TOKEN"); tok != "" && !have["authorization"] {
spec.Headers = append(spec.Headers, Header{Key: "Authorization", Value: "Bearer " + tok})
spec.envHeaderKeys = append(spec.envHeaderKeys, "authorization")
}
return spec
}
func buildRequest(spec RequestSpec) (*http.Request, error) {
method := strings.ToUpper(strings.TrimSpace(spec.Method))
if method == "" {
method = http.MethodGet
}
if strings.TrimSpace(spec.URL) == "" {
return nil, fmt.Errorf("no URL provided")
}
var body io.Reader
if len(spec.Body) > 0 && methodAllowsBody(method) {
body = bytes.NewReader(spec.Body)
}
req, err := http.NewRequest(method, spec.URL, body)
if err != nil {
return nil, err
}
for _, h := range spec.Headers {
if strings.TrimSpace(h.Key) == "" {
continue
}
req.Header.Add(h.Key, h.Value)
}
if len(spec.envHeaderKeys) > 0 {
req = req.WithContext(context.WithValue(req.Context(), envHeaderKeysCtx{}, spec.envHeaderKeys))
}
return req, nil
}
// methodAllowsBody reports whether a request body is meaningful for a method.
// Used both to attach the body and to grey out the TUI body field.
func methodAllowsBody(method string) bool {
switch strings.ToUpper(method) {
case http.MethodPost, http.MethodPut, http.MethodPatch:
return true
default:
return false
}
}
func hasScheme(u string) bool {
return strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://")
}
func joinURL(base, rel string) string {
b, err := url.Parse(base)
if err != nil {
return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(rel, "/")
}
r, err := url.Parse(rel)
if err != nil {
return base
}
return b.ResolveReference(r).String()
}
// parseHeaderList parses a "K:V;K2:V2" string into headers, tolerating spaces
// and empty segments. A backslash escapes the separator, so "\;" yields a
// literal semicolon inside a value and "\\" a literal backslash.
func parseHeaderList(s string) []Header {
var out []Header
for _, part := range splitHeaderSegments(s) {
part = strings.TrimSpace(part)
if part == "" {
continue
}
k, v, ok := strings.Cut(part, ":")
if !ok {
continue
}
out = append(out, Header{Key: strings.TrimSpace(k), Value: strings.TrimSpace(v)})
}
return out
}
// splitHeaderSegments splits s on unescaped ';', unescaping "\;" and "\\"
// within each segment. Any other backslash passes through unchanged.
func splitHeaderSegments(s string) []string {
var segs []string
var b strings.Builder
for i := 0; i < len(s); i++ {
switch c := s[i]; {
case c == '\\' && i+1 < len(s) && (s[i+1] == ';' || s[i+1] == '\\'):
b.WriteByte(s[i+1])
i++
case c == ';':
segs = append(segs, b.String())
b.Reset()
default:
b.WriteByte(c)
}
}
return append(segs, b.String())
}
// prettyBody pretty-prints the body when it looks like JSON, otherwise returns
// it unchanged. This is for DISPLAY only — CLI stdout always emits the raw bytes
// so downstream tools (jq, etc.) see exactly what the server sent.
func prettyBody(body []byte, contentType string) string {
if isJSON(contentType) {
var buf bytes.Buffer
if err := json.Indent(&buf, body, "", " "); err == nil {
return buf.String()
}
}
return string(body)
}
func isJSON(contentType string) bool {
ct := strings.ToLower(contentType)
return strings.Contains(ct, "application/json") || strings.Contains(ct, "+json")
}
func sortedHeaderKeys(h http.Header) []string {
keys := make([]string, 0, len(h))
for k := range h {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}