// This file is part of MinIO Console Server // Copyright (c) 2021 MinIO, Inc. // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // This file is safe to edit. Once it exists it will not be overwritten package api import ( "bytes" "context" "crypto/tls" "fmt" "io" "io/fs" "log" "net" "net/http" "path" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" "github.com/google/uuid" "github.com/minio/console/pkg/logger" "github.com/minio/console/pkg/utils" "github.com/minio/minio-go/v7/pkg/credentials" "github.com/klauspost/compress/gzhttp" portal_ui "github.com/minio/console/web-app" "github.com/minio/pkg/v3/env" "github.com/minio/pkg/v3/mimedb" xnet "github.com/minio/pkg/v3/net" "github.com/go-openapi/errors" "github.com/go-openapi/swag" "github.com/minio/console/api/operations" "github.com/minio/console/models" "github.com/minio/console/pkg/auth" "github.com/unrolled/secure" ) //go:generate swagger generate server --target ../../console --name Console --spec ../swagger.yml var additionalServerFlags = struct { CertsDir string `long:"certs-dir" description:"path to certs directory" env:"CONSOLE_CERTS_DIR"` }{} const ( SubPath = "CONSOLE_SUBPATH" ) var ( cfgSubPath = "/" subPathOnce sync.Once ) func configureFlags(api *operations.ConsoleAPI) { api.CommandLineOptionsGroups = []swag.CommandLineOptionsGroup{ { ShortDescription: "additional server flags", Options: &additionalServerFlags, }, } } func configureAPI(api *operations.ConsoleAPI) http.Handler { // Applies when the "x-token" header is set api.KeyAuth = func(token string, _ []string) (*models.Principal, error) { // we are validating the session token by decrypting the claims inside, if the operation succeed that means the jwt // was generated and signed by us in the first place if token == "Anonymous" { return &models.Principal{}, nil } claims, err := auth.ParseClaimsFromToken(token) if err != nil { api.Logger("Unable to validate the session token %s: %v", token, err) return nil, errors.New(401, "incorrect api key auth") } return &models.Principal{ STSAccessKeyID: claims.STSAccessKeyID, STSSecretAccessKey: claims.STSSecretAccessKey, STSSessionToken: claims.STSSessionToken, AccountAccessKey: claims.AccountAccessKey, Hm: claims.HideMenu, Ob: claims.ObjectBrowser, CustomStyleOb: claims.CustomStyleOB, }, nil } api.AnonymousAuth = func(_ string) (*models.Principal, error) { return &models.Principal{}, nil } // Register login handlers registerLoginHandlers(api) // Register logout handlers registerLogoutHandlers(api) // Register bucket handlers registerBucketsHandlers(api) // Register all users handlers registerUsersHandlers(api) // Register groups handlers registerGroupsHandlers(api) // Register policies handlers registersPoliciesHandler(api) // Register configurations handlers registerConfigHandlers(api) // Register bucket events handlers registerBucketEventsHandlers(api) // Register service handlers registerServiceHandlers(api) // Register session handlers registerSessionHandlers(api) // Register admin info handlers registerAdminInfoHandlers(api) // Register admin arns handlers registerAdminArnsHandlers(api) // Register admin notification endpoints handlers registerAdminNotificationEndpointsHandlers(api) // Register admin Service Account Handlers registerServiceAccountsHandlers(api) // Register admin remote buckets registerAdminBucketRemoteHandlers(api) // Register admin log search registerLogSearchHandlers(api) // Register admin KMS handlers registerKMSHandlers(api) // Register admin IDP handlers registerIDPHandlers(api) // Register Inspect Handler registerInspectHandler(api) // Register nodes handlers registerNodesHandler(api) // Operator Console // Register Object's Handlers registerObjectsHandlers(api) // Register Bucket Quota's Handlers registerBucketQuotaHandlers(api) // Register Account handlers registerAccountHandlers(api) registerReleasesHandlers(api) registerPublicObjectsHandlers(api) api.PreServerShutdown = func() {} api.ServerShutdown = func() {} return setupGlobalMiddleware(api.Serve(setupMiddlewares)) } // The TLS configuration before HTTPS server starts. func configureTLS(tlsConfig *tls.Config) { tlsConfig.RootCAs = GlobalRootCAs tlsConfig.GetCertificate = GlobalTLSCertsManager.GetCertificate } // The middleware configuration is for the handler executors. These do not apply to the swagger.json document. // The middleware executes after routing but before authentication, binding and validation func setupMiddlewares(handler http.Handler) http.Handler { return handler } func ContextMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestID := uuid.NewString() ctx := context.WithValue(r.Context(), utils.ContextRequestID, requestID) ctx = context.WithValue(ctx, utils.ContextRequestUserAgent, r.UserAgent()) ctx = context.WithValue(ctx, utils.ContextRequestHost, r.Host) ctx = context.WithValue(ctx, utils.ContextRequestRemoteAddr, r.RemoteAddr) ctx = context.WithValue(ctx, utils.ContextClientIP, getClientIP(r)) next.ServeHTTP(w, r.WithContext(ctx)) }) } func AuditLogMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rw := logger.NewResponseWriter(w) next.ServeHTTP(rw, r) if strings.HasPrefix(r.URL.Path, "/ws") || strings.HasPrefix(r.URL.Path, "/api") { logger.AuditLog(r.Context(), rw, r, map[string]interface{}{}, "Authorization", "Cookie", "Set-Cookie") } }) } func DebugLogMiddleware(next http.Handler) http.Handler { debugLogLevel, _ := env.GetInt("CONSOLE_DEBUG_LOGLEVEL", 0) if debugLogLevel == 0 { return next } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { rw := logger.NewResponseWriter(w) next.ServeHTTP(rw, r) debugLog(debugLogLevel, r, rw) }) } func debugLog(debugLogLevel int, r *http.Request, rw *logger.ResponseWriter) { switch debugLogLevel { case 1: // Log server errors only (summary) if rw.StatusCode >= 500 { debugLogSummary(r, rw) } case 2: // Log server and client errors (summary) if rw.StatusCode >= 400 { debugLogSummary(r, rw) } case 3: // Log all requests (summary) debugLogSummary(r, rw) case 4: // Log server errors only (including headers) if rw.StatusCode >= 500 { debugLogDetails(r, rw) } case 5: // Log server and client errors (including headers) if rw.StatusCode >= 400 { debugLogDetails(r, rw) } case 6: // Log all requests (including headers) debugLogDetails(r, rw) } } func debugLogSummary(r *http.Request, rw *logger.ResponseWriter) { statusCode := strconv.Itoa(rw.StatusCode) if rw.Hijacked { statusCode = "hijacked" } logger.Info(fmt.Sprintf("%s %s %s %s %dms", r.RemoteAddr, r.Method, r.URL, statusCode, time.Since(rw.StartTime).Milliseconds())) } func debugLogDetails(r *http.Request, rw *logger.ResponseWriter) { var sb strings.Builder sb.WriteString(fmt.Sprintf("- Method/URL: %s %s\n", r.Method, r.URL)) sb.WriteString(fmt.Sprintf(" Remote endpoint: %s\n", r.RemoteAddr)) if rw.Hijacked { sb.WriteString(" Status code: \n") } else { sb.WriteString(fmt.Sprintf(" Status code: %d\n", rw.StatusCode)) } sb.WriteString(fmt.Sprintf(" Duration (ms): %d\n", time.Since(rw.StartTime).Milliseconds())) sb.WriteString(" Request headers: ") debugLogHeaders(&sb, r.Header) sb.WriteString(" Response headers: ") debugLogHeaders(&sb, rw.Header()) logger.Info(sb.String()) } func debugLogHeaders(sb *strings.Builder, h http.Header) { keys := make([]string, 0, len(h)) for key := range h { keys = append(keys, key) } sort.Strings(keys) first := true for _, key := range keys { values := h[key] for _, value := range values { if !first { sb.WriteString(" ") } else { first = false } sb.WriteString(fmt.Sprintf("%s: %s\n", key, value)) } } if first { sb.WriteRune('\n') } } // The middleware configuration happens before anything, this middleware also applies to serving the swagger.json document. // So this is a good place to plug in a panic handling middleware, logger and metrics func setupGlobalMiddleware(handler http.Handler) http.Handler { gnext := gzhttp.GzipHandler(handler) // if audit-log is enabled console will log all incoming request next := AuditLogMiddleware(gnext) // serve static files next = FileServerMiddleware(next) // add information to request context next = ContextMiddleware(next) // handle cookie or authorization header for session next = AuthenticationMiddleware(next) // handle debug logging next = DebugLogMiddleware(next) sslHostFn := secure.SSLHostFunc(func(host string) string { xhost, err := xnet.ParseHost(host) if err != nil { return host } return net.JoinHostPort(xhost.Name, TLSPort) }) // Secure middleware, this middleware wrap all the previous handlers and add // HTTP security headers secureOptions := secure.Options{ AllowedHosts: GetSecureAllowedHosts(), AllowedHostsAreRegex: GetSecureAllowedHostsAreRegex(), HostsProxyHeaders: GetSecureHostsProxyHeaders(), SSLRedirect: GetTLSRedirect() == "on" && len(GlobalPublicCerts) > 0, SSLHostFunc: &sslHostFn, SSLHost: GetSecureTLSHost(), STSSeconds: GetSecureSTSSeconds(), STSIncludeSubdomains: GetSecureSTSIncludeSubdomains(), STSPreload: GetSecureSTSPreload(), SSLTemporaryRedirect: false, ForceSTSHeader: GetSecureForceSTSHeader(), FrameDeny: GetSecureFrameDeny(), ContentTypeNosniff: GetSecureContentTypeNonSniff(), BrowserXssFilter: GetSecureBrowserXSSFilter(), ContentSecurityPolicy: GetSecureContentSecurityPolicy(), ContentSecurityPolicyReportOnly: GetSecureContentSecurityPolicyReportOnly(), ReferrerPolicy: GetSecureReferrerPolicy(), FeaturePolicy: GetSecureFeaturePolicy(), IsDevelopment: false, } secureMiddleware := secure.New(secureOptions) next = secureMiddleware.Handler(next) return RejectS3Middleware(next) } const apiRequestErr = `InvalidArgumentS3 API Requests must be made to API port.0` // RejectS3Middleware will reject requests that have AWS S3 specific headers. func RejectS3Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if len(r.Header.Get("X-Amz-Content-Sha256")) > 0 || len(r.Header.Get("X-Amz-Date")) > 0 || strings.HasPrefix(r.Header.Get("Authorization"), "AWS4-HMAC-SHA256") || r.URL.Query().Get("AWSAccessKeyId") != "" { w.Header().Set("Location", getMinIOServer()) w.WriteHeader(http.StatusBadRequest) w.Write([]byte(apiRequestErr)) return } next.ServeHTTP(w, r) }) } func AuthenticationMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token, err := auth.GetTokenFromRequest(r) if err != nil && err != auth.ErrNoAuthToken { http.Error(w, err.Error(), http.StatusUnauthorized) return } sessionToken, _ := auth.DecryptToken(token) // All handlers handle appropriately to return errors // based on their swagger rules, we do not need to // additionally return error here, let the next ServeHTTPs // handle it appropriately. if len(sessionToken) > 0 { r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", string(sessionToken))) } else { r.Header.Add("Authorization", fmt.Sprintf("Bearer %s", "Anonymous")) } ctx := r.Context() claims, _ := auth.ParseClaimsFromToken(string(sessionToken)) if claims != nil { // save user session id context ctx = context.WithValue(r.Context(), utils.ContextRequestUserID, claims.STSSessionToken) } next.ServeHTTP(w, r.WithContext(ctx)) }) } // FileServerMiddleware serves files from the static folder func FileServerMiddleware(next http.Handler) http.Handler { buildFs, err := fs.Sub(portal_ui.GetStaticAssets(), "build") if err != nil { panic(err) } spaFileHandler := wrapHandlerSinglePageApplication(requestBounce(http.FileServer(http.FS(buildFs)))) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Server", globalAppName) // do not add version information switch { case strings.HasPrefix(r.URL.Path, "/ws"): serveWS(w, r) case strings.HasPrefix(r.URL.Path, "/api"): next.ServeHTTP(w, r) default: spaFileHandler.ServeHTTP(w, r) } }) } type notFoundRedirectRespWr struct { http.ResponseWriter // We embed http.ResponseWriter status int } func (w *notFoundRedirectRespWr) WriteHeader(status int) { w.status = status // Store the status for our own use if status != http.StatusNotFound { w.ResponseWriter.WriteHeader(status) } } func (w *notFoundRedirectRespWr) Write(p []byte) (int, error) { if w.status != http.StatusNotFound { return w.ResponseWriter.Write(p) } return len(p), nil // Lie that we successfully wrote it } // handleSPA handles the serving of the React Single Page Application func handleSPA(w http.ResponseWriter, r *http.Request) { basePath := "/" // For SPA mode we will replace root base with a sub path if configured unless we received cp=y and cpb=/NEW/BASE if v := r.URL.Query().Get("cp"); v == "y" { if base := r.URL.Query().Get("cpb"); base != "" { // make sure the subpath has a trailing slash if !strings.HasSuffix(base, "/") { base = fmt.Sprintf("%s/", base) } basePath = base } } indexPage, err := portal_ui.GetStaticAssets().Open("build/index.html") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } sts := r.URL.Query().Get("sts") stsAccessKey := r.URL.Query().Get("sts_a") stsSecretKey := r.URL.Query().Get("sts_s") overridenStyles := r.URL.Query().Get("ov_st") // if these three parameters are present we are being asked to issue a session with these values if sts != "" && stsAccessKey != "" && stsSecretKey != "" { creds := credentials.NewStaticV4(stsAccessKey, stsSecretKey, sts) consoleCreds := &ConsoleCredentials{ ConsoleCredentials: creds, AccountAccessKey: stsAccessKey, } sf := &auth.SessionFeatures{} sf.HideMenu = true sf.ObjectBrowser = true if overridenStyles != "" { err := ValidateEncodedStyles(overridenStyles) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } sf.CustomStyleOB = overridenStyles } sessionID, err := login(consoleCreds, sf) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } cookie := NewSessionCookieForConsole(*sessionID) http.SetCookie(w, &cookie) // Allow us to be iframed w.Header().Del("X-Frame-Options") } indexPageBytes, err := io.ReadAll(indexPage) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } // if we have a seeded basePath. This should override CONSOLE_SUBPATH every time, thus the `if else` if basePath != "/" { indexPageBytes = replaceBaseInIndex(indexPageBytes, basePath) // if we have a custom subpath replace it in } else if getSubPath() != "/" { indexPageBytes = replaceBaseInIndex(indexPageBytes, getSubPath()) } indexPageBytes = replaceLicense(indexPageBytes) // it's important to force "Content-Type: text/html", because a previous // handler may have already set the content-type to a different value. // (i.e. the FileServer when it detected that it couldn't find the file) w.Header().Set("Content-Type", "text/html") http.ServeContent(w, r, "index.html", time.Now(), bytes.NewReader(indexPageBytes)) } // wrapHandlerSinglePageApplication handles a http.FileServer returning a 404 and overrides it with index.html func wrapHandlerSinglePageApplication(h http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { handleSPA(w, r) return } w.Header().Set("Content-Type", mimedb.TypeByExtension(filepath.Ext(r.URL.Path))) nfw := ¬FoundRedirectRespWr{ResponseWriter: w} h.ServeHTTP(nfw, r) if nfw.status == http.StatusNotFound { handleSPA(w, r) } } } type nullWriter struct{} func (lw nullWriter) Write(b []byte) (int, error) { return len(b), nil } // As soon as server is initialized but not run yet, this function will be called. // If you need to modify a config, store server instance to stop it individually later, this is the place. // This function can be called multiple times, depending on the number of serving schemes. // scheme value will be set accordingly: "http", "https" or "unix" func configureServer(s *http.Server, _, _ string) { // Turn-off random logger by Go net/http s.ErrorLog = log.New(&nullWriter{}, "", 0) } func getSubPath() string { subPathOnce.Do(func() { cfgSubPath = parseSubPath(env.Get(SubPath, "")) }) return cfgSubPath } func parseSubPath(v string) string { v = strings.TrimSpace(v) if v == "" { return SlashSeparator } // Replace all unnecessary `\` to `/` // also add pro-actively at the end. subPath := path.Clean(filepath.ToSlash(v)) if !strings.HasPrefix(subPath, SlashSeparator) { subPath = SlashSeparator + subPath } if !strings.HasSuffix(subPath, SlashSeparator) { subPath += SlashSeparator } return subPath } func replaceBaseInIndex(indexPageBytes []byte, basePath string) []byte { if basePath != "" { validBasePath := regexp.MustCompile(`^[0-9a-zA-Z\/-]+$`) if !validBasePath.MatchString(basePath) { return indexPageBytes } indexPageStr := string(indexPageBytes) newBase := fmt.Sprintf("", basePath) indexPageStr = strings.Replace(indexPageStr, "", newBase, 1) indexPageBytes = []byte(indexPageStr) } return indexPageBytes } func replaceLicense(indexPageBytes []byte) []byte { indexPageStr := string(indexPageBytes) indexPageBytes = []byte(indexPageStr) return indexPageBytes } func requestBounce(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.HasSuffix(r.URL.Path, "/") { http.NotFound(w, r) return } handler.ServeHTTP(w, r) }) }