package rproxy import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "maps" "net/http" "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 ( errNoDisposition = errors.New("the answer has no valid " + proxy.DispositionHeader) errAnswerTooLong = errors.New("the answer's body is over 1 MiB") 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 } // askServeWAF asks servewaf about the request, and notes what is to be done // with it. It returns servewaf's answer, or nil when servewaf gave none, and // SWRP_DEFAULT_DISPOSITION decides. When the visitor went away while servewaf // was being asked, servewaf has not failed, and nothing is noted. ctx is the // request's own context. func (rq *request) askServeWAF(ctx context.Context) *servewafAnswer { answer, err := rq.ask(ctx) if err != nil { if ctx.Err() != nil { return nil } rq.h.processLog.Warn("servewaf gave no answer", "error", err.Error()) rq.disposition = rq.h.config.DefaultDisposition rq.byDefault = true return nil } rq.disposition = answer.header.Get(proxy.DispositionHeader) rq.answered = true rq.asn = answer.header.Get(asnHeader) rq.country = answer.header.Get(countryHeader) return answer } // ask sends servewaf the question, and returns its answer, which must have come // whole within SWRP_TIMEOUT_MS, with a valid disposition. 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) } if len(body) > answerBodyMaxBytes { return nil, errAnswerTooLong } switch res.Header.Get(proxy.DispositionHeader) { case proxy.DispositionAllow, proxy.DispositionDeny, proxy.DispositionClose: return &servewafAnswer{res.StatusCode, res.Header, body}, nil default: return nil, fmt.Errorf("%w, and status %d", errNoDisposition, res.StatusCode) } } // 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. A body whose // length the visitor did not announce goes chunked. Named here, since Go's // client would otherwise send a CONNECT's body with neither length nor chunks, // which servewaf would read as further requests. 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) rq.out.WriteHeader(answer.status) _, _ = rq.out.Write(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 }