diff options
-rw-r--r-- | .buildkite/hooks/post-command | 8 | ||||
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | WORKSPACE | 4 | ||||
-rw-r--r-- | website/cmd/server/BUILD | 3 | ||||
-rw-r--r-- | website/cmd/server/main.go | 153 |
5 files changed, 154 insertions, 16 deletions
diff --git a/.buildkite/hooks/post-command b/.buildkite/hooks/post-command index 5cd974002..8af1369a6 100644 --- a/.buildkite/hooks/post-command +++ b/.buildkite/hooks/post-command @@ -27,10 +27,14 @@ make -s testlogs 2>/dev/null | grep // | sort | uniq | ( # Upload all profiles, and include in an annotation. declare profile_output=$(mktemp --tmpdir) for file in $(find /tmp/profile -name \*.pprof -print 2>/dev/null | sort); do - # Generate a link to the profile file at the top. + # Generate a link to the profile parsing function in gvisor.dev, which + # implicitly uses a prefix of https://storage.googleapis.com. Note that + # this relies on the specific BuildKite bucket location, and will break if + # this changes (although the artifacts will still exist and be just fine). profile_name="${file#/tmp/profile/}" + profile_url="https://gvisor.dev/profile/gvisor-buildkite/${BUILDKITE_BUILD_ID}/${BUILDKITE_JOB_ID}/${file#/}/" buildkite-agent artifact upload "${file}" - echo "<li><a href='artifact://${file#/}'>${profile_name}</a></li>" >> "${profile_output}" + echo "<li><a href='${profile_url}'>${profile_name}</a></li>" >> "${profile_output}" done # Upload if we had outputs. @@ -389,7 +389,7 @@ website-push: website-build ## Push a new image and update the service. .PHONY: website-push website-deploy: website-push ## Deploy a new version of the website. - @gcloud run deploy $(WEBSITE_SERVICE) --platform=managed --region=$(WEBSITE_REGION) --project=$(WEBSITE_PROJECT) --image=$(WEBSITE_IMAGE) + @gcloud run deploy $(WEBSITE_SERVICE) --platform=managed --region=$(WEBSITE_REGION) --project=$(WEBSITE_PROJECT) --image=$(WEBSITE_IMAGE) --memory 1Gi .PHONY: website-deploy ## @@ -866,8 +866,8 @@ go_repository( go_repository( name = "com_github_google_pprof", importpath = "github.com/google/pprof", - sum = "h1:DLpL8pWq0v4JYoRpEhDfsJhhJyGKCcQM2WPW2TJs31c=", - version = "v0.0.0-20191218002539-d4f498aebedc", + sum = "h1:LR89qFljJ48s990kEKGsk213yIJDPI4205OKOzbURK8=", + version = "v0.0.0-20201218002935-b9804c9f04c2", ) go_repository( diff --git a/website/cmd/server/BUILD b/website/cmd/server/BUILD index 6b5a08f0d..e4cf91e07 100644 --- a/website/cmd/server/BUILD +++ b/website/cmd/server/BUILD @@ -7,4 +7,7 @@ go_binary( srcs = ["main.go"], pure = True, visibility = ["//website:__pkg__"], + deps = [ + "@com_github_google_pprof//driver:go_default_library", + ], ) diff --git a/website/cmd/server/main.go b/website/cmd/server/main.go index ac09550a9..84a576858 100644 --- a/website/cmd/server/main.go +++ b/website/cmd/server/main.go @@ -21,8 +21,11 @@ import ( "log" "net/http" "os" + "path" "regexp" "strings" + + "github.com/google/pprof/driver" ) var redirects = map[string]string{ @@ -170,28 +173,155 @@ func redirectHandler(target string) http.Handler { // redirectRedirects registers redirect http handlers. func registerRedirects(mux *http.ServeMux) { - if mux == nil { - mux = http.DefaultServeMux - } - for prefix, baseURL := range prefixHelpers { p := "/" + prefix + "/" mux.Handle(p, hostRedirectHandler(wrappedHandler(prefixRedirectHandler(p, baseURL)))) } - for path, redirect := range redirects { mux.Handle(path, hostRedirectHandler(wrappedHandler(redirectHandler(redirect)))) } } -// registerStatic registers static file handlers +// registerStatic registers static file handlers. func registerStatic(mux *http.ServeMux, staticDir string) { - if mux == nil { - mux = http.DefaultServeMux - } mux.Handle("/", hostRedirectHandler(wrappedHandler(http.FileServer(http.Dir(staticDir))))) } +// profileMeta implements synthetic flags for pprof. +type profileMeta struct { + // Mux is the mux to register on. + Mux *http.ServeMux + + // SourceURL is the source of the profile. + SourceURL string +} + +func (*profileMeta) ExtraUsage() string { return "" } +func (*profileMeta) AddExtraUsage(string) {} +func (*profileMeta) Bool(_ string, def bool, _ string) *bool { return &def } +func (*profileMeta) Int(_ string, def int, _ string) *int { return &def } +func (*profileMeta) Float64(_ string, def float64, _ string) *float64 { return &def } +func (*profileMeta) StringList(_ string, def string, _ string) *[]*string { return new([]*string) } +func (*profileMeta) String(option string, def string, _ string) *string { + switch option { + case "http": + // Only http is specified. Other options may be accessible via + // the web interface, so we just need to spoof a valid option + // here. The server is actually bound by HTTPServer, below. + value := "localhost:80" + return &value + case "symbolize": + // Don't attempt symbolization. Most profiles should come with + // mappings built-in to the profile itself. + value := "none" + return &value + default: + return &def // Default. + } +} + +// Parse implements plugin.FlagSet.Parse. +func (p *profileMeta) Parse(usage func()) []string { + // Just return the SourceURL. This is interpreted as the profile to + // download. We validate that the URL corresponds to a Google Cloud + // Storage URL below. + return []string{p.SourceURL} +} + +// pprofFixedPrefix is used to limit the exposure to SSRF. +// +// See registerProfile below. +const pprofFixedPrefix = "https://storage.googleapis.com/" + +// Target returns the URL target. +func (p *profileMeta) Target() string { + return fmt.Sprintf("/profile/%s/", p.SourceURL[len(pprofFixedPrefix):]) +} + +// HTTPServer is a function passed to driver.PProf. +func (p *profileMeta) HTTPServer(args *driver.HTTPServerArgs) error { + target := p.Target() + for subpath, handler := range args.Handlers { + handlerPath := path.Join(target, subpath) + if len(handlerPath) < len(target) { + // Don't clean the target, match only as the literal + // directory path in order to keep relative links + // working in the profile. E.g. /profile/foo/ is the + // base URL for the profile at https://.../foo. + handlerPath = target + } + p.Mux.Handle(handlerPath, handler) + } + return nil +} + +// registerProfile registers the profile handler. +// +// Note that this has a security surface worth considering. +// +// We are passed effectively a URL, which we fetch and parse, +// then display the profile output. We limit the possibility of +// SSRF by interpreting the URL strictly as a part to an object +// in Google Cloud Storage, but we allow the user to specify any +// bucket (since this may change with the CI system). +// +// We additionally must consider the possibility that users may +// craft malicious profile objects (somehow) and pass those URLs +// here as well. It seems feasible that we could parse a profile +// that causes a crash (DOS), but this would be automatically +// handled without a blip. It seems unlikely that we could parse a +// profile that gives full code execution, but even so there is +// nothing in this image except this code and CA certs. At worst, +// code execution would enable someone to serve up content under the +// web domain. This would be ephemeral with the specific instance, +// and persisting such an attack would require constantly crashing +// instances in whatever way gives remote code execution. Even if +// this were possible, it's unlikely that exploiting such a crash +// could be done so constantly and consistently. +// +// The user can also fill the "disk" of this container instance, +// causing an OOM and a crash. This has similar semantics to the +// DOS scenario above, and would just be handled by Cloud Run. +// +// Finally, a malicious user could cause us to repeatedly fetch +// extremely large objects. However, since we fetch objects via +// the unauthenticated URL, such accesses would always be charged +// to the object owner. Downloading large objects can lead to the +// filling of the "disk" scenario above, but this is similarly a +// minor issue and immediately mitigated. +func registerProfile(mux *http.ServeMux) { + const urlPrefix = "/profile/" + mux.Handle(urlPrefix, hostRedirectHandler(wrappedHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Extract the URL; this is everything except the final /. + parts := strings.Split(r.URL.Path[len(urlPrefix):], "/") + url := pprofFixedPrefix + strings.Join(parts[:len(parts)-1], "/") + if url == pprofFixedPrefix { + http.Error(w, "Invalid URL: no path provided.", http.StatusNotFound) + return + } + + // Set up the meta handler. This will modify the original mux + // accordingly, and we ultimately return a redirect that + // includes all the original arguments. This means that if we + // ever hit a server that does not have this profile loaded, it + // will load and redirect again. + meta := &profileMeta{ + Mux: mux, + SourceURL: url, + } + if err := driver.PProf(&driver.Options{ + Flagset: meta, + HTTPServer: meta.HTTPServer, + }); err != nil { + http.Error(w, fmt.Sprintf("Invalid profile: %v", err), http.StatusNotImplemented) + return + } + + // Serve the path directly. + mux.ServeHTTP(w, r) + })))) +} + func envFlagString(name, def string) string { if val := os.Getenv(name); val != "" { return val @@ -211,8 +341,9 @@ var ( func main() { flag.Parse() - registerRedirects(nil) - registerStatic(nil, *staticDir) + registerRedirects(http.DefaultServeMux) + registerStatic(http.DefaultServeMux, *staticDir) + registerProfile(http.DefaultServeMux) log.Printf("Listening on %s...", *addr) log.Fatal(http.ListenAndServe(*addr, nil)) |