package rproxy import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "maps" "net/http" "strings" "sneak.berlin/go/smallwebwaf/internal/proxy" ) // answerBodyMaxBytes is the most of the body of servewaf's answer that is read. // It is far above any answer's. const answerBodyMaxBytes = 1 << 20 // reportAnswerMaxBytes is the most of the answer to a report that is read, // which has no body when servewaf takes the report. const reportAnswerMaxBytes = 4 << 10 var ( errCannotPassOn = errors.New("the answer cannot be passed to the visitor") errReportRefused = errors.New("the report was answered") ) // servewafAnswer is what servewaf answered to a question. type servewafAnswer struct { status int header http.Header body []byte } // noteAnswer notes what servewaf answered. An answer without a valid disposition // notes nothing, and no report is due for it: servewaf holds no decision to report // on. func (rq *request) noteAnswer(answer *servewafAnswer) { disposition := answer.header.Get(proxy.DispositionHeader) switch disposition { case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose: rq.disposition = disposition rq.answered = true rq.asn = answer.header.Get(asnHeader) rq.country = answer.header.Get(countryHeader) } } // handleNoUsableAnswer deals with a request whose question ended in err, which // leaves no answer to act on. ctx is the request's own context. func (rq *request) handleNoUsableAnswer(ctx context.Context, err error) { switch { case ctx.Err() != nil: // The visitor went away while servewaf was being asked. servewaf has not // failed, and nothing is noted. This panic has Go's server close the // connection without an answer, and log nothing; the deferred finish still // runs. panic(http.ErrAbortHandler) case errors.Is(err, errCannotPassOn): rq.h.processLog.Warn("servewaf's answer is dropped", "error", err.Error()) rq.answer(http.StatusBadGateway) default: // servewaf cannot be connected to, does not answer at all, or does not answer // within SWRP_TIMEOUT_MS. A request that keeps servewaf busy past // SWRP_TIMEOUT_MS, like any request while servewaf is overloaded, gets // SWRP_DEFAULT_DISPOSITION here. deny closes this, at the cost of answering 503 // whenever servewaf is slow. Making such requests hard to send is // https://git.eeqj.de/sneak/smallwebwaf/issues/182. rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error()) rq.disposition = rq.h.config.DefaultDisposition rq.byDefault = true if rq.disposition == proxy.DispositionAllow { rq.passToApp(ctx) } else { rq.answer(http.StatusServiceUnavailable) } } } // ask sends servewaf the question, and returns its answer, which must have come // whole within SWRP_TIMEOUT_MS. It ends when ctx does, as when the visitor goes // away. func (rq *request) ask(ctx context.Context) (*servewafAnswer, error) { ctx, cancel := context.WithTimeout(ctx, rq.h.config.Timeout) defer cancel() question, err := rq.newQuestion(ctx) if err != nil { return nil, err } res, err := rq.h.serveWAFTransport.RoundTrip(question) if err != nil { return nil, fmt.Errorf("send the question: %w", err) } defer func() { _ = res.Body.Close() }() body, err := io.ReadAll(io.LimitReader(res.Body, answerBodyMaxBytes+1)) if err != nil { return nil, fmt.Errorf("read the answer: %w", err) } switch { case len(body) > answerBodyMaxBytes: return nil, fmt.Errorf("%w: its body is over 1 MiB", errCannotPassOn) case res.StatusCode < http.StatusContinue: // Go's client takes such a status. Go's server panics when asked to send it. return nil, fmt.Errorf("%w: its status is %d", errCannotPassOn, res.StatusCode) } return &servewafAnswer{res.StatusCode, res.Header, body}, nil } // canBeAsked reports whether Go's client can send servewaf r as the visitor sent // it. It cannot send these as they came: // - a target that does not start with /, or that starts with //; // - an empty Host, for which it sends servewaf's own address; // - a Host holding an IPv6 zone, which it removes; // - more than one User-Agent, of which it sends the first. func canBeAsked(r *http.Request) bool { target, host := r.RequestURI, r.Host hostHasZone := strings.HasPrefix(host, "[") && strings.Contains(host, "%") return strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") && host != "" && !hostHasZone && len(r.Header.Values("User-Agent")) <= 1 } // newQuestion returns the question about the request, as "The question" in // SPEC.md gives it: the visitor's request as the visitor sent it, with the part // of the body it holds and rproxy's headers. func (rq *request) newQuestion(ctx context.Context) (*http.Request, error) { in := rq.in var held []byte if rq.body != nil { held = rq.body.held } question, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL ctx, in.Method, rq.h.config.WAFBackend.String(), bytes.NewReader(held)) if err != nil { return nil, fmt.Errorf("make the question: %w", err) } backend := question.URL target := *in.URL target.Scheme = backend.Scheme target.Host = backend.Host setPathAsSent(&target, in) question.URL = &target question.Host = in.Host // The length NewRequestWithContext set is that of what is held. This line makes // a body whose length the visitor did not announce go chunked, as "The question" // in SPEC.md requires. question.TransferEncoding = in.TransferEncoding question.Header = in.Header.Clone() question.Header.Set(proxy.ClientHeader, rq.client.String()) question.Header.Set(proxy.PeerHeader, rq.peer.String()) question.Header.Del(proxy.ContentLengthHeader) announced := in.Header.Get("Content-Length") if announced != "" { question.Header.Set(proxy.ContentLengthHeader, announced) } question.Header.Set(requestIDHeader, rq.id) question.Header.Set("X-Forwarded-Proto", scheme(in, rq.peerTrusted)) // Go's client adds a User-Agent of its own to a request without the key. _, sent := question.Header["User-Agent"] if !sent { question.Header["User-Agent"] = nil } return question, nil } // answerWith gives the visitor servewaf's answer, less its disposition. func (rq *request) answerWith(answer *servewafAnswer) { rq.prepareOwnAnswer(rq.requestDeadline()) header := rq.out.Header() maps.Copy(header, answer.header) header.Del(proxy.DispositionHeader) // Go's server adds a Content-Type it guesses from the body to a response whose // headers have no Content-Type key. The key with no value sends none. if header.Get("Content-Type") == "" { header["Content-Type"] = nil } rq.send(answer.status, answer.body) } // sendReport sends servewaf report on a goroutine of its own, so that the // visitor does not wait for it. The report goes on after ctx, the request's own // context, has ended. func (h *handler) sendReport(ctx context.Context, report *proxy.Report) { h.reportsMu.Lock() defer h.reportsMu.Unlock() if h.reportsStopped { return } h.reports.Go(func() { err := h.postReport(context.WithoutCancel(ctx), report) if err != nil { h.processLog.Warn("a report is dropped", "error", err.Error()) } }) } // postReport posts report to servewaf, and waits for its answer for up to // SWRP_TIMEOUT_MS. func (h *handler) postReport(ctx context.Context, report *proxy.Report) error { body, err := json.Marshal(report) if err != nil { return fmt.Errorf("encode the report: %w", err) } ctx, cancel := context.WithTimeout(ctx, h.config.Timeout) defer cancel() req, err := http.NewRequestWithContext( //nolint:gosec // a setting's URL ctx, http.MethodPost, h.reportURL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("make the request: %w", err) } req.Header.Set("Content-Type", "application/json") res, err := h.serveWAFTransport.RoundTrip(req) if err != nil { return fmt.Errorf("send the report: %w", err) } _, _ = io.Copy(io.Discard, io.LimitReader(res.Body, reportAnswerMaxBytes)) _ = res.Body.Close() if res.StatusCode != http.StatusNoContent { return fmt.Errorf("%w %s", errReportRefused, res.Status) } return nil }