| package baseapp |
| |
| import ( |
| "encoding/json" |
| "flag" |
| "fmt" |
| "net/http" |
| "os" |
| "path/filepath" |
| "runtime" |
| "time" |
| |
| "github.com/go-chi/chi/v5" |
| "github.com/unrolled/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 |
| ) |
| |
| // App is the interface that Constructor returns. |
| type App interface { |
| // AddHandlers is called by Serve and the receiver must add all handlers |
| // to the passed in chi.Router. |
| AddHandlers(chi.Router) |
| |
| // AddMiddleware returns a list of middleware functions to add to the router. |
| // This is a good place to add auth middleware. |
| AddMiddleware() []func(http.Handler) http.Handler |
| } |
| |
| // 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 |
| } |
| |
| // cspString returns a properly formatted content security policy string. |
| func cspString(allowedHosts []string, local bool, options []Option) string { |
| addScriptSrc := "" |
| // Currently, when executing WebAssembly, if there is a non-empty CSP policy for a page (such as |
| // when we are running with --local), the unsafe-eval policy must be enabled. See |
| // https://chromestatus.com/feature/5499765773041664. |
| if local || hasWASMOption(options) { |
| addScriptSrc = "'unsafe-eval'" |
| } |
| |
| imgSrc := "'self'" |
| if hasAllowAnyImageOption(options) { |
| // unsafe-eval allows us to get to the underlying bits of the image. |
| imgSrc = "* 'unsafe-eval' blob: data:" |
| } |
| |
| // This non-local, CSP string without any options passes the tests at https://csp-evaluator.withgoogle.com/. |
| // |
| // See also: https://csp.withgoogle.com/docs/strict-csp.html |
| // |
| return fmt.Sprintf("base-uri 'none'; img-src %s ; object-src 'none' ; style-src 'self' https://fonts.googleapis.com/ https://www.gstatic.com/ 'unsafe-inline' ; script-src 'strict-dynamic' $NONCE %s 'unsafe-inline' https: http: ; report-uri /cspreport ;", imgSrc, addScriptSrc) |
| } |
| |
| // SecurityMiddleware sets the CPS headers. |
| func SecurityMiddleware(allowedHosts []string, local bool, options []Option) func(http.Handler) http.Handler { |
| |
| // 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(allowedHosts, local, options), |
| IsDevelopment: local, |
| }) |
| |
| return secureMiddleware.Handler |
| } |
| |
| // Option is the base type for options passed to Serve(). |
| type Option interface{} |
| |
| // AllowWASM allows 'unsafe-eval' for scripts, which is needed for WASM. |
| type AllowWASM struct{} |
| |
| func hasWASMOption(options []Option) bool { |
| for _, opt := range options { |
| if _, ok := opt.(AllowWASM); ok { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // AllowAnyImage allows images to be loaded from all sources, not just self. |
| type AllowAnyImage struct{} |
| |
| func hasAllowAnyImageOption(options []Option) bool { |
| for _, opt := range options { |
| if _, ok := opt.(AllowAnyImage); ok { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // DisableResponseGZip disables the automatic gzipping of responses regardless |
| // of the contents of the "Accept-Encoding" header. Required for services like |
| // verdaccio- https://verdaccio.org/docs/reverse-proxy/#invalid-checksum |
| type DisableResponseGZip struct{} |
| |
| func hasDisableResponseGZip(options []Option) bool { |
| for _, opt := range options { |
| if _, ok := opt.(DisableResponseGZip); ok { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // DisableLoggingRequestResponse disables LoggingRequestResponse, which doesn't |
| // work with Server-Sent Events. |
| type DisableLoggingRequestResponse struct{} |
| |
| func hasDisableLoggingRequestResponse(options []Option) bool { |
| for _, opt := range options { |
| if _, ok := opt.(DisableLoggingRequestResponse); ok { |
| return true |
| } |
| } |
| return false |
| } |
| |
| var ( |
| // Whether or not this is a Serve test. |
| isServeTest bool |
| |
| // server started by the Serve function. Used from tests to gracefully stop the server. |
| server *http.Server |
| |
| // error returned by server.ListenAndServe. Used from tests. |
| listenAndServeErr error |
| ) |
| |
| // Serve builds and runs the App in a secure manner in our kubernetes cluster. |
| // |
| // The 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. |
| // |
| // The allowedHosts are 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 Bazel inject an attribute with a template for the nonce to all |
| // <script> and <link> tags via the sk_page rule's nonce attribute, e.g. |
| // |
| // load("//infra-sk:index.bzl", "sk_page") |
| // |
| // sk_page( |
| // name = "index", |
| // html_file = "index.html", |
| // 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. Bazel-built HTML, CSS and JS files, will be served at |
| // '/dist/' and will serve the contents of the '/dist' directory. |
| func Serve(constructor Constructor, allowedHosts []string, options ...Option) { |
| // Do common init. |
| common.InitWithMust( |
| "generic-k8s-app", |
| common.PrometheusOpt(PromPort), |
| ) |
| |
| // 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 := chi.NewRouter() |
| |
| // Layer on all the middleware. |
| // |
| // chi.Router panics if we add middleware after defining routes, so we must add our middleware |
| // functions first. |
| middleware := []func(http.Handler) http.Handler{} |
| if !*Local { |
| middleware = append(middleware, httputils.HealthzAndHTTPS) |
| } |
| middleware = append(middleware, app.AddMiddleware()...) |
| |
| if !hasDisableLoggingRequestResponse(options) { |
| middleware = append(middleware, httputils.LoggingRequestResponse) |
| } |
| |
| if !hasDisableResponseGZip(options) { |
| middleware = append(middleware, httputils.GzipRequestResponse) |
| } |
| |
| middleware = append(middleware, SecurityMiddleware(allowedHosts, *Local, options)) |
| r.Use(middleware...) |
| |
| r.Post("/cspreport", cspReporter) |
| // The /static/ path is kept for legacy apps, but all apps should migrate to /dist/ |
| // to work with puppeteer. |
| r.Handle("/static/*", http.StripPrefix("/static/", http.HandlerFunc(httputils.MakeResourceHandler(*ResourcesDir)))) |
| r.Handle("/dist/*", http.StripPrefix("/dist/", http.HandlerFunc(httputils.MakeResourceHandler(*ResourcesDir)))) |
| app.AddHandlers(r) |
| |
| // We must specify that we handle /healthz or it will never flow through to our middleware. |
| // Even though this handler is never actually called (due to the early termination in |
| // httputils.HealthzAndHTTPS), we need to have it added to the routes we handle. |
| r.HandleFunc("/healthz", httputils.ReadyHandleFunc) |
| |
| // Start serving. |
| hostname, err := os.Hostname() |
| if err != nil { |
| sklog.Fatal(err) |
| } |
| sklog.Infof("Ready to serve at http://%s%s", hostname, *Port) // The port string includes a colon, e.g. ":8000". |
| server = &http.Server{ |
| Addr: *Port, |
| Handler: r, |
| ReadTimeout: SERVER_READ_TIMEOUT, |
| WriteTimeout: SERVER_WRITE_TIMEOUT, |
| MaxHeaderBytes: 1 << 20, |
| } |
| if isServeTest { |
| listenAndServeErr = server.ListenAndServe() |
| } else { |
| sklog.Fatal(server.ListenAndServe()) |
| } |
| } |