control plane: add request id to all error pages (#2149)

* controlplane: add request id to all error pages

- use a single http error handler for both envoy and go control plane
- add http lib style status text for our custom statuses.

Signed-off-by: Bobby DeSimone <bobbydesimone@gmail.com>
This commit is contained in:
bobby 2021-04-28 15:04:44 -07:00 committed by GitHub
parent 91c7dc742f
commit 9215833a0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 126 additions and 136 deletions

View file

@ -1,12 +1,13 @@
package authorize package authorize
import ( import (
"bytes"
"context" "context"
"errors"
"io"
"net/http" "net/http"
"net/http/httptest"
"net/url" "net/url"
"sort" "sort"
"strings"
envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3" envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
@ -18,6 +19,7 @@ import (
"github.com/pomerium/pomerium/authorize/evaluator" "github.com/pomerium/pomerium/authorize/evaluator"
"github.com/pomerium/pomerium/internal/httputil" "github.com/pomerium/pomerium/internal/httputil"
"github.com/pomerium/pomerium/internal/log" "github.com/pomerium/pomerium/internal/log"
"github.com/pomerium/pomerium/internal/telemetry/requestid"
"github.com/pomerium/pomerium/internal/urlutil" "github.com/pomerium/pomerium/internal/urlutil"
) )
@ -41,79 +43,51 @@ func (a *Authorize) okResponse(reply *evaluator.Result) *envoy_service_auth_v3.C
} }
func (a *Authorize) deniedResponse( func (a *Authorize) deniedResponse(
ctx context.Context,
in *envoy_service_auth_v3.CheckRequest, in *envoy_service_auth_v3.CheckRequest,
code int32, reason string, headers map[string]string, code int32, reason string, headers map[string]string,
) (*envoy_service_auth_v3.CheckResponse, error) { ) (*envoy_service_auth_v3.CheckResponse, error) {
returnHTMLError := true
inHeaders := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
if inHeaders != nil {
returnHTMLError = strings.Contains(inHeaders["accept"], "text/html")
}
if returnHTMLError {
return a.htmlDeniedResponse(in, code, reason, headers)
}
return a.plainTextDeniedResponse(code, reason, headers), nil
}
func (a *Authorize) htmlDeniedResponse(
in *envoy_service_auth_v3.CheckRequest,
code int32, reason string, headers map[string]string,
) (*envoy_service_auth_v3.CheckResponse, error) {
opts := a.currentOptions.Load()
authenticateURL, err := opts.GetAuthenticateURL()
if err != nil {
return nil, err
}
debugEndpoint := authenticateURL.ResolveReference(&url.URL{Path: "/.pomerium/"})
// create go-style http request
r := getHTTPRequestFromCheckRequest(in)
redirectURL := urlutil.GetAbsoluteURL(r).String()
if ref := r.Header.Get(httputil.HeaderReferrer); ref != "" {
redirectURL = ref
}
debugEndpoint = debugEndpoint.ResolveReference(&url.URL{
RawQuery: url.Values{
urlutil.QueryRedirectURI: {redirectURL},
}.Encode(),
})
debugEndpoint = urlutil.NewSignedURL(a.state.Load().sharedKey, debugEndpoint).Sign()
var details string var details string
switch code { switch code {
case httputil.StatusInvalidClientCertificate: case httputil.StatusInvalidClientCertificate:
details = "a valid client certificate is required to access this page" details = httputil.StatusText(httputil.StatusInvalidClientCertificate)
case http.StatusForbidden: case http.StatusForbidden:
details = "access to this page is forbidden" details = http.StatusText(http.StatusForbidden)
default: default:
details = reason details = reason
} }
if reason == "" { // create a http response writer recorder
reason = http.StatusText(int(code)) w := httptest.NewRecorder()
} r := getHTTPRequestFromCheckRequest(in)
var buf bytes.Buffer // build the user info / debug endpoint
err = a.templates.ExecuteTemplate(&buf, "error.html", map[string]interface{}{ debugEndpoint, _ := a.userInfoEndpointURL(in) // if there's an error, we just wont display it
"Status": code,
"StatusText": reason, // run the request through our go error handler
"CanDebug": code/100 == 4, httpErr := httputil.HTTPError{
"DebugURL": debugEndpoint, Status: int(code),
"Error": details, Err: errors.New(details),
}) DebugURL: debugEndpoint,
RequestID: requestid.FromContext(ctx),
}
httpErr.ErrorResponse(w, r)
// transpose the go http response writer into a envoy response
resp := w.Result()
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
buf.WriteString(reason) log.Error(ctx).Err(err).Msg("error executing error template")
log.Error(context.TODO()).Err(err).Msg("error executing error template") return nil, err
} }
// convert go headers to envoy headers
respHeader := toEnvoyHeaders(resp.Header)
envoyHeaders := []*envoy_config_core_v3.HeaderValueOption{ // add any additional headers
mkHeader("Content-Type", "text/html", false),
}
for k, v := range headers { for k, v := range headers {
envoyHeaders = append(envoyHeaders, mkHeader(k, v, false)) respHeader = append(respHeader, mkHeader(k, v, false))
} }
return &envoy_service_auth_v3.CheckResponse{ return &envoy_service_auth_v3.CheckResponse{
@ -123,36 +97,14 @@ func (a *Authorize) htmlDeniedResponse(
Status: &envoy_type_v3.HttpStatus{ Status: &envoy_type_v3.HttpStatus{
Code: envoy_type_v3.StatusCode(code), Code: envoy_type_v3.StatusCode(code),
}, },
Headers: envoyHeaders, Headers: respHeader,
Body: buf.String(), Body: string(respBody),
}, },
}, },
}, nil }, nil
} }
func (a *Authorize) plainTextDeniedResponse(code int32, reason string, headers map[string]string) *envoy_service_auth_v3.CheckResponse { func (a *Authorize) redirectResponse(ctx context.Context, in *envoy_service_auth_v3.CheckRequest) (*envoy_service_auth_v3.CheckResponse, error) {
envoyHeaders := []*envoy_config_core_v3.HeaderValueOption{
mkHeader("Content-Type", "text/plain", false),
}
for k, v := range headers {
envoyHeaders = append(envoyHeaders, mkHeader(k, v, false))
}
return &envoy_service_auth_v3.CheckResponse{
Status: &status.Status{Code: int32(codes.PermissionDenied), Message: "Access Denied"},
HttpResponse: &envoy_service_auth_v3.CheckResponse_DeniedResponse{
DeniedResponse: &envoy_service_auth_v3.DeniedHttpResponse{
Status: &envoy_type_v3.HttpStatus{
Code: envoy_type_v3.StatusCode(code),
},
Headers: envoyHeaders,
Body: reason,
},
},
}
}
func (a *Authorize) redirectResponse(in *envoy_service_auth_v3.CheckRequest) (*envoy_service_auth_v3.CheckResponse, error) {
opts := a.currentOptions.Load() opts := a.currentOptions.Load()
state := a.state.Load() state := a.state.Load()
authenticateURL, err := opts.GetAuthenticateURL() authenticateURL, err := opts.GetAuthenticateURL()
@ -173,7 +125,7 @@ func (a *Authorize) redirectResponse(in *envoy_service_auth_v3.CheckRequest) (*e
signinURL.RawQuery = q.Encode() signinURL.RawQuery = q.Encode()
redirectTo := urlutil.NewSignedURL(state.sharedKey, signinURL).String() redirectTo := urlutil.NewSignedURL(state.sharedKey, signinURL).String()
return a.deniedResponse(in, http.StatusFound, "Login", map[string]string{ return a.deniedResponse(ctx, in, http.StatusFound, "Login", map[string]string{
"Location": redirectTo, "Location": redirectTo,
}) })
} }
@ -189,3 +141,42 @@ func mkHeader(k, v string, shouldAppend bool) *envoy_config_core_v3.HeaderValueO
}, },
} }
} }
func toEnvoyHeaders(headers http.Header) []*envoy_config_core_v3.HeaderValueOption {
var ks []string
for k := range headers {
ks = append(ks, k)
}
sort.Strings(ks)
envoyHeaders := make([]*envoy_config_core_v3.HeaderValueOption, 0, len(headers))
for _, k := range ks {
envoyHeaders = append(envoyHeaders, mkHeader(k, headers.Get(k), false))
}
return envoyHeaders
}
// userInfoEndpointURL returns the user info endpoint url which can be used to debug the user's
// session that lives on the authenticate service.
func (a *Authorize) userInfoEndpointURL(in *envoy_service_auth_v3.CheckRequest) (*url.URL, error) {
opts := a.currentOptions.Load()
authenticateURL, err := opts.GetAuthenticateURL()
if err != nil {
return nil, err
}
debugEndpoint := authenticateURL.ResolveReference(&url.URL{Path: "/.pomerium/"})
r := getHTTPRequestFromCheckRequest(in)
redirectURL := urlutil.GetAbsoluteURL(r).String()
if ref := r.Header.Get(httputil.HeaderReferrer); ref != "" {
redirectURL = ref
}
debugEndpoint = debugEndpoint.ResolveReference(&url.URL{
RawQuery: url.Values{
urlutil.QueryRedirectURI: {redirectURL},
}.Encode(),
})
return urlutil.NewSignedURL(a.state.Load().sharedKey, debugEndpoint).Sign(), nil
}

View file

@ -1,6 +1,7 @@
package authorize package authorize
import ( import (
"context"
"html/template" "html/template"
"net/http" "net/http"
"net/url" "net/url"
@ -151,36 +152,8 @@ func TestAuthorize_deniedResponse(t *testing.T) {
Code: envoy_type_v3.StatusCode(codes.InvalidArgument), Code: envoy_type_v3.StatusCode(codes.InvalidArgument),
}, },
Headers: []*envoy_config_core_v3.HeaderValueOption{ Headers: []*envoy_config_core_v3.HeaderValueOption{
mkHeader("Content-Type", "text/html", false), mkHeader("Content-Type", "text/html; charset=UTF-8", false),
}, mkHeader("X-Pomerium-Intercepted-Response", "true", false),
Body: "Access Denied",
},
},
},
},
{
"plain text denied",
&envoy_service_auth_v3.CheckRequest{
Attributes: &envoy_service_auth_v3.AttributeContext{
Request: &envoy_service_auth_v3.AttributeContext_Request{
Http: &envoy_service_auth_v3.AttributeContext_HttpRequest{
Headers: map[string]string{},
},
},
},
},
http.StatusBadRequest,
"Access Denied",
map[string]string{},
&envoy_service_auth_v3.CheckResponse{
Status: &status.Status{Code: int32(codes.PermissionDenied), Message: "Access Denied"},
HttpResponse: &envoy_service_auth_v3.CheckResponse_DeniedResponse{
DeniedResponse: &envoy_service_auth_v3.DeniedHttpResponse{
Status: &envoy_type_v3.HttpStatus{
Code: envoy_type_v3.StatusCode(codes.InvalidArgument),
},
Headers: []*envoy_config_core_v3.HeaderValueOption{
mkHeader("Content-Type", "text/plain", false),
}, },
Body: "Access Denied", Body: "Access Denied",
}, },
@ -192,7 +165,7 @@ func TestAuthorize_deniedResponse(t *testing.T) {
tc := tc tc := tc
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
got, err := a.deniedResponse(tc.in, tc.code, tc.reason, tc.headers) got, err := a.deniedResponse(context.TODO(), tc.in, tc.code, tc.reason, tc.headers)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, tc.want.Status.Code, got.Status.Code) assert.Equal(t, tc.want.Status.Code, got.Status.Code)
assert.Equal(t, tc.want.Status.Message, got.Status.Message) assert.Equal(t, tc.want.Status.Message, got.Status.Message)

View file

@ -70,11 +70,11 @@ func (a *Authorize) Check(ctx context.Context, in *envoy_service_auth_v3.CheckRe
return a.okResponse(reply), nil return a.okResponse(reply), nil
case reply.Status == http.StatusUnauthorized: case reply.Status == http.StatusUnauthorized:
if isForwardAuth && hreq.URL.Path == "/verify" { if isForwardAuth && hreq.URL.Path == "/verify" {
return a.deniedResponse(in, http.StatusUnauthorized, "Unauthenticated", nil) return a.deniedResponse(ctx, in, http.StatusUnauthorized, "Unauthenticated", nil)
} }
return a.redirectResponse(in) return a.redirectResponse(ctx, in)
} }
return a.deniedResponse(in, int32(reply.Status), reply.Message, nil) return a.deniedResponse(ctx, in, int32(reply.Status), reply.Message, nil)
} }
func getForwardAuthURL(r *http.Request) *url.URL { func getForwardAuthURL(r *http.Request) *url.URL {

View file

@ -24,8 +24,17 @@
</div> </div>
</div> </div>
<div class="category-link"> <div class="category-link">
{{if and .CanDebug .DebugURL }} {{if .CanDebug}}
If you should have access, contact your administrator with your <a href="{{.DebugURL}}">session details</a>. If you should have access, contact your administrator with your
{{if .RequestID }}
request id {{.RequestID}}
{{end}}
{{if and .RequestID .DebugURL }}
and
{{end}}
{{if .DebugURL }}
<a href="{{.DebugURL}}">session details</a>.
{{end}}
{{end}} {{end}}
</div> </div>
</div> </div>

View file

@ -3,9 +3,9 @@ package httputil
import ( import (
"html/template" "html/template"
"net/http" "net/http"
"net/url"
"github.com/pomerium/pomerium/internal/frontend" "github.com/pomerium/pomerium/internal/frontend"
"github.com/pomerium/pomerium/internal/log"
"github.com/pomerium/pomerium/internal/telemetry/requestid" "github.com/pomerium/pomerium/internal/telemetry/requestid"
) )
@ -15,8 +15,12 @@ var errorTemplate = template.Must(frontend.NewTemplates())
type HTTPError struct { type HTTPError struct {
// HTTP status codes as registered with IANA. // HTTP status codes as registered with IANA.
Status int Status int
// Err is the wrapped error // Err is the wrapped error.
Err error Err error
// DebugURL is the URL to the debug endpoint.
DebugURL *url.URL
// The request ID.
RequestID string
} }
// NewError returns an error that contains a HTTP status and error. // NewError returns an error that contains a HTTP status and error.
@ -36,32 +40,35 @@ func (e *HTTPError) Unwrap() error { return e.Err }
// It does not otherwise end the request; the caller should ensure no further // It does not otherwise end the request; the caller should ensure no further
// writes are done to w. // writes are done to w.
func (e *HTTPError) ErrorResponse(w http.ResponseWriter, r *http.Request) { func (e *HTTPError) ErrorResponse(w http.ResponseWriter, r *http.Request) {
// indicate to clients that the error originates from Pomerium, not the app reqID := e.RequestID
w.Header().Set(HeaderPomeriumResponse, "true") if e.RequestID == "" {
w.WriteHeader(e.Status) // if empty, try to grab from the request id from the request context
reqID = requestid.FromContext(r.Context())
log.FromRequest(r).Info().Err(e).Msg("httputil: ErrorResponse") }
requestID := requestid.FromContext(r.Context())
response := struct { response := struct {
Status int Status int
Error string Error string
StatusText string `json:"-"` StatusText string `json:"-"`
RequestID string `json:",omitempty"` RequestID string `json:",omitempty"`
CanDebug bool `json:"-"` CanDebug bool `json:"-"`
Version string `json:"-"` Version string `json:"-"`
DebugURL *url.URL `json:",omitempty"`
}{ }{
Status: e.Status, Status: e.Status,
StatusText: http.StatusText(e.Status), StatusText: http.StatusText(e.Status),
Error: e.Error(), Error: e.Error(),
RequestID: requestID, RequestID: reqID,
CanDebug: e.Status/100 == 4, CanDebug: e.Status/100 == 4 && (e.DebugURL != nil || reqID != ""),
DebugURL: e.DebugURL,
} }
// indicate to clients that the error originates from Pomerium, not the app
w.Header().Set(HeaderPomeriumResponse, "true")
if r.Header.Get("Accept") == "application/json" { if r.Header.Get("Accept") == "application/json" {
RenderJSON(w, e.Status, response) RenderJSON(w, e.Status, response)
return return
} }
w.Header().Set("Content-Type", "text/html; charset=UTF-8") w.Header().Set("Content-Type", "text/html; charset=UTF-8")
w.WriteHeader(e.Status)
errorTemplate.ExecuteTemplate(w, "error.html", response) errorTemplate.ExecuteTemplate(w, "error.html", response)
} }

View file

@ -59,7 +59,7 @@ func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := f(w, r); err != nil { if err := f(w, r); err != nil {
var e *HTTPError var e *HTTPError
if !errors.As(err, &e) { if !errors.As(err, &e) {
e = &HTTPError{http.StatusInternalServerError, err} e = &HTTPError{Status: http.StatusInternalServerError, Err: err}
} }
e.ErrorResponse(w, r) e.ErrorResponse(w, r)
} }

View file

@ -4,3 +4,13 @@ package httputil
// client's certificate is invalid. This is the same status code used // client's certificate is invalid. This is the same status code used
// by nginx for this purpose. // by nginx for this purpose.
const StatusInvalidClientCertificate = 495 const StatusInvalidClientCertificate = 495
var statusText = map[int]string{
StatusInvalidClientCertificate: "a valid client certificate is required to access this page",
}
// StatusText returns a text for the HTTP status code. It returns the empty
// string if the code is unknown.
func StatusText(code int) string {
return statusText[code]
}