gvisor.dev/gvisor@v0.0.0-20240520182842-f9d4d51c7e0f/website/cmd/server/main.go (about)

     1  // Copyright 2019 The gVisor Authors
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //     https://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Server is the main gvisor.dev binary.
    16  package main
    17  
    18  import (
    19  	"flag"
    20  	"fmt"
    21  	"log"
    22  	"net/http"
    23  	"net/url"
    24  	"os"
    25  	"path"
    26  	"regexp"
    27  	"strings"
    28  
    29  	"github.com/google/pprof/driver"
    30  )
    31  
    32  var redirects = map[string]string{
    33  	// GitHub redirects.
    34  	"/change":    "https://github.com/google/gvisor",
    35  	"/issue":     "https://github.com/google/gvisor/issues",
    36  	"/issues":    "https://github.com/google/gvisor/issues",
    37  	"/issue/new": "https://github.com/google/gvisor/issues/new/choose",
    38  	"/pr":        "https://github.com/google/gvisor/pulls",
    39  
    40  	// For links.
    41  	"/faq": "/docs/user_guide/faq/",
    42  
    43  	// From 2020-05-12 to 2020-06-30, the FAQ URL was uppercase. Redirect that
    44  	// back to maintain any links.
    45  	"/docs/user_guide/FAQ/": "/docs/user_guide/faq/",
    46  
    47  	// Redirects to compatibility docs.
    48  	"/c":             "/docs/user_guide/compatibility/",
    49  	"/c/linux/amd64": "/docs/user_guide/compatibility/linux/amd64/",
    50  
    51  	// Redirect for old URLs.
    52  	"/docs/user_guide/compatibility/amd64/":  "/docs/user_guide/compatibility/linux/amd64/",
    53  	"/docs/user_guide/compatibility/amd64":   "/docs/user_guide/compatibility/linux/amd64/",
    54  	"/docs/user_guide/kubernetes/":           "/docs/user_guide/quick_start/kubernetes/",
    55  	"/docs/user_guide/kubernetes":            "/docs/user_guide/quick_start/kubernetes/",
    56  	"/docs/user_guide/oci/":                  "/docs/user_guide/quick_start/oci/",
    57  	"/docs/user_guide/oci":                   "/docs/user_guide/quick_start/oci/",
    58  	"/docs/user_guide/docker/":               "/docs/user_guide/quick_start/docker/",
    59  	"/docs/user_guide/docker":                "/docs/user_guide/quick_start/docker/",
    60  	"/blog/2020/09/22/platform-portability":  "/blog/2020/10/22/platform-portability/",
    61  	"/blog/2020/09/22/platform-portability/": "/blog/2020/10/22/platform-portability/",
    62  
    63  	// Deprecated, but links continue to work.
    64  	"/cl": "https://gvisor-review.googlesource.com",
    65  
    66  	// Access package documentation.
    67  	"/gvisor": "https://pkg.go.dev/gvisor.dev/gvisor",
    68  
    69  	// Code search root.
    70  	"/cs": "https://cs.opensource.google/gvisor/gvisor",
    71  }
    72  
    73  type prefixInfo struct {
    74  	baseURL      string
    75  	checkValidID bool
    76  	queryEscape  bool
    77  }
    78  
    79  var prefixHelpers = map[string]prefixInfo{
    80  	"change": {baseURL: "https://github.com/google/gvisor/commit/%s", checkValidID: true},
    81  	"issue":  {baseURL: "https://github.com/google/gvisor/issues/%s", checkValidID: true},
    82  	"issues": {baseURL: "https://github.com/google/gvisor/issues/%s", checkValidID: true},
    83  	"pr":     {baseURL: "https://github.com/google/gvisor/pull/%s", checkValidID: true},
    84  
    85  	// Redirects to compatibility docs.
    86  	"c/linux/amd64": {baseURL: "/docs/user_guide/compatibility/linux/amd64/#%s", checkValidID: true},
    87  
    88  	// Deprecated, but links continue to work.
    89  	"cl": {baseURL: "https://gvisor-review.googlesource.com/c/gvisor/+/%s", checkValidID: true},
    90  
    91  	// Redirect to source documentation.
    92  	"gvisor": {baseURL: "https://pkg.go.dev/gvisor.dev/gvisor/%s"},
    93  
    94  	// Redirect to code search, with the path as the query.
    95  	"cs": {baseURL: "https://cs.opensource.google/search?q=%s&ss=gvisor", queryEscape: true},
    96  }
    97  
    98  var (
    99  	validID    = regexp.MustCompile(`^[A-Za-z0-9-]*/?$`)
   100  	goGetHTML5 = `<!doctype html><html><head><meta charset=utf-8>
   101  <meta name="go-import" content="gvisor.dev/gvisor git https://github.com/google/gvisor">
   102  <meta name="go-import" content="gvisor.dev/website git https://github.com/google/gvisor-website">
   103  <title>Go-get</title></head><body></html>`
   104  )
   105  
   106  // cronHandler wraps an http.Handler to check that the request is from the App
   107  // Engine Cron service.
   108  // See: https://cloud.google.com/appengine/docs/standard/go112/scheduling-jobs-with-cron-yaml#validating_cron_requests
   109  func cronHandler(h http.Handler) http.Handler {
   110  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   111  		if r.Header.Get("X-Appengine-Cron") != "true" {
   112  			http.NotFound(w, r)
   113  			return
   114  		}
   115  		// Fallthrough.
   116  		h.ServeHTTP(w, r)
   117  	})
   118  }
   119  
   120  // wrappedHandler wraps an http.Handler.
   121  //
   122  // If the query parameters include go-get=1, then we redirect to a single
   123  // static page that allows us to serve arbitrary Go packages.
   124  func wrappedHandler(h http.Handler) http.Handler {
   125  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   126  		gg, ok := r.URL.Query()["go-get"]
   127  		if ok && len(gg) == 1 && gg[0] == "1" {
   128  			// Serve a trivial html page.
   129  			w.Write([]byte(goGetHTML5))
   130  			return
   131  		}
   132  		// Fallthrough.
   133  		h.ServeHTTP(w, r)
   134  	})
   135  }
   136  
   137  // redirectWithQuery redirects to the given target url preserving query parameters.
   138  func redirectWithQuery(w http.ResponseWriter, r *http.Request, target string) {
   139  	url := target
   140  	if qs := r.URL.RawQuery; qs != "" {
   141  		url += "?" + qs
   142  	}
   143  	http.Redirect(w, r, url, http.StatusFound)
   144  }
   145  
   146  // domainMatch returns whether the domain is accepted and if we should redirect
   147  // to a custom domain.
   148  func domainMatch(host string) (redirect bool, ok bool) {
   149  	if *customHost == "*" {
   150  		redirect = false
   151  		ok = true
   152  		return
   153  	}
   154  
   155  	// Custom Host handling.
   156  	if *customHost != "" {
   157  		if host == "www."+*customHost {
   158  			redirect = true
   159  			ok = true
   160  			return
   161  		}
   162  
   163  		if host == *customHost {
   164  			ok = true
   165  			return
   166  		}
   167  	}
   168  
   169  	// Cloud Run App domain handling.
   170  	if strings.HasPrefix(host, *projectID+"-") && strings.HasSuffix(host, ".run.app") {
   171  		redirect = *customHost != ""
   172  		ok = true
   173  		return
   174  	}
   175  
   176  	return
   177  }
   178  
   179  // hostRedirectHandler redirects the www. domain to the naked domain.
   180  func hostRedirectHandler(h http.Handler) http.Handler {
   181  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   182  		redirect, ok := domainMatch(r.Host)
   183  		if !ok {
   184  			// Do not serve on invalid domains.
   185  			http.Error(w, "Bad Request", http.StatusBadRequest)
   186  			return
   187  		}
   188  
   189  		if redirect {
   190  			// Redirect to the naked domain.
   191  			r.URL.Scheme = "https" // Assume https.
   192  			r.URL.Host = *customHost
   193  			http.Redirect(w, r, r.URL.String(), http.StatusMovedPermanently)
   194  			return
   195  		}
   196  
   197  		h.ServeHTTP(w, r)
   198  	})
   199  }
   200  
   201  // prefixRedirectHandler returns a handler that redirects to the given formated url.
   202  func prefixRedirectHandler(prefix string, info prefixInfo) http.Handler {
   203  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   204  		if p := r.URL.Path; p == prefix {
   205  			// Redirect /prefix/ to /prefix.
   206  			http.Redirect(w, r, p[:len(p)-1], http.StatusFound)
   207  			return
   208  		}
   209  		id := r.URL.Path[len(prefix):]
   210  		if info.checkValidID && !validID.MatchString(id) {
   211  			http.Error(w, "Not found", http.StatusNotFound)
   212  			return
   213  		}
   214  		if info.queryEscape {
   215  			id = url.QueryEscape(id)
   216  		}
   217  		target := fmt.Sprintf(info.baseURL, id)
   218  		redirectWithQuery(w, r, target)
   219  	})
   220  }
   221  
   222  // redirectHandler returns a handler that redirects to the given url.
   223  func redirectHandler(target string) http.Handler {
   224  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   225  		redirectWithQuery(w, r, target)
   226  	})
   227  }
   228  
   229  // registerRedirects registers redirect http handlers.
   230  func registerRedirects(mux *http.ServeMux) {
   231  	for prefix, info := range prefixHelpers {
   232  		p := "/" + prefix + "/"
   233  		mux.Handle(p, hostRedirectHandler(wrappedHandler(prefixRedirectHandler(p, info))))
   234  	}
   235  	for path, redirect := range redirects {
   236  		mux.Handle(path, hostRedirectHandler(wrappedHandler(redirectHandler(redirect))))
   237  	}
   238  }
   239  
   240  // registerStatic registers static file handlers.
   241  func registerStatic(mux *http.ServeMux, staticDir string) {
   242  	mux.Handle("/", hostRedirectHandler(wrappedHandler(http.FileServer(http.Dir(staticDir)))))
   243  }
   244  
   245  // profileMeta implements synthetic flags for pprof.
   246  type profileMeta struct {
   247  	// Mux is the mux to register on.
   248  	Mux *http.ServeMux
   249  
   250  	// SourceURL is the source of the profile.
   251  	SourceURL string
   252  }
   253  
   254  func (*profileMeta) ExtraUsage() string                                   { return "" }
   255  func (*profileMeta) AddExtraUsage(string)                                 {}
   256  func (*profileMeta) Bool(_ string, def bool, _ string) *bool              { return &def }
   257  func (*profileMeta) Int(_ string, def int, _ string) *int                 { return &def }
   258  func (*profileMeta) Float64(_ string, def float64, _ string) *float64     { return &def }
   259  func (*profileMeta) StringList(_ string, def string, _ string) *[]*string { return new([]*string) }
   260  func (*profileMeta) String(option string, def string, _ string) *string {
   261  	switch option {
   262  	case "http":
   263  		// Only http is specified. Other options may be accessible via
   264  		// the web interface, so we just need to spoof a valid option
   265  		// here. The server is actually bound by HTTPServer, below.
   266  		value := "localhost:80"
   267  		return &value
   268  	case "symbolize":
   269  		// Don't attempt symbolization. Most profiles should come with
   270  		// mappings built-in to the profile itself.
   271  		value := "none"
   272  		return &value
   273  	default:
   274  		return &def // Default.
   275  	}
   276  }
   277  
   278  // Parse implements plugin.FlagSet.Parse.
   279  func (p *profileMeta) Parse(usage func()) []string {
   280  	// Just return the SourceURL. This is interpreted as the profile to
   281  	// download. We validate that the URL corresponds to a Google Cloud
   282  	// Storage URL below.
   283  	return []string{p.SourceURL}
   284  }
   285  
   286  // pprofFixedPrefix is used to limit the exposure to SSRF.
   287  //
   288  // See registerProfile below.
   289  const pprofFixedPrefix = "https://storage.googleapis.com/"
   290  
   291  // allowedBuckets enforces constraints on the pprof target.
   292  //
   293  // If the continuous integration system is changed in the future to use
   294  // additional buckets, they may be allowed here. See registerProfile.
   295  var allowedBuckets = map[string]bool{
   296  	"gvisor-buildkite": true,
   297  }
   298  
   299  // Target returns the URL target.
   300  func (p *profileMeta) Target() string {
   301  	return fmt.Sprintf("/profile/%s/", p.SourceURL[len(pprofFixedPrefix):])
   302  }
   303  
   304  // HTTPServer is a function passed to driver.PProf.
   305  func (p *profileMeta) HTTPServer(args *driver.HTTPServerArgs) error {
   306  	target := p.Target()
   307  	for subpath, handler := range args.Handlers {
   308  		handlerPath := path.Join(target, subpath)
   309  		if len(handlerPath) < len(target) {
   310  			// Don't clean the target, match only as the literal
   311  			// directory path in order to keep relative links
   312  			// working in the profile. E.g. /profile/foo/ is the
   313  			// base URL for the profile at https://.../foo.
   314  			//
   315  			// The base target typically shows the dot-based graph,
   316  			// which will not work in the image (due to the lack of
   317  			// a dot binary to execute). Therefore, we redirect to
   318  			// the flamegraph handler. Everything should otherwise
   319  			// work the exact same way, except the "Graph" link.
   320  			handlerPath = target
   321  			handler = redirectHandler(path.Join(handlerPath, "flamegraph"))
   322  		}
   323  		p.Mux.Handle(handlerPath, handler)
   324  	}
   325  	return nil
   326  }
   327  
   328  // registerProfile registers the profile handler.
   329  //
   330  // Note that this has a security surface worth considering.
   331  //
   332  // We are passed effectively a URL, which we fetch and parse,
   333  // then display the profile output. We limit the possibility of
   334  // SSRF by interpreting the URL strictly as a part to an object
   335  // in Google Cloud Storage, and further limit the buckets that
   336  // may be used. This contains the vast majority of concerns,
   337  // since objects must at least be uploaded by our CI system.
   338  //
   339  // However, we additionally consider the possibility that users
   340  // craft malicious profile objects (somehow) and pass those URLs
   341  // here as well. It seems feasible that we could parse a profile
   342  // that causes a crash (DOS), but this would be automatically
   343  // handled without a blip. It seems unlikely that we could parse a
   344  // profile that gives full code execution, but even so there is
   345  // nothing in this image except this code and CA certs. At worst,
   346  // code execution would enable someone to serve up content under the
   347  // web domain. This would be ephemeral with the specific instance,
   348  // and persisting such an attack would require constantly crashing
   349  // instances in whatever way gives remote code execution. Even if
   350  // this were possible, it's unlikely that exploiting such a crash
   351  // could be done so constantly and consistently.
   352  //
   353  // The user can also fill the "disk" of this container instance,
   354  // causing an OOM and a crash. This has similar semantics to the
   355  // DOS scenario above, and would just be handled by Cloud Run.
   356  //
   357  // Note that all of the above scenarios would require uploading
   358  // malicious profiles to controller buckets, and a clear audit
   359  // trail would exist in those cases.
   360  func registerProfile(mux *http.ServeMux) {
   361  	const urlPrefix = "/profile/"
   362  	mux.Handle(urlPrefix, hostRedirectHandler(wrappedHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   363  		// Extract the URL; this is everything except the final /.
   364  		parts := strings.Split(r.URL.Path[len(urlPrefix):], "/")
   365  		if len(parts) == 0 {
   366  			http.Error(w, "Invalid URL: no bucket provided.", http.StatusNotFound)
   367  			return
   368  		}
   369  		if !allowedBuckets[parts[0]] {
   370  			http.Error(w, fmt.Sprintf("Invalid URL: not an allowed bucket (%s).", parts[0]), http.StatusNotFound)
   371  			return
   372  		}
   373  		url := pprofFixedPrefix + strings.Join(parts[:len(parts)-1], "/")
   374  		if url == pprofFixedPrefix {
   375  			http.Error(w, "Invalid URL: no path provided.", http.StatusNotFound)
   376  			return
   377  		}
   378  
   379  		// Set up the meta handler. This will modify the original mux
   380  		// accordingly, and we ultimately return a redirect that
   381  		// includes all the original arguments. This means that if we
   382  		// ever hit a server that does not have this profile loaded, it
   383  		// will load and redirect again.
   384  		meta := &profileMeta{
   385  			Mux:       mux,
   386  			SourceURL: url,
   387  		}
   388  		if err := driver.PProf(&driver.Options{
   389  			Flagset:    meta,
   390  			HTTPServer: meta.HTTPServer,
   391  		}); err != nil {
   392  			http.Error(w, fmt.Sprintf("Invalid profile: %v", err), http.StatusNotImplemented)
   393  			return
   394  		}
   395  
   396  		// Serve the path directly.
   397  		mux.ServeHTTP(w, r)
   398  	}))))
   399  }
   400  
   401  func envFlagString(name, def string) string {
   402  	if val, ok := os.LookupEnv(name); ok {
   403  		return val
   404  	}
   405  	return def
   406  }
   407  
   408  var (
   409  	addr      = flag.String("http", envFlagString("HTTP", ":"+envFlagString("PORT", "8080")), "HTTP service address")
   410  	staticDir = flag.String("static-dir", envFlagString("STATIC_DIR", "_site"), "static files directory")
   411  
   412  	// NOTE: GOOGLE_CLOUD_PROJECT environment variable does not seem to be used
   413  	// anymore by Google Cloud but we can continue to look for it and fall back
   414  	// to the default project.
   415  	projectID  = flag.String("project-id", envFlagString("GOOGLE_PROJECT_ID", "gvisordev"), "The Google Cloud project ID.")
   416  	customHost = flag.String("custom-domain", envFlagString("CUSTOM_DOMAIN", "gvisor.dev"), "The application's custom domain.")
   417  )
   418  
   419  func main() {
   420  	flag.Parse()
   421  
   422  	if *customHost == "*" {
   423  		fmt.Println("WARNING: custom domain of '*' matches anything and is potentially insecure.")
   424  	}
   425  
   426  	if *projectID == "" {
   427  		fmt.Fprintf(os.Stderr, "Project ID not set.\n")
   428  		os.Exit(1)
   429  	}
   430  
   431  	registerRedirects(http.DefaultServeMux)
   432  	registerStatic(http.DefaultServeMux, *staticDir)
   433  	registerProfile(http.DefaultServeMux)
   434  
   435  	log.Printf("Listening on %s...", *addr)
   436  	log.Fatal(http.ListenAndServe(*addr, nil))
   437  }