authorize: handle gRPC requests (#5400)

This commit is contained in:
Caleb Doxsey 2024-12-19 08:46:53 -07:00 committed by GitHub
parent 84da474816
commit 85ef08b3a0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 170 additions and 81 deletions

View file

@ -5,11 +5,11 @@ import (
"encoding/json"
"errors"
"io"
"maps"
"net/http"
"net/http/httptest"
"net/url"
"sort"
"strconv"
"strings"
envoy_config_core_v3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3"
@ -109,19 +109,11 @@ func invalidClientCertReason(reasons criteria.Reasons) bool {
}
func (a *Authorize) okResponse(headers http.Header) *envoy_service_auth_v3.CheckResponse {
var requestHeaders []*envoy_config_core_v3.HeaderValueOption
for k, vs := range headers {
requestHeaders = append(requestHeaders, mkHeader(k, strings.Join(vs, ",")))
}
// ensure request headers are sorted by key for deterministic output
sort.Slice(requestHeaders, func(i, j int) bool {
return requestHeaders[i].Header.Key < requestHeaders[j].Header.Value
})
return &envoy_service_auth_v3.CheckResponse{
Status: &status.Status{Code: int32(codes.OK), Message: "OK"},
HttpResponse: &envoy_service_auth_v3.CheckResponse_OkResponse{
OkResponse: &envoy_service_auth_v3.OkHttpResponse{
Headers: requestHeaders,
Headers: toEnvoyHeaders(headers),
},
},
}
@ -130,9 +122,11 @@ func (a *Authorize) okResponse(headers http.Header) *envoy_service_auth_v3.Check
func (a *Authorize) deniedResponse(
ctx context.Context,
in *envoy_service_auth_v3.CheckRequest,
code int32, reason string, headers map[string]string,
code int32, reason string, headers http.Header,
) (*envoy_service_auth_v3.CheckResponse, error) {
respHeader := []*envoy_config_core_v3.HeaderValueOption{}
if headers == nil {
headers = make(http.Header)
}
var respBody []byte
@ -163,25 +157,21 @@ func (a *Authorize) deniedResponse(
"reason": statusReason, // must correspond to k8s StatusReason strings
"code": code, // http code
})
respHeader = append(respHeader,
mkHeader("Content-Type", "application/json"))
headers.Set("Content-Type", "application/json")
case getCheckRequestURL(in).Path == "/robots.txt":
code = 200
respBody = []byte("User-agent: *\nDisallow: /")
respHeader = append(respHeader,
mkHeader("Content-Type", "text/plain"))
headers.Set("Content-Type", "text/plain")
case isJSONWebRequest(in):
respBody, _ = json.Marshal(map[string]any{
"error": reason,
"request_id": requestid.FromContext(ctx),
})
respHeader = append(respHeader,
mkHeader("Content-Type", "application/json"))
headers.Set("Content-Type", "application/json")
case isGRPCRequest(in):
return deniedResponseForGRPC(code, reason, headers), nil
case isGRPCWebRequest(in):
respHeader = append(respHeader,
mkHeader("Content-Type", "application/grpc-web+json"),
mkHeader("grpc-status", strconv.Itoa(int(codes.Unauthenticated))),
mkHeader("grpc-message", codes.Unauthenticated.String()))
return deniedResponseForGRPCWeb(code, reason, headers), nil
default:
// create a http response writer recorder
w := httptest.NewRecorder()
@ -210,27 +200,12 @@ func (a *Authorize) deniedResponse(
log.Ctx(ctx).Error().Err(err).Msg("error executing error template")
return nil, err
}
// convert go headers to envoy headers
respHeader = append(respHeader, toEnvoyHeaders(resp.Header)...)
for k, vs := range resp.Header {
headers[k] = vs
}
}
// add any additional headers
for k, v := range headers {
respHeader = append(respHeader, mkHeader(k, v))
}
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: respHeader,
Body: string(respBody),
},
},
}, nil
return mkDeniedCheckResponse(code, headers, string(respBody)), nil
}
func (a *Authorize) requireLoginResponse(
@ -242,7 +217,7 @@ func (a *Authorize) requireLoginResponse(
state := a.state.Load()
if !a.shouldRedirect(in) {
return a.deniedResponse(ctx, in, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized), nil)
return a.deniedResponse(ctx, in, http.StatusUnauthorized, "Unauthenticated", nil)
}
idp, err := options.GetIdentityProviderForPolicy(request.Policy)
@ -260,8 +235,8 @@ func (a *Authorize) requireLoginResponse(
return nil, err
}
return a.deniedResponse(ctx, in, http.StatusFound, "Login", map[string]string{
"Location": redirectTo,
return a.deniedResponse(ctx, in, http.StatusFound, "Login", http.Header{
"Location": {redirectTo},
})
}
@ -285,7 +260,7 @@ func (a *Authorize) requireWebAuthnResponse(
}
if !a.shouldRedirect(in) {
return a.deniedResponse(ctx, in, http.StatusUnauthorized, http.StatusText(http.StatusUnauthorized), nil)
return a.deniedResponse(ctx, in, http.StatusUnauthorized, "Unauthenticated", nil)
}
q := url.Values{}
@ -303,11 +278,26 @@ func (a *Authorize) requireWebAuthnResponse(
}
q.Set(urlutil.QueryIdentityProviderID, idp.GetId())
signinURL := urlutil.WebAuthnURL(getHTTPRequestFromCheckRequest(in), &checkRequestURL, state.sharedKey, q)
return a.deniedResponse(ctx, in, http.StatusFound, "Login", map[string]string{
"Location": signinURL,
return a.deniedResponse(ctx, in, http.StatusFound, "Login", http.Header{
"Location": {signinURL},
})
}
func mkDeniedCheckResponse(httpStatusCode int32, headers http.Header, body string) *envoy_service_auth_v3.CheckResponse {
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(httpStatusCode),
},
Headers: toEnvoyHeaders(headers),
Body: body,
},
},
}
}
func mkHeader(k, v string) *envoy_config_core_v3.HeaderValueOption {
return &envoy_config_core_v3.HeaderValueOption{
Header: &envoy_config_core_v3.HeaderValue{
@ -319,16 +309,13 @@ func mkHeader(k, v string) *envoy_config_core_v3.HeaderValueOption {
}
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)))
for k, vs := range maps.All(headers) {
envoyHeaders = append(envoyHeaders, mkHeader(k, strings.Join(vs, ",")))
}
sort.Slice(envoyHeaders, func(i, j int) bool {
return envoyHeaders[i].GetHeader().GetKey() < envoyHeaders[j].GetHeader().GetKey()
})
return envoyHeaders
}
@ -363,7 +350,7 @@ func (a *Authorize) shouldRedirect(in *envoy_service_auth_v3.CheckRequest) bool
return true
}
if strings.HasPrefix(requestHeaders["content-type"], "application/grpc") {
if isGRPCRequest(in) {
return false
}
@ -387,29 +374,6 @@ func (a *Authorize) shouldRedirect(in *envoy_service_auth_v3.CheckRequest) bool
return mediaType == "text/html"
}
func isGRPCWebRequest(in *envoy_service_auth_v3.CheckRequest) bool {
hdrs := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
if hdrs == nil {
return false
}
v := getHeader(hdrs, "Accept")
if v == "" {
return false
}
accept, err := rfc7231.ParseAccept(v)
if err != nil {
return false
}
mediaType, _ := accept.MostAcceptable([]string{
"text/html",
"application/grpc-web-text",
})
return mediaType == "application/grpc-web-text"
}
func isJSONWebRequest(in *envoy_service_auth_v3.CheckRequest) bool {
hdrs := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
if hdrs == nil {

View file

@ -0,0 +1,81 @@
package authorize
import (
"net/http"
"strconv"
"strings"
envoy_service_auth_v3 "github.com/envoyproxy/go-control-plane/envoy/service/auth/v3"
"github.com/tniswong/go.rfcx/rfc7231"
"google.golang.org/grpc/codes"
)
func isGRPCRequest(in *envoy_service_auth_v3.CheckRequest) bool {
hdrs := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
if hdrs == nil {
return false
}
return hdrs["content-type"] == "application/grpc" || strings.HasPrefix(hdrs["content-type"], "application/grpc+")
}
func isGRPCWebRequest(in *envoy_service_auth_v3.CheckRequest) bool {
hdrs := in.GetAttributes().GetRequest().GetHttp().GetHeaders()
if hdrs == nil {
return false
}
v := getHeader(hdrs, "Accept")
if v == "" {
return false
}
accept, err := rfc7231.ParseAccept(v)
if err != nil {
return false
}
mediaType, _ := accept.MostAcceptable([]string{
"text/html",
"application/grpc-web-text",
})
return mediaType == "application/grpc-web-text"
}
func deniedResponseForGRPC(
code int32, reason string, headers http.Header,
) *envoy_service_auth_v3.CheckResponse {
headers.Set("Content-Type", "application/grpc+json")
headers["grpc-status"] = []string{strconv.Itoa(int(httpStatusCodeToGRPCStatusCode(code)))}
headers["grpc-message"] = []string{reason}
return mkDeniedCheckResponse(code, headers, "")
}
func deniedResponseForGRPCWeb(
code int32, reason string, headers http.Header,
) *envoy_service_auth_v3.CheckResponse {
headers.Set("Content-Type", "application/grpc-web+json")
headers["grpc-status"] = []string{strconv.Itoa(int(httpStatusCodeToGRPCStatusCode(code)))}
headers["grpc-message"] = []string{reason}
return mkDeniedCheckResponse(code, headers, "")
}
func httpStatusCodeToGRPCStatusCode(httpStatusCode int32) codes.Code {
// from https://github.com/grpc/grpc/blob/master/doc/http-grpc-status-mapping.md
switch httpStatusCode {
case http.StatusBadRequest:
return codes.Internal
case http.StatusUnauthorized:
return codes.Unauthenticated
case http.StatusForbidden:
return codes.PermissionDenied
case http.StatusNotFound:
return codes.Unimplemented
case http.StatusTooManyRequests,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return codes.Unavailable
default:
return codes.Unknown
}
}

View file

@ -230,6 +230,50 @@ func TestAuthorize_deniedResponse(t *testing.T) {
}`, res)
})
t.Run("grpc", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
ctx = requestid.WithValue(ctx, "REQUESTID")
res, err := a.deniedResponse(ctx, &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{
"content-type": "application/grpc+json",
},
},
},
},
}, http.StatusBadRequest, "ERROR", nil)
assert.NoError(t, err)
testutil.AssertProtoJSONEqual(t, `{
"deniedResponse": {
"headers": [
{
"appendAction": "OVERWRITE_IF_EXISTS_OR_ADD",
"header": { "key": "Content-Type", "value": "application/grpc+json" }
},
{
"appendAction": "OVERWRITE_IF_EXISTS_OR_ADD",
"header": { "key": "grpc-message", "value": "ERROR" }
},
{
"appendAction": "OVERWRITE_IF_EXISTS_OR_ADD",
"header": { "key": "grpc-status", "value": "13" }
}
],
"status": {
"code": "BadRequest"
}
},
"status": {
"code": 7,
"message": "Access Denied"
}
}`, res)
})
t.Run("grpc-web", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
@ -256,11 +300,11 @@ func TestAuthorize_deniedResponse(t *testing.T) {
},
{
"appendAction": "OVERWRITE_IF_EXISTS_OR_ADD",
"header": { "key": "grpc-status", "value": "16" }
"header": { "key": "grpc-message", "value": "ERROR" }
},
{
"appendAction": "OVERWRITE_IF_EXISTS_OR_ADD",
"header": { "key": "grpc-message", "value": "Unauthenticated" }
"header": { "key": "grpc-status", "value": "13" }
}
],
"status": {