rproxy, the proxy in front of each app, asks servewaf and fails open (closes #171)
check / check (push) Failing after 14m17s
check / check (push) Failing after 14m17s
`smallwebwaf rproxy` reads the first 64 KiB of each body, asks servewaf, and passes what servewaf allows to the app within its own size and time limits. It reports each request servewaf answered. SWRP_DEFAULT_DISPOSITION decides when servewaf gives no answer. Duplication: internal/rproxy copies passing-on code from internal/proxy until #172 removes the originals. Rule suppressed: gosec G704 on servewaf's URL, a setting. Rule suppressed: gosec G705 on the answer written to the visitor. Judgement call: the SWRP_ size and time limits take off; SWRP_TIMEOUT_MS does not. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
// Package rproxy is the reverse proxy in front of one app that asks servewaf
|
||||
// about each request. It passes a request servewaf allows to the app and the
|
||||
// app's answer back, within the size and time limits. When servewaf gives no
|
||||
// answer, SWRP_DEFAULT_DISPOSITION decides. It keeps nothing between requests
|
||||
// and never touches disk. "rproxy" in SPEC.md says what it does.
|
||||
package rproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/smallwebwaf/internal/config"
|
||||
"sneak.berlin/go/smallwebwaf/internal/proxy"
|
||||
)
|
||||
|
||||
// How rproxy keeps connections to the app and to servewaf open between
|
||||
// requests.
|
||||
const (
|
||||
idleConns = 100
|
||||
idleConnTimeout = 90 * time.Second
|
||||
)
|
||||
|
||||
// The limits on a connection from a visitor that have no setting.
|
||||
const (
|
||||
// requestHeaderMaxBytes is the largest request line and headers a visitor
|
||||
// may send.
|
||||
requestHeaderMaxBytes = 32 << 10
|
||||
// goServerExtraHeaderBytes is how far past MaxHeaderBytes Go's server reads
|
||||
// before it refuses.
|
||||
goServerExtraHeaderBytes = 4 << 10
|
||||
// idleTimeout is how long a kept-open connection may wait for its next
|
||||
// request.
|
||||
idleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// adminPrefix starts the path of every request for smallwebwaf itself, which
|
||||
// never reaches the app.
|
||||
const adminPrefix = "/_smallwebwaf/"
|
||||
|
||||
// Params are what New needs.
|
||||
type Params struct {
|
||||
Config *config.RProxy
|
||||
// RequestLog receives one short JSON line per request.
|
||||
RequestLog io.Writer
|
||||
// ProcessLog receives the process's own messages.
|
||||
ProcessLog *slog.Logger
|
||||
}
|
||||
|
||||
// Server is the server `smallwebwaf rproxy` runs.
|
||||
type Server struct {
|
||||
*http.Server
|
||||
|
||||
h *handler
|
||||
}
|
||||
|
||||
// New returns the server `smallwebwaf rproxy` runs. Go's server itself refuses
|
||||
// a request line and headers over requestHeaderMaxBytes, with 431, closes a
|
||||
// connection idle for idleTimeout, and applies SWRP_MAX_REQUEST_TIME_MS while
|
||||
// the headers arrive. The handler applies the time and size limits from then
|
||||
// on.
|
||||
func New(params Params) *Server {
|
||||
backend := params.Config.WAFBackend
|
||||
errorLog := slog.NewLogLogger(params.ProcessLog.Handler(), slog.LevelWarn)
|
||||
h := &handler{
|
||||
config: params.Config,
|
||||
requestLog: params.RequestLog,
|
||||
processLog: params.ProcessLog,
|
||||
errorLog: errorLog,
|
||||
transport: newTransport(),
|
||||
reportURL: (&url.URL{
|
||||
Scheme: backend.Scheme, Host: backend.Host, Path: proxy.ReportPath,
|
||||
}).String(),
|
||||
}
|
||||
|
||||
return &Server{
|
||||
Server: &http.Server{
|
||||
Addr: params.Config.ListenAddr,
|
||||
Handler: h,
|
||||
ErrorLog: errorLog,
|
||||
ReadHeaderTimeout: params.Config.MaxRequestTime,
|
||||
IdleTimeout: idleTimeout,
|
||||
MaxHeaderBytes: requestHeaderMaxBytes - goServerExtraHeaderBytes,
|
||||
},
|
||||
h: h,
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForReports returns once the reports being sent have gone or failed,
|
||||
// which SWRP_TIMEOUT_MS bounds. Any report after this call is dropped: that of
|
||||
// a WebSocket that closes while rproxy stops, for one.
|
||||
func (s *Server) WaitForReports() {
|
||||
s.h.reportsMu.Lock()
|
||||
s.h.reportsStopped = true
|
||||
s.h.reportsMu.Unlock()
|
||||
|
||||
s.h.reports.Wait()
|
||||
}
|
||||
|
||||
// handler is the proxy. It holds what every request shares; what belongs to
|
||||
// one request is in a request.
|
||||
type handler struct {
|
||||
config *config.RProxy
|
||||
requestLog io.Writer
|
||||
processLog *slog.Logger
|
||||
errorLog *log.Logger
|
||||
transport http.RoundTripper
|
||||
// reportURL is where the reports go.
|
||||
reportURL string
|
||||
// reports waits for the reports being sent. reportsMu guards starting one
|
||||
// against reportsStopped, which is set once nothing is to wait for another.
|
||||
reports sync.WaitGroup
|
||||
reportsMu sync.Mutex
|
||||
reportsStopped bool
|
||||
}
|
||||
|
||||
// newTransport returns what carries requests to the app and to servewaf. It
|
||||
// never goes through a proxy named in the environment, and leaves answers
|
||||
// compressed or not as they were sent.
|
||||
func newTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
MaxIdleConns: idleConns,
|
||||
MaxIdleConnsPerHost: idleConns,
|
||||
IdleConnTimeout: idleConnTimeout,
|
||||
DisableCompression: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP handles one request: it answers the health check itself, and for
|
||||
// any other request reads the first part of the body, asks servewaf, and does
|
||||
// what the answer says. Once the answer has ended, it writes the request's log
|
||||
// line and sends its report.
|
||||
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
rq := h.newRequest(w, r)
|
||||
defer rq.finish(r.Context())
|
||||
|
||||
if r.Method == http.MethodGet && r.URL.Path == proxy.HealthPath {
|
||||
rq.answerHealth()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !rq.readFirstPartOfBody() {
|
||||
return
|
||||
}
|
||||
|
||||
answer := rq.askServeWAF(r.Context())
|
||||
|
||||
switch rq.disposition {
|
||||
case proxy.DispositionAllow:
|
||||
rq.passToApp(r.Context())
|
||||
case proxy.DispositionDeny:
|
||||
if answer == nil {
|
||||
rq.answer(http.StatusServiceUnavailable)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
rq.answerWith(answer)
|
||||
default: // proxy.DispositionClose
|
||||
// This panic has Go's server close the connection without an answer,
|
||||
// and log nothing; the deferred finish still runs.
|
||||
panic(http.ErrAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// passToApp passes the request on to the app, unless a limit or its path keeps
|
||||
// it from the app. servewaf answers a path under adminPrefix itself, so such a
|
||||
// request gets here only when SWRP_DEFAULT_DISPOSITION allowed it. ctx is the
|
||||
// request's own context.
|
||||
func (rq *request) passToApp(ctx context.Context) {
|
||||
maxBytes := rq.h.config.MaxRequestSize
|
||||
|
||||
switch {
|
||||
case rq.refused.Load() != 0 || (maxBytes > 0 && rq.in.ContentLength > maxBytes):
|
||||
rq.answer(http.StatusRequestEntityTooLarge)
|
||||
case strings.HasPrefix(rq.in.URL.Path, adminPrefix):
|
||||
rq.answer(http.StatusNotFound)
|
||||
default:
|
||||
rq.forward(ctx)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user