From f6900fcf530e80c921dac8e4f09996cffce7f436 Mon Sep 17 00:00:00 2001 From: Francis Lavoie Date: Fri, 6 May 2022 10:50:26 -0400 Subject: reverseproxy: Support performing pre-check requests (#4739) --- modules/caddyhttp/reverseproxy/caddyfile.go | 29 ++- .../caddyhttp/reverseproxy/fastcgi/caddyfile.go | 10 +- .../reverseproxy/forwardauth/caddyfile.go | 269 +++++++++++++++++++++ modules/caddyhttp/reverseproxy/reverseproxy.go | 56 ++++- 4 files changed, 349 insertions(+), 15 deletions(-) create mode 100644 modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go (limited to 'modules/caddyhttp/reverseproxy') diff --git a/modules/caddyhttp/reverseproxy/caddyfile.go b/modules/caddyhttp/reverseproxy/caddyfile.go index 14de230..ebea49e 100644 --- a/modules/caddyhttp/reverseproxy/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/caddyfile.go @@ -27,6 +27,7 @@ import ( "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" "github.com/dustin/go-humanize" ) @@ -85,10 +86,12 @@ func parseCaddyfile(h httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) // buffer_responses // max_buffer_size // -// # header manipulation +// # request manipulation // trusted_proxies [private_ranges] // header_up [+|-] [ []] // header_down [+|-] [ []] +// method +// rewrite // // # round trip // transport { @@ -600,6 +603,30 @@ func (h *Handler) UnmarshalCaddyfile(d *caddyfile.Dispenser) error { return d.Err(err.Error()) } + case "method": + if !d.NextArg() { + return d.ArgErr() + } + if h.Rewrite == nil { + h.Rewrite = &rewrite.Rewrite{} + } + h.Rewrite.Method = d.Val() + if d.NextArg() { + return d.ArgErr() + } + + case "rewrite": + if !d.NextArg() { + return d.ArgErr() + } + if h.Rewrite == nil { + h.Rewrite = &rewrite.Rewrite{} + } + h.Rewrite.URI = d.Val() + if d.NextArg() { + return d.ArgErr() + } + case "transport": if !d.NextArg() { return d.ArgErr() diff --git a/modules/caddyhttp/reverseproxy/fastcgi/caddyfile.go b/modules/caddyhttp/reverseproxy/fastcgi/caddyfile.go index 1b4cecf..96b84f2 100644 --- a/modules/caddyhttp/reverseproxy/fastcgi/caddyfile.go +++ b/modules/caddyhttp/reverseproxy/fastcgi/caddyfile.go @@ -196,7 +196,15 @@ func parsePHPFastCGI(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error // NOTE: we delete the tokens as we go so that the reverse_proxy // unmarshal doesn't see these subdirectives which it cannot handle for dispenser.Next() { - for dispenser.NextBlock(0) && dispenser.Nesting() == 1 { + for dispenser.NextBlock(0) { + // ignore any sub-subdirectives that might + // have the same name somewhere within + // the reverse_proxy passthrough tokens + if dispenser.Nesting() != 1 { + continue + } + + // parse the php_fastcgi subdirectives switch dispenser.Val() { case "root": if !dispenser.NextArg() { diff --git a/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go b/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go new file mode 100644 index 0000000..1571f09 --- /dev/null +++ b/modules/caddyhttp/reverseproxy/forwardauth/caddyfile.go @@ -0,0 +1,269 @@ +// Copyright 2015 Matthew Holt and The Caddy Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package forwardauth + +import ( + "encoding/json" + "net/http" + + "github.com/caddyserver/caddy/v2" + "github.com/caddyserver/caddy/v2/caddyconfig" + "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile" + "github.com/caddyserver/caddy/v2/modules/caddyhttp" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/reverseproxy" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" +) + +func init() { + httpcaddyfile.RegisterDirective("forward_auth", parseCaddyfile) +} + +// parseCaddyfile parses the forward_auth directive, which has the same syntax +// as the reverse_proxy directive (in fact, the reverse_proxy's directive +// Unmarshaler is invoked by this function) but the resulting proxy is specially +// configured for most™️ auth gateways that support forward auth. The typical +// config which looks something like this: +// +// forward_auth auth-gateway:9091 { +// uri /authenticate?redirect=https://auth.example.com +// copy_headers Remote-User Remote-Email +// } +// +// is equivalent to a reverse_proxy directive like this: +// +// reverse_proxy auth-gateway:9091 { +// method GET +// rewrite /authenticate?redirect=https://auth.example.com +// +// header_up X-Forwarded-Method {method} +// header_up X-Forwarded-Uri {uri} +// +// @good status 2xx +// handle_response @good { +// request_header { +// Remote-User {http.reverse_proxy.header.Remote-User} +// Remote-Email {http.reverse_proxy.header.Remote-Email} +// } +// } +// +// handle_response { +// copy_response_headers { +// exclude Connection Keep-Alive Te Trailers Transfer-Encoding Upgrade +// } +// copy_response +// } +// } +// +func parseCaddyfile(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) { + if !h.Next() { + return nil, h.ArgErr() + } + + // if the user specified a matcher token, use that + // matcher in a route that wraps both of our routes; + // either way, strip the matcher token and pass + // the remaining tokens to the unmarshaler so that + // we can gain the rest of the reverse_proxy syntax + userMatcherSet, err := h.ExtractMatcherSet() + if err != nil { + return nil, err + } + + // make a new dispenser from the remaining tokens so that we + // can reset the dispenser back to this point for the + // reverse_proxy unmarshaler to read from it as well + dispenser := h.NewFromNextSegment() + + // create the reverse proxy handler + rpHandler := &reverseproxy.Handler{ + // set up defaults for header_up; reverse_proxy already deals with + // adding the other three X-Forwarded-* headers, but for this flow, + // we want to also send along the incoming method and URI since this + // request will have a rewritten URI and method. + Headers: &headers.Handler{ + Request: &headers.HeaderOps{ + Set: http.Header{ + "X-Forwarded-Method": []string{"{http.request.method}"}, + "X-Forwarded-Uri": []string{"{http.request.uri}"}, + }, + }, + }, + + // we always rewrite the method to GET, which implicitly + // turns off sending the incoming request's body, which + // allows later middleware handlers to consume it + Rewrite: &rewrite.Rewrite{ + Method: "GET", + }, + + HandleResponse: []caddyhttp.ResponseHandler{}, + } + + // collect the headers to copy from the auth response + // onto the original request, so they can get passed + // through to a backend app + headersToCopy := []string{} + + // read the subdirectives for configuring the forward_auth shortcut + // NOTE: we delete the tokens as we go so that the reverse_proxy + // unmarshal doesn't see these subdirectives which it cannot handle + for dispenser.Next() { + for dispenser.NextBlock(0) { + // ignore any sub-subdirectives that might + // have the same name somewhere within + // the reverse_proxy passthrough tokens + if dispenser.Nesting() != 1 { + continue + } + + // parse the forward_auth subdirectives + switch dispenser.Val() { + case "uri": + if !dispenser.NextArg() { + return nil, dispenser.ArgErr() + } + rpHandler.Rewrite.URI = dispenser.Val() + dispenser.Delete() + dispenser.Delete() + + case "copy_headers": + args := dispenser.RemainingArgs() + dispenser.Delete() + for _, headerField := range args { + dispenser.Delete() + headersToCopy = append(headersToCopy, headerField) + } + if len(headersToCopy) == 0 { + return nil, dispenser.ArgErr() + } + } + } + } + + // reset the dispenser after we're done so that the reverse_proxy + // unmarshaler can read it from the start + dispenser.Reset() + + // the auth target URI must not be empty + if rpHandler.Rewrite.URI == "" { + return nil, dispenser.Errf("the 'uri' subdirective is required") + } + + // set up handler for good responses; when a response + // has 2xx status, then we will copy some headers from + // the response onto the original request, and allow + // handling to continue down the middleware chain, + // by _not_ executing a terminal handler. + goodResponseHandler := caddyhttp.ResponseHandler{ + Match: &caddyhttp.ResponseMatcher{ + StatusCode: []int{2}, + }, + Routes: []caddyhttp.Route{}, + } + if len(headersToCopy) > 0 { + handler := &headers.Handler{ + Request: &headers.HeaderOps{ + Set: http.Header{}, + }, + } + + for _, headerField := range headersToCopy { + handler.Request.Set[headerField] = []string{ + "{http.reverse_proxy.header." + headerField + "}", + } + } + + goodResponseHandler.Routes = append( + goodResponseHandler.Routes, + caddyhttp.Route{ + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject( + handler, + "handler", + "headers", + nil, + )}, + }, + ) + } + rpHandler.HandleResponse = append(rpHandler.HandleResponse, goodResponseHandler) + + // set up handler for denial responses; when a response + // has any other status than 2xx, then we copy the response + // back to the client, and terminate handling. + denialResponseHandler := caddyhttp.ResponseHandler{ + Routes: []caddyhttp.Route{ + { + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject( + &reverseproxy.CopyResponseHeadersHandler{ + Exclude: []string{ + "Connection", + "Keep-Alive", + "Te", + "Trailers", + "Transfer-Encoding", + "Upgrade", + }, + }, + "handler", + "copy_response_headers", + nil, + )}, + }, + { + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject( + &reverseproxy.CopyResponseHandler{}, + "handler", + "copy_response", + nil, + )}, + }, + }, + } + rpHandler.HandleResponse = append(rpHandler.HandleResponse, denialResponseHandler) + + // the rest of the config is specified by the user + // using the reverse_proxy directive syntax + err = rpHandler.UnmarshalCaddyfile(dispenser) + if err != nil { + return nil, err + } + err = rpHandler.FinalizeUnmarshalCaddyfile(h) + if err != nil { + return nil, err + } + + // create the final reverse proxy route + rpRoute := caddyhttp.Route{ + HandlersRaw: []json.RawMessage{caddyconfig.JSONModuleObject( + rpHandler, + "handler", + "reverse_proxy", + nil, + )}, + } + + // apply the user's matcher if any + if userMatcherSet != nil { + rpRoute.MatcherSetsRaw = []caddy.ModuleMap{userMatcherSet} + } + + return []httpcaddyfile.ConfigValue{ + { + Class: "route", + Value: rpRoute, + }, + }, nil +} diff --git a/modules/caddyhttp/reverseproxy/reverseproxy.go b/modules/caddyhttp/reverseproxy/reverseproxy.go index d69b04e..07eb99c 100644 --- a/modules/caddyhttp/reverseproxy/reverseproxy.go +++ b/modules/caddyhttp/reverseproxy/reverseproxy.go @@ -35,6 +35,7 @@ import ( "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile" "github.com/caddyserver/caddy/v2/modules/caddyhttp" "github.com/caddyserver/caddy/v2/modules/caddyhttp/headers" + "github.com/caddyserver/caddy/v2/modules/caddyhttp/rewrite" "go.uber.org/zap" "golang.org/x/net/http/httpguts" ) @@ -136,6 +137,18 @@ type Handler struct { // used for the requests and responses (in bytes). MaxBufferSize int64 `json:"max_buffer_size,omitempty"` + // If configured, rewrites the copy of the upstream request. + // Allows changing the request method and URI (path and query). + // Since the rewrite is applied to the copy, it does not persist + // past the reverse proxy handler. + // If the method is changed to `GET` or `HEAD`, the request body + // will not be copied to the backend. This allows a later request + // handler -- either in a `handle_response` route, or after -- to + // read the body. + // By default, no rewrite is performed, and the method and URI + // from the incoming request is used as-is for proxying. + Rewrite *rewrite.Rewrite `json:"rewrite,omitempty"` + // List of handlers and their associated matchers to evaluate // after successful roundtrips. The first handler that matches // the response from a backend will be invoked. The response @@ -258,6 +271,13 @@ func (h *Handler) Provision(ctx caddy.Context) error { } } + if h.Rewrite != nil { + err := h.Rewrite.Provision(ctx) + if err != nil { + return fmt.Errorf("provisioning rewrite: %v", err) + } + } + // set up transport if h.Transport == nil { t := &HTTPTransport{} @@ -385,7 +405,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer) // prepare the request for proxying; this is needed only once - clonedReq, err := h.prepareRequest(r) + clonedReq, err := h.prepareRequest(r, repl) if err != nil { return caddyhttp.Error(http.StatusInternalServerError, fmt.Errorf("preparing request for upstream round-trip: %v", err)) @@ -412,7 +432,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht var proxyErr error for { var done bool - done, proxyErr = h.proxyLoopIteration(clonedReq, w, proxyErr, start, repl, reqHeader, reqHost, next) + done, proxyErr = h.proxyLoopIteration(clonedReq, r, w, proxyErr, start, repl, reqHeader, reqHost, next) if done { break } @@ -429,7 +449,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyht // that has to be passed in, we brought this into its own method so that we could run defer more easily. // It returns true when the loop is done and should break; false otherwise. The error value returned should // be assigned to the proxyErr value for the next iteration of the loop (or the error handled after break). -func (h *Handler) proxyLoopIteration(r *http.Request, w http.ResponseWriter, proxyErr error, start time.Time, +func (h *Handler) proxyLoopIteration(r *http.Request, origReq *http.Request, w http.ResponseWriter, proxyErr error, start time.Time, repl *caddy.Replacer, reqHeader http.Header, reqHost string, next caddyhttp.Handler) (bool, error) { // get the updated list of upstreams upstreams := h.Upstreams @@ -503,7 +523,7 @@ func (h *Handler) proxyLoopIteration(r *http.Request, w http.ResponseWriter, pro } // proxy the request to that upstream - proxyErr = h.reverseProxy(w, r, repl, dialInfo, next) + proxyErr = h.reverseProxy(w, r, origReq, repl, dialInfo, next) if proxyErr == nil || proxyErr == context.Canceled { // context.Canceled happens when the downstream client // cancels the request, which is not our failure @@ -536,9 +556,20 @@ func (h *Handler) proxyLoopIteration(r *http.Request, w http.ResponseWriter, pro // properties of the cloned request and should be done just once (before // proxying) regardless of proxy retries. This assumes that no mutations // of the cloned request are performed by h during or after proxying. -func (h Handler) prepareRequest(req *http.Request) (*http.Request, error) { +func (h Handler) prepareRequest(req *http.Request, repl *caddy.Replacer) (*http.Request, error) { req = cloneRequest(req) + // if enabled, perform rewrites on the cloned request; if + // the method is GET or HEAD, prevent the request body + // from being copied to the upstream + if h.Rewrite != nil { + changed := h.Rewrite.Rewrite(req, repl) + if changed && (h.Rewrite.Method == "GET" || h.Rewrite.Method == "HEAD") { + req.ContentLength = 0 + req.Body = nil + } + } + // if enabled, buffer client request; this should only be // enabled if the upstream requires it and does not work // with "slow clients" (gunicorn, etc.) - this obviously @@ -547,7 +578,7 @@ func (h Handler) prepareRequest(req *http.Request) (*http.Request, error) { // attacks, so it is strongly recommended to only use this // feature if absolutely required, if read timeouts are // set, and if body size is limited - if h.BufferRequests { + if h.BufferRequests && req.Body != nil { req.Body = h.bufferedBody(req.Body) } @@ -673,10 +704,7 @@ func (h Handler) addForwardedHeaders(req *http.Request) error { // we pass through the request Host as-is, but in situations // where we proxy over HTTPS, the user may need to override // Host themselves, so it's helpful to send the original too. - host, _, err := net.SplitHostPort(req.Host) - if err != nil { - host = req.Host // OK; there probably was no port - } + host := req.Host prior, ok, omit = lastHeaderValue(req.Header, "X-Forwarded-Host") if trusted && ok && prior != "" { host = prior @@ -691,7 +719,7 @@ func (h Handler) addForwardedHeaders(req *http.Request) error { // reverseProxy performs a round-trip to the given backend and processes the response with the client. // (This method is mostly the beginning of what was borrowed from the net/http/httputil package in the // Go standard library which was used as the foundation.) -func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, repl *caddy.Replacer, di DialInfo, next caddyhttp.Handler) error { +func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, origReq *http.Request, repl *caddy.Replacer, di DialInfo, next caddyhttp.Handler) error { _ = di.Upstream.Host.countRequest(1) //nolint:errcheck defer di.Upstream.Host.countRequest(-1) @@ -798,17 +826,19 @@ func (h *Handler) reverseProxy(rw http.ResponseWriter, req *http.Request, repl * // we make some data available via request context to child routes // so that they may inherit some options and functions from the // handler, and be able to copy the response. + // we use the original request here, so that any routes from 'next' + // see the original request rather than the proxy cloned request. hrc := &handleResponseContext{ handler: h, response: res, start: start, logger: logger, } - ctx := req.Context() + ctx := origReq.Context() ctx = context.WithValue(ctx, proxyHandleResponseContextCtxKey, hrc) // pass the request through the response handler routes - routeErr := rh.Routes.Compile(next).ServeHTTP(rw, req.WithContext(ctx)) + routeErr := rh.Routes.Compile(next).ServeHTTP(rw, origReq.WithContext(ctx)) // if the response handler routes already finalized the response, // we can return early. It should be finalized if the routes executed -- cgit v1.2.3