package authorization import ( "bufio" "bytes" "context" "fmt" "io" "mime" "net/http" "net/url" "regexp" "strings" "github.com/containerd/log" "github.com/moby/moby/v2/pkg/ioutils" ) const maxBodySize = 4 * 1024 * 1024 // 4MiB // NewCtx creates new authZ context, it is used to store authorization information related to a specific docker // REST http session // A context provides two method: // Authenticate Request: // Call authZ plugins with current REST request and AuthN response // Request contains full HTTP packet sent to the docker daemon // https://docs.docker.com/reference/api/engine/ // // Authenticate Response: // Call authZ plugins with full info about current REST request, REST response and AuthN response // The response from this method may contains content that overrides the daemon response // This allows authZ plugins to filter privileged content // // If multiple authZ plugins are specified, the block/allow decision is based on ANDing all plugin results // For response manipulation, the response from each plugin is piped between plugins. Plugin execution order // is determined according to daemon parameters func NewCtx(authZPlugins []Plugin, user, userAuthNMethod, requestMethod, requestURI string) *Ctx { return &Ctx{ plugins: authZPlugins, user: user, userAuthNMethod: userAuthNMethod, requestMethod: requestMethod, requestURI: requestURI, } } // Ctx stores a single request-response interaction context type Ctx struct { user string userAuthNMethod string requestMethod string requestURI string plugins []Plugin // authReq stores the cached request object for the current transaction authReq *Request } // AuthZRequest authorized the request to the docker daemon using authZ plugins func (ctx *Ctx) AuthZRequest(w http.ResponseWriter, r *http.Request) error { var body []byte if sendBody(ctx.requestURI, r.Header) { // Wrap the original request body in a buffered reader so we can inspect // the prefix without consuming bytes from the downstream reader. // `Peek(maxBodySize + 1)` is used as a size check: // - err == nil means at least maxBodySize+1 bytes are buffered/available, // so the payload exceeds the plugin limit and is rejected. // - otherwise, `peeked` contains the complete body bytes currently available // (for short bodies this is the full payload), and reads from r.Body still // stream the original body unchanged. bufBody := bufio.NewReaderSize(r.Body, maxBodySize+1) r.Body = ioutils.NewReadCloserWrapper(bufBody, r.Body.Close) peeked, err := bufBody.Peek(maxBodySize + 1) if err == nil { // Successfully peeked maxBodySize+1 bytes, so body is too large // TODO: Allows plugin to opt in return fmt.Errorf("request body too large for authorization plugin: size exceeds %d bytes", maxBodySize) } else if err != io.EOF { return err } body = peeked } var h bytes.Buffer if err := r.Header.Write(&h); err != nil { return err } ctx.authReq = &Request{ User: ctx.user, UserAuthNMethod: ctx.userAuthNMethod, RequestMethod: ctx.requestMethod, RequestURI: ctx.requestURI, RequestBody: body, RequestHeaders: headers(r.Header), } if r.TLS != nil { for _, c := range r.TLS.PeerCertificates { pc := PeerCertificate(*c) ctx.authReq.RequestPeerCertificates = append(ctx.authReq.RequestPeerCertificates, &pc) } } for _, plugin := range ctx.plugins { log.G(context.TODO()).Debugf("AuthZ request using plugin %s", plugin.Name()) authRes, err := plugin.AuthZRequest(ctx.authReq) if err != nil { return fmt.Errorf("plugin %s failed with error: %s", plugin.Name(), err) } if !authRes.Allow { return newAuthorizationError(plugin.Name(), authRes.Msg) } } return nil } // AuthZResponse authorized and manipulates the response from docker daemon using authZ plugins func (ctx *Ctx) AuthZResponse(rm ResponseModifier, r *http.Request) error { ctx.authReq.ResponseStatusCode = rm.StatusCode() ctx.authReq.ResponseHeaders = headers(rm.Header()) if sendBody(ctx.requestURI, rm.Header()) { ctx.authReq.ResponseBody = rm.RawBody() } for _, plugin := range ctx.plugins { log.G(context.TODO()).Debugf("AuthZ response using plugin %s", plugin.Name()) authRes, err := plugin.AuthZResponse(ctx.authReq) if err != nil { return fmt.Errorf("plugin %s failed with error: %s", plugin.Name(), err) } if !authRes.Allow { return newAuthorizationError(plugin.Name(), authRes.Msg) } } rm.FlushAll() return nil } func isAuthEndpoint(urlPath string) (bool, error) { // eg www.test.com/v1.24/auth/optional?optional1=something&optional2=something (version optional) matched, err := regexp.MatchString(`^[^\/]*\/(v\d[\d\.]*\/)?auth.*`, urlPath) if err != nil { return false, err } return matched, nil } // sendBody returns true when request/response body should be sent to AuthZPlugin func sendBody(inURL string, header http.Header) bool { u, err := url.Parse(inURL) // Assume no if the URL cannot be parsed - an empty request will still be forwarded to the plugin and should be rejected if err != nil { return false } // Skip body for auth endpoint isAuth, err := isAuthEndpoint(u.Path) if isAuth || err != nil { return false } // body is sent only for text or json messages contentType, _, err := mime.ParseMediaType(header.Get("Content-Type")) if err != nil { return false } return contentType == "application/json" } // headers returns flatten version of the http headers excluding authorization func headers(header http.Header) map[string]string { v := make(map[string]string) for k, values := range header { // Skip authorization headers if strings.EqualFold(k, "Authorization") || strings.EqualFold(k, "X-Registry-Config") || strings.EqualFold(k, "X-Registry-Auth") { continue } for _, val := range values { v[k] = val } } return v } // authorizationError represents an authorization deny error type authorizationError struct { error } func (authorizationError) Forbidden() {} func newAuthorizationError(plugin, msg string) authorizationError { return authorizationError{error: fmt.Errorf("authorization denied by plugin %s: %s", plugin, msg)} }