baseapp - Create a simple way to securely serve applications.

This structure should be secure by default, easy to use, and provide
good defaults that are common for all of our applications.

baseapp also provides the --local, --port, and --prom_port flags
so their usage and documentation are consistent.

Bug: skia:
Change-Id: Ib5a9b98ba3e209f8eb2efa3b2f967704d7ac89af
Reviewed-on: https://skia-review.googlesource.com/c/180281
Commit-Queue: Joe Gregorio <jcgregorio@google.com>
Reviewed-by: Ravi Mistry <rmistry@google.com>
diff --git a/am/Makefile b/am/Makefile
index 6a938e9..21e5b4d 100644
--- a/am/Makefile
+++ b/am/Makefile
@@ -20,6 +20,10 @@
 	pushk --cluster=skia-public alert-manager alert-to-pubsub
 	pushk --cluster=skia-corp alert-to-pubsub
 
+
+push_am: release
+	pushk --cluster=skia-public alert-manager
+
 serve: package-lock.json
 	npx webpack-dev-server --mode=development --watch-poll
 
diff --git a/am/go/alert-manager/main.go b/am/go/alert-manager/main.go
index 7a4efc7..19faf60 100644
--- a/am/go/alert-manager/main.go
+++ b/am/go/alert-manager/main.go
@@ -2,21 +2,17 @@
 
 import (
 	"context"
-	"encoding/base64"
 	"encoding/json"
 	"flag"
 	"fmt"
 	"html/template"
-	"io/ioutil"
 	"net/http"
 	"os"
 	"path/filepath"
-	"runtime"
 	"sort"
 	"time"
 
 	"cloud.google.com/go/pubsub"
-	"github.com/gorilla/csrf"
 	"github.com/gorilla/mux"
 	"github.com/skia-dev/secure"
 	"go.skia.org/infra/am/go/incident"
@@ -26,7 +22,7 @@
 	"go.skia.org/infra/go/allowed"
 	"go.skia.org/infra/go/auditlog"
 	"go.skia.org/infra/go/auth"
-	"go.skia.org/infra/go/common"
+	"go.skia.org/infra/go/baseapp"
 	"go.skia.org/infra/go/ds"
 	"go.skia.org/infra/go/httputils"
 	"go.skia.org/infra/go/login"
@@ -41,20 +37,14 @@
 	assignGroup        = flag.String("assign_group", "google/skia-root@google.com", "The chrome infra auth group to use for users incidents can be assigned to.")
 	authGroup          = flag.String("auth_group", "google/skia-staff@google.com", "The chrome infra auth group to use for restricting access.")
 	chromeInfraAuthJWT = flag.String("chrome_infra_auth_jwt", "/var/secrets/skia-public-auth/key.json", "The JWT key for the service account that has access to chrome infra auth.")
-	local              = flag.Bool("local", false, "Running locally if true. As opposed to in production.")
 	namespace          = flag.String("namespace", "", "The Cloud Datastore namespace, such as 'perf'.")
-	port               = flag.String("port", ":8000", "HTTP service address (e.g., ':8000')")
 	internalPort       = flag.String("internal_port", ":9000", "HTTP internal service address (e.g., ':9000') for unauthenticated in-cluster requests.")
 	project            = flag.String("project", "skia-public", "The Google Cloud project name.")
-	promPort           = flag.String("prom_port", ":20000", "Metrics service address (e.g., ':10110')")
-	resourcesDir       = flag.String("resources_dir", "", "The directory to find templates, JS, and CSS files. If blank the current directory will be used.")
 )
 
 const (
 	// EXPIRE_DURATION is the time to wait before expiring an incident.
 	EXPIRE_DURATION = 2 * time.Minute
-
-	APP_NAME = "alert-manager"
 )
 
 // Server is the state of the server.
@@ -62,30 +52,15 @@
 	incidentStore *incident.Store
 	silenceStore  *silence.Store
 	templates     *template.Template
-	salt          []byte        // Salt for csrf cookies.
 	allow         allowed.Allow // Who is allowed to use the site.
 	assign        allowed.Allow // A list of people that incidents can be assigned to.
 }
 
-func New() (*Server, error) {
-	if *resourcesDir == "" {
-		_, filename, _, _ := runtime.Caller(0)
-		*resourcesDir = filepath.Join(filepath.Dir(filename), "../../dist")
-	}
-
-	// Setup the salt.
-	salt := []byte("32-byte-long-auth-key")
-	if !*local {
-		var err error
-		salt, err = ioutil.ReadFile("/var/skia/salt.txt")
-		if err != nil {
-			return nil, err
-		}
-	}
-
+// See baseapp.Constructor.
+func New() (baseapp.App, error) {
 	var allow allowed.Allow
 	var assign allowed.Allow
-	if !*local {
+	if !*baseapp.Local {
 		ts, err := auth.NewJWTServiceAccountTokenSource("", *chromeInfraAuthJWT, auth.SCOPE_USERINFO_EMAIL)
 		if err != nil {
 			return nil, err
@@ -104,10 +79,10 @@
 		assign = allowed.NewAllowedFromList([]string{"betty@example.org", "fred@example.org", "barney@example.org", "wilma@example.org"})
 	}
 
-	login.InitWithAllow(*port, *local, nil, nil, allow)
+	login.InitWithAllow(*baseapp.Port, *baseapp.Local, nil, nil, allow)
 
 	ctx := context.Background()
-	ts, err := auth.NewDefaultTokenSource(*local, pubsub.ScopePubSub, "https://www.googleapis.com/auth/datastore")
+	ts, err := auth.NewDefaultTokenSource(*baseapp.Local, pubsub.ScopePubSub, "https://www.googleapis.com/auth/datastore")
 	if err != nil {
 		return nil, err
 	}
@@ -115,7 +90,7 @@
 	if *namespace == "" {
 		return nil, fmt.Errorf("The --namespace flag is required. See infra/DATASTORE.md for format details.\n")
 	}
-	if !*local && !util.In(*namespace, []string{ds.ALERT_MANAGER_NS}) {
+	if !*baseapp.Local && !util.In(*namespace, []string{ds.ALERT_MANAGER_NS}) {
 		return nil, fmt.Errorf("When running in prod the datastore namespace must be a known value.")
 	}
 	if err := ds.InitWithOpt(*project, *namespace, option.WithTokenSource(ts)); err != nil {
@@ -135,7 +110,7 @@
 	// When running in production we have every instance use the same topic name so that
 	// they load-balance pulling items from the topic.
 	subName := fmt.Sprintf("%s-%s", alerts.TOPIC, "prod")
-	if *local {
+	if *baseapp.Local {
 		// When running locally create a new topic for every host.
 		subName = fmt.Sprintf("%s-%s", alerts.TOPIC, hostname)
 	}
@@ -154,7 +129,6 @@
 	}
 
 	srv := &Server{
-		salt:          salt,
 		incidentStore: incident.NewStore(ds.DS, []string{"kubernetes_pod_name", "instance", "pod_template_hash"}),
 		silenceStore:  silence.NewStore(ds.DS),
 		allow:         allow,
@@ -217,23 +191,23 @@
 		}
 	}()
 
+	srv.startInternalServer()
+
 	return srv, nil
 }
 
 func (srv *Server) loadTemplates() {
 	srv.templates = template.Must(template.New("").Delims("{%", "%}").ParseFiles(
-		filepath.Join(*resourcesDir, "index.html"),
+		filepath.Join(*baseapp.ResourcesDir, "index.html"),
 	))
 }
 
 func (srv *Server) mainHandler(w http.ResponseWriter, r *http.Request) {
 	w.Header().Set("Content-Type", "text/html")
-	if *local {
+	if *baseapp.Local {
 		srv.loadTemplates()
 	}
 	if err := srv.templates.ExecuteTemplate(w, "index.html", map[string]string{
-		// base64 encode the csrf to avoid golang templating escaping.
-		"csrf": base64.StdEncoding.EncodeToString([]byte(csrf.Token(r))),
 		// Look in webpack.config.js for where the nonce templates are injected.
 		"nonce": secure.CSPNonce(r.Context()),
 	}); err != nil {
@@ -249,7 +223,7 @@
 // user returns the currently logged in user, or a placeholder if running locally.
 func (srv *Server) user(r *http.Request) string {
 	user := "barney@example.org"
-	if !*local {
+	if !*baseapp.Local {
 		user = login.LoggedInAs(r)
 	}
 	return user
@@ -587,98 +561,17 @@
 	}
 }
 
-// cspReportWrapper wraps a handler and intercepts csp failure reports and
-// turns them into structured log entries.
-//
-// Note this should be outside the csrf wrapper since execution may fail
-// before the csrf is in place.
-func cspReportWrapper(h http.Handler) http.Handler {
-	s := func(w http.ResponseWriter, r *http.Request) {
-		if r.URL.Path == "/cspreport" && r.Method == "POST" {
-			var body interface{}
-			if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
-				sklog.Errorf("Failed to decode csp report: %s", err)
-				return
-			}
-			c := struct {
-				Type string      `json:"type"`
-				Body interface{} `json:"body"`
-			}{
-				Type: "csp",
-				Body: body,
-			}
-			b, err := json.Marshal(c)
-			if err != nil {
-				sklog.Errorf("Failed to marshal csp log entry: %s", err)
-				return
-			}
-			fmt.Println(string(b))
-			return
-		} else {
-			h.ServeHTTP(w, r)
-		}
-	}
-	return http.HandlerFunc(s)
-}
-
-func (srv *Server) applySecurityWrappers(h http.Handler) http.Handler {
-	// Configure Content Security Policy (CSP).
-	addScriptSrc := ""
-	if *local {
-		// webpack uses eval() in development mode, so allow unsafe-eval when local.
-		addScriptSrc = "'unsafe-eval'"
-	}
-	// This non-local CSP string passes the tests at https://csp-evaluator.withgoogle.com/.
-	//
-	// See also: https://csp.withgoogle.com/docs/strict-csp.html
-	cspString := fmt.Sprintf("base-uri 'none';  img-src 'self' ; object-src 'none' ; style-src 'self' 'unsafe-inline' ; script-src 'strict-dynamic' $NONCE 'unsafe-inline' %s https: http: ; report-uri /cspreport ;", addScriptSrc)
-
-	// Apply CSP and other security minded headers.
-	secureMiddleware := secure.New(secure.Options{
-		AllowedHosts:          []string{"am.skia.org"},
-		HostsProxyHeaders:     []string{"X-Forwarded-Host"},
-		SSLRedirect:           true,
-		SSLProxyHeaders:       map[string]string{"X-Forwarded-Proto": "https"},
-		STSSeconds:            60 * 60 * 24 * 365,
-		STSIncludeSubdomains:  true,
-		ContentSecurityPolicy: cspString,
-		IsDevelopment:         *local,
-	})
-
-	h = secureMiddleware.Handler(h)
-	h = csrf.Protect(srv.salt, csrf.Secure(!*local), csrf.Path("/"))(h)
-	return h
-}
-
-func main() {
-	common.InitWithMust(
-		APP_NAME,
-		common.PrometheusOpt(promPort),
-		common.MetricsLoggingOpt(),
-	)
-
-	srv, err := New()
-	if err != nil {
-		sklog.Fatalf("Failed to create Server: %s", err)
-	}
-
-	// Internal endpoints that are only accessible from within the cluster.
-	unprotected := mux.NewRouter()
-	unprotected.HandleFunc("/_/incidents", srv.incidentHandler).Methods("GET")
-	unprotected.HandleFunc("/_/silences", srv.silencesHandler).Methods("GET")
-	go func() {
-		sklog.Fatal(http.ListenAndServe(*internalPort, unprotected))
-	}()
-
-	r := mux.NewRouter()
+// See baseapp.App.
+func (srv *Server) AddHandlers(r *mux.Router) {
 	r.HandleFunc("/", srv.mainHandler)
+	r.HandleFunc("/loginstatus/", login.StatusHandler).Methods("GET")
+
 	// GETs
 	r.HandleFunc("/_/emails", srv.emailsHandler).Methods("GET")
 	r.HandleFunc("/_/incidents", srv.incidentHandler).Methods("GET")
 	r.HandleFunc("/_/new_silence", srv.newSilenceHandler).Methods("GET")
 	r.HandleFunc("/_/recent_incidents", srv.recentIncidentsHandler).Methods("GET")
 	r.HandleFunc("/_/silences", srv.silencesHandler).Methods("GET")
-	r.HandleFunc("/loginstatus/", login.StatusHandler).Methods("GET")
 
 	// POSTs
 	r.HandleFunc("/_/add_note", srv.addNoteHandler).Methods("POST")
@@ -692,18 +585,27 @@
 	r.HandleFunc("/_/take", srv.takeHandler).Methods("POST")
 	r.HandleFunc("/_/stats", srv.statsHandler).Methods("POST")
 	r.HandleFunc("/_/incidents_in_range", srv.incidentsInRangeHandler).Methods("POST")
+}
 
-	r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.HandlerFunc(httputils.MakeResourceHandler(*resourcesDir))))
-
-	h := srv.applySecurityWrappers(r)
-	if !*local {
-		h = httputils.LoggingGzipRequestResponse(h)
-		h = login.RestrictViewer(h)
-		h = login.ForceAuth(h, login.DEFAULT_REDIRECT_URL)
-		h = httputils.HealthzAndHTTPS(h)
+// See baseapp.App.
+func (srv *Server) AddMiddleware() []mux.MiddlewareFunc {
+	ret := []mux.MiddlewareFunc{}
+	if !*baseapp.Local {
+		ret = append(ret, login.ForceAuthMiddleware(login.DEFAULT_REDIRECT_URL), login.RestrictViewer)
 	}
-	h = cspReportWrapper(h)
-	http.Handle("/", h)
-	sklog.Infoln("Ready to serve.")
-	sklog.Fatal(http.ListenAndServe(*port, nil))
+	return ret
+}
+
+func (srv *Server) startInternalServer() {
+	// Internal endpoints that are only accessible from within the cluster.
+	unprotected := mux.NewRouter()
+	unprotected.HandleFunc("/_/incidents", srv.incidentHandler).Methods("GET")
+	unprotected.HandleFunc("/_/silences", srv.silencesHandler).Methods("GET")
+	go func() {
+		sklog.Fatal(http.ListenAndServe(*internalPort, unprotected))
+	}()
+}
+
+func main() {
+	baseapp.Serve(New, []string{"am.skia.org"})
 }
diff --git a/am/modules/alert-manager-sk/alert-manager-sk.js b/am/modules/alert-manager-sk/alert-manager-sk.js
index 2fc1282..e6304e3 100644
--- a/am/modules/alert-manager-sk/alert-manager-sk.js
+++ b/am/modules/alert-manager-sk/alert-manager-sk.js
@@ -4,7 +4,6 @@
  *
  *   The main application element for am.skia.org.
  *
- * @attr csrf - The csrf string to attach to POST requests, based64 encoded.
  */
 import 'elements-sk/checkbox-sk'
 import 'elements-sk/error-toast-sk'
@@ -531,7 +530,6 @@
     }
   }
 
-
   // Common work done for all fetch requests.
   _doImpl(url, detail, action=json => this._incidentAction(json)) {
     this._busy.active = true;
@@ -539,7 +537,6 @@
       body: JSON.stringify(detail),
       headers: {
         'content-type': 'application/json',
-        'X-CSRF-Token': atob(this.getAttribute('csrf')),
       },
       credentials: 'include',
       method: 'POST',
diff --git a/am/pages/index.html b/am/pages/index.html
index a37cbb6..8d48e2d 100644
--- a/am/pages/index.html
+++ b/am/pages/index.html
@@ -8,6 +8,6 @@
     <link id=favicon rel="icon" type="image/png" href="/static/icon-active.png">
 </head>
 <body>
-  <alert-manager-sk csrf="{%.csrf%}"></alert-manager-sk>
+  <alert-manager-sk></alert-manager-sk>
 </body>
 </html>
diff --git a/go/baseapp/baseapp.go b/go/baseapp/baseapp.go
new file mode 100644
index 0000000..ce56ee1
--- /dev/null
+++ b/go/baseapp/baseapp.go
@@ -0,0 +1,187 @@
+package baseapp
+
+import (
+	"encoding/json"
+	"flag"
+	"fmt"
+	"net/http"
+	"path/filepath"
+	"runtime"
+	"time"
+
+	"github.com/gorilla/mux"
+	"github.com/skia-dev/secure"
+	"go.skia.org/infra/go/common"
+	"go.skia.org/infra/go/httputils"
+	"go.skia.org/infra/go/sklog"
+)
+
+var (
+	Local        = flag.Bool("local", false, "Running locally if true. As opposed to in production.")
+	Port         = flag.String("port", ":8000", "HTTP service address (e.g., ':8000')")
+	PromPort     = flag.String("prom_port", ":20000", "Metrics service address (e.g., ':10110')")
+	ResourcesDir = flag.String("resources_dir", "", "The directory to find templates, JS, and CSS files. If blank the current directory will be used.")
+)
+
+const (
+	SERVER_READ_TIMEOUT  = 5 * time.Minute
+	SERVER_WRITE_TIMEOUT = 5 * time.Minute
+)
+
+// Applications that want to use Serve() must conform the App interface.
+type App interface {
+	// AddHandlers is called by Serve and the receiver must add all handlers
+	// to the passed in mux.Router.
+	AddHandlers(*mux.Router)
+
+	// AddMiddleware returns a list of mux.Middleware's to add to the router.
+	// This is a good place to add auth middleware.
+	AddMiddleware() []mux.MiddlewareFunc
+}
+
+// Constructor is a function that builds an App instance.
+//
+// Used as a parameter to Serve.
+type Constructor func() (App, error)
+
+// cspReporter takes csp failure reports and turns them into structured log
+// entries.
+func cspReporter(w http.ResponseWriter, r *http.Request) {
+	var body interface{}
+	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+		sklog.Errorf("Failed to decode csp report: %s", err)
+		return
+	}
+	c := struct {
+		Type string      `json:"type"`
+		Body interface{} `json:"body"`
+	}{
+		Type: "csp",
+		Body: body,
+	}
+	b, err := json.Marshal(c)
+	if err != nil {
+		sklog.Errorf("Failed to marshal csp log entry: %s", err)
+		return
+	}
+	fmt.Println(string(b))
+	return
+}
+
+func securityMiddleware(allowedHosts []string) mux.MiddlewareFunc {
+	// Configure Content Security Policy (CSP).
+	addScriptSrc := ""
+	if *Local {
+		// webpack uses eval() in development mode, so allow unsafe-eval when local.
+		addScriptSrc = "'unsafe-eval'"
+	}
+	// This non-local CSP string passes the tests at https://csp-evaluator.withgoogle.com/.
+	//
+	// See also: https://csp.withgoogle.com/docs/strict-csp.html
+	cspString := fmt.Sprintf("base-uri 'none';  img-src 'self' ; object-src 'none' ; style-src 'self' 'unsafe-inline' ; script-src 'strict-dynamic' $NONCE 'unsafe-inline' %s https: http: ; report-uri /cspreport ;", addScriptSrc)
+
+	// Apply CSP and other security minded headers.
+	secureMiddleware := secure.New(secure.Options{
+		AllowedHosts:          allowedHosts,
+		HostsProxyHeaders:     []string{"X-Forwarded-Host"},
+		SSLRedirect:           true,
+		SSLProxyHeaders:       map[string]string{"X-Forwarded-Proto": "https"},
+		STSSeconds:            60 * 60 * 24 * 365,
+		STSIncludeSubdomains:  true,
+		ContentSecurityPolicy: cspString,
+		IsDevelopment:         *Local,
+	})
+
+	return secureMiddleware.Handler
+}
+
+// Serve builds and runs the App in a secure manner in out kubernetes cluster.
+//
+// constructor - Builds an App instance. Note that we don't pass in an App instance directly, because
+//    we want the constructor called after the common.Init*() functions are called, i.e. after flags
+//    are parsed.
+// allowedHosts - The list of domains that are allowed to make requests to this app. Make sure to include
+//    the domain name of the app itself. For example; []string{"am.skia.org"}.
+//
+// See https://csp.withgoogle.com/docs/strict-csp.html for more information on
+// Strict CSP in general.
+//
+// For this to work every script and style tag must have a nonce attribute
+// whose value matches the one sent in the Content-Security-Policy: header. You
+// can have webpack inject an attribute with a template for the nonce by adding
+// the HtmlWebPackInjectAttributesPlugin to your plugins, i.e.
+//
+//    config.plugins.push(
+//      new HtmlWebpackInjectAttributesPlugin({
+//        nonce: "{%.nonce%}",
+//      }),
+//    );
+//
+// And then include that nonce when expanding any pages:
+//
+// 	  if err := srv.templates.ExecuteTemplate(w, "index.html", map[string]string{
+// 		  "nonce": secure.CSPNonce(r.Context()),
+//   	}); err != nil {
+//	  	sklog.Errorf("Failed to expand template: %s", err)
+//  	}
+//
+// Since our audience is small and only uses modern browsers we shouldn't need
+// any further XSS protection. For example, even if a user is logged into
+// another Google site that is compromised, while they can request the main
+// index page and get both the csrf token and value, they couldn't POST it back
+// to the site we are serving since that site wouldn't be listed in
+// allowedHosts.
+//
+// CSP failures will be logged as structured log events.
+//
+// Static resources, e.g. webpack output, will be served at '/static/' and will
+// serve the contents of the '/dist' directory.
+func Serve(constructor Constructor, allowedHosts []string) {
+	// Do common init.
+	common.InitWithMust(
+		"generic-k8s-app", // The app name is only used by ../go/sklog/cloud_logging, and we don't use that on k8s.
+		common.PrometheusOpt(PromPort),
+		common.MetricsLoggingOpt(),
+	)
+
+	// Fix up flag values.
+	if *ResourcesDir == "" {
+		_, filename, _, _ := runtime.Caller(1)
+		*ResourcesDir = filepath.Join(filepath.Dir(filename), "../../dist")
+	}
+
+	// Build App instance.
+	app, err := constructor()
+	if err != nil {
+		sklog.Fatal(err)
+	}
+
+	// Add all routing.
+	r := mux.NewRouter()
+	r.HandleFunc("/cspreport", cspReporter).Methods("POST")
+	r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.HandlerFunc(httputils.MakeResourceHandler(*ResourcesDir))))
+	app.AddHandlers(r)
+
+	// Layer on all the middleware.
+	middleware := []mux.MiddlewareFunc{}
+	if !*Local {
+		middleware = append(middleware, httputils.HealthzAndHTTPS)
+	}
+	middleware = append(middleware, app.AddMiddleware()...)
+	middleware = append(middleware,
+		httputils.LoggingGzipRequestResponse,
+		securityMiddleware(allowedHosts),
+	)
+	r.Use(middleware...)
+
+	// Start serving.
+	sklog.Infoln("Ready to serve.")
+	server := &http.Server{
+		Addr:           *Port,
+		Handler:        r,
+		ReadTimeout:    SERVER_READ_TIMEOUT,
+		WriteTimeout:   SERVER_WRITE_TIMEOUT,
+		MaxHeaderBytes: 1 << 20,
+	}
+	sklog.Fatal(server.ListenAndServe())
+}
diff --git a/go/login/login.go b/go/login/login.go
index 405610a..7d64fff 100644
--- a/go/login/login.go
+++ b/go/login/login.go
@@ -588,6 +588,14 @@
 	}
 }
 
+// ForceAuthMiddleware does ForceAuth by returning a func that can be used as
+// middleware.
+func ForceAuthMiddleware(oauthCallbackPath string) func(http.Handler) http.Handler {
+	return func(h http.Handler) http.Handler {
+		return ForceAuth(h, oauthCallbackPath)
+	}
+}
+
 // ForceAuth is middleware that enforces authentication
 // before the wrapped handler is called. oauthCallbackPath is the
 // URL path that the user is redirected to at the end of the auth flow.