github.com/google/syzkaller@v0.0.0-20251211124644-a066d2bc4b02/dashboard/app/handler.go (about)

     1  // Copyright 2017 syzkaller project authors. All rights reserved.
     2  // Use of this source code is governed by Apache 2 LICENSE that can be found in the LICENSE file.
     3  
     4  package main
     5  
     6  import (
     7  	"bytes"
     8  	"compress/gzip"
     9  	"context"
    10  	"encoding/base64"
    11  	"encoding/json"
    12  	"errors"
    13  	"fmt"
    14  	"io"
    15  	"net/http"
    16  	"sort"
    17  	"strings"
    18  	"time"
    19  
    20  	"github.com/google/syzkaller/pkg/html"
    21  	"google.golang.org/appengine/v2/log"
    22  	"google.golang.org/appengine/v2/user"
    23  )
    24  
    25  // This file contains common middleware for UI handlers (auth, html templates, etc).
    26  
    27  type contextHandler func(c context.Context, w http.ResponseWriter, r *http.Request) error
    28  
    29  func handlerWrapper(fn contextHandler) http.Handler {
    30  	return handleContext(handleAuth(fn))
    31  }
    32  
    33  func handleContext(fn contextHandler) http.Handler {
    34  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    35  		c := context.WithValue(r.Context(), &currentURLKey, r.URL.RequestURI())
    36  		authorizedUser, _ := userAccessLevel(currentUser(c), "", getConfig(c))
    37  		if !authorizedUser {
    38  			if !throttleRequest(c, w, r) {
    39  				return
    40  			}
    41  			defer backpressureRobots(c, r)()
    42  		}
    43  
    44  		w.Header().Set("Content-Type", "text/html; charset=utf-8")
    45  		gzw := newGzipResponseWriterCloser(w)
    46  		defer gzw.Close()
    47  		err := fn(c, gzw, r)
    48  		if err == nil {
    49  			if err = gzw.writeResult(r); err == nil {
    50  				return
    51  			}
    52  		}
    53  		hdr := commonHeaderRaw(c, r)
    54  		data := &struct {
    55  			Header  *uiHeader
    56  			Error   string
    57  			TraceID string
    58  		}{
    59  			Header:  hdr,
    60  			Error:   err.Error(),
    61  			TraceID: strings.Join(r.Header["X-Cloud-Trace-Context"], " "),
    62  		}
    63  		if err == ErrAccess {
    64  			if hdr.LoginLink != "" {
    65  				http.Redirect(w, r, hdr.LoginLink, http.StatusTemporaryRedirect)
    66  				return
    67  			}
    68  			http.Error(w, "403 Forbidden", http.StatusForbidden)
    69  			return
    70  		}
    71  		var redir *ErrRedirect
    72  		if errors.As(err, &redir) {
    73  			http.Redirect(w, r, redir.Error(), http.StatusFound)
    74  			return
    75  		}
    76  
    77  		status := logErrorPrepareStatus(c, err)
    78  		w.WriteHeader(status)
    79  		if err1 := templates.ExecuteTemplate(w, "error.html", data); err1 != nil {
    80  			combinedError := fmt.Sprintf("got err \"%v\" processing ExecuteTemplate() for err \"%v\"", err1, err)
    81  			http.Error(w, combinedError, http.StatusInternalServerError)
    82  		}
    83  	})
    84  }
    85  
    86  func logErrorPrepareStatus(c context.Context, err error) int {
    87  	status := http.StatusInternalServerError
    88  	logf := log.Errorf
    89  	var clientError *ErrClient
    90  	if errors.As(err, &clientError) {
    91  		// We don't log these as errors because they can be provoked
    92  		// by invalid user requests, so we don't wan't to pollute error log.
    93  		logf = log.Warningf
    94  		status = clientError.HTTPStatus()
    95  	}
    96  	logf(c, "appengine error: %v", err)
    97  	return status
    98  }
    99  
   100  func isRobot(r *http.Request) bool {
   101  	userAgent := strings.ToLower(strings.Join(r.Header["User-Agent"], " "))
   102  	if strings.HasPrefix(userAgent, "curl") ||
   103  		strings.HasPrefix(userAgent, "wget") {
   104  		return true
   105  	}
   106  	return false
   107  }
   108  
   109  // We don't count the request round trip time here.
   110  // Actual delay will be the minDelay + requestRoundTripTime.
   111  func backpressureRobots(c context.Context, r *http.Request) func() {
   112  	if !isRobot(r) {
   113  		return func() {}
   114  	}
   115  	cfg := getConfig(c).Throttle
   116  	if cfg.Empty() {
   117  		return func() {}
   118  	}
   119  	minDelay := cfg.Window / time.Duration(cfg.Limit)
   120  	delayUntil := time.Now().Add(minDelay)
   121  	return func() {
   122  		select {
   123  		case <-c.Done():
   124  		case <-time.After(time.Until(delayUntil)):
   125  		}
   126  	}
   127  }
   128  
   129  func throttleRequest(c context.Context, w http.ResponseWriter, r *http.Request) bool {
   130  	// AppEngine removes all App Engine-specific headers, which include
   131  	// X-Appengine-User-IP and X-Forwarded-For.
   132  	// https://cloud.google.com/appengine/docs/standard/reference/request-headers?tab=python#removed_headers
   133  	ip := r.Header.Get("X-Appengine-User-IP")
   134  	if ip == "" {
   135  		ip = r.Header.Get("X-Forwarded-For")
   136  		ip, _, _ = strings.Cut(ip, ",") // X-Forwarded-For is a comma-delimited list.
   137  		ip = strings.TrimSpace(ip)
   138  	}
   139  	cron := r.Header.Get("X-Appengine-Cron") != ""
   140  	if ip == "" || cron {
   141  		log.Infof(c, "cannot throttle request from %q, cron %t", ip, cron)
   142  		return true
   143  	}
   144  	accept, err := ThrottleRequest(c, ip)
   145  	if errors.Is(err, ErrThrottleTooManyRetries) {
   146  		// We get these at peak QPS anyway, it's not an error.
   147  		log.Warningf(c, "failed to throttle: %v", err)
   148  	} else if err != nil {
   149  		log.Errorf(c, "failed to throttle: %v", err)
   150  	}
   151  	log.Infof(c, "throttling for %q: %t", ip, accept)
   152  	if !accept {
   153  		http.Error(w, throttlingErrorMessage(c), http.StatusTooManyRequests)
   154  		return false
   155  	}
   156  	return true
   157  }
   158  
   159  func throttlingErrorMessage(c context.Context) string {
   160  	ret := fmt.Sprintf("429 Too Many Requests\nAllowed rate is %d requests per %d seconds.",
   161  		getConfig(c).Throttle.Limit, int(getConfig(c).Throttle.Window.Seconds()))
   162  	email := getConfig(c).ContactEmail
   163  	if email == "" {
   164  		return ret
   165  	}
   166  	return fmt.Sprintf("%s\nPlease contact us at %s if you need access to our data.", ret, email)
   167  }
   168  
   169  var currentURLKey = "the URL of the HTTP request in context"
   170  
   171  func getCurrentURL(c context.Context) string {
   172  	val, ok := c.Value(&currentURLKey).(string)
   173  	if ok {
   174  		return val
   175  	}
   176  	return ""
   177  }
   178  
   179  type (
   180  	ErrClient   struct{ error }
   181  	ErrRedirect struct{ error }
   182  )
   183  
   184  var ErrClientNotFound = &ErrClient{errors.New("resource not found")}
   185  var ErrClientBadRequest = &ErrClient{errors.New("bad request")}
   186  
   187  func (ce *ErrClient) HTTPStatus() int {
   188  	switch ce {
   189  	case ErrClientNotFound:
   190  		return http.StatusNotFound
   191  	case ErrClientBadRequest:
   192  		return http.StatusBadRequest
   193  	}
   194  	return http.StatusInternalServerError
   195  }
   196  
   197  func handleAuth(fn contextHandler) contextHandler {
   198  	return func(c context.Context, w http.ResponseWriter, r *http.Request) error {
   199  		if err := checkAccessLevel(c, r, getConfig(c).AccessLevel); err != nil {
   200  			return err
   201  		}
   202  		return fn(c, w, r)
   203  	}
   204  }
   205  
   206  func serveTemplate(w http.ResponseWriter, name string, data interface{}) error {
   207  	buf := new(bytes.Buffer)
   208  	if err := templates.ExecuteTemplate(buf, name, data); err != nil {
   209  		return err
   210  	}
   211  	w.Write(buf.Bytes())
   212  	return nil
   213  }
   214  
   215  type uiHeader struct {
   216  	Admin               bool
   217  	URLPath             string
   218  	LoginLink           string
   219  	AnalyticsTrackingID string
   220  	Subpage             string
   221  	Namespace           string
   222  	ContactEmail        string
   223  	BugCounts           *CachedBugStats
   224  	MissingBackports    int
   225  	Namespaces          []uiNamespace
   226  	ShowSubsystems      bool
   227  	ShowCoverageMenu    bool
   228  }
   229  
   230  type uiNamespace struct {
   231  	Name    string
   232  	Caption string
   233  }
   234  
   235  type cookieData struct {
   236  	Namespace string `json:"namespace"`
   237  }
   238  
   239  func commonHeaderRaw(c context.Context, r *http.Request) *uiHeader {
   240  	h := &uiHeader{
   241  		Admin:               accessLevel(c, r) == AccessAdmin,
   242  		URLPath:             r.URL.Path,
   243  		AnalyticsTrackingID: getConfig(c).AnalyticsTrackingID,
   244  		ContactEmail:        getConfig(c).ContactEmail,
   245  	}
   246  	if user.Current(c) == nil {
   247  		h.LoginLink, _ = user.LoginURL(c, r.URL.String())
   248  	}
   249  	return h
   250  }
   251  
   252  func commonHeader(c context.Context, r *http.Request, w http.ResponseWriter, ns string) (*uiHeader, error) {
   253  	accessLevel := accessLevel(c, r)
   254  	if ns == "" {
   255  		ns = strings.ToLower(r.URL.Path)
   256  		if ns != "" && ns[0] == '/' {
   257  			ns = ns[1:]
   258  		}
   259  		if pos := strings.IndexByte(ns, '/'); pos != -1 {
   260  			ns = ns[:pos]
   261  		}
   262  	}
   263  	h := commonHeaderRaw(c, r)
   264  	const adminPage = "admin"
   265  	isAdminPage := r.URL.Path == "/"+adminPage
   266  	found := false
   267  	for ns1, cfg := range getConfig(c).Namespaces {
   268  		if accessLevel < cfg.AccessLevel {
   269  			if ns1 == ns {
   270  				return nil, ErrAccess
   271  			}
   272  			continue
   273  		}
   274  		if ns1 == ns {
   275  			found = true
   276  		}
   277  		if getNsConfig(c, ns1).Decommissioned {
   278  			continue
   279  		}
   280  		h.Namespaces = append(h.Namespaces, uiNamespace{
   281  			Name:    ns1,
   282  			Caption: cfg.DisplayTitle,
   283  		})
   284  	}
   285  	sort.Slice(h.Namespaces, func(i, j int) bool {
   286  		return h.Namespaces[i].Caption < h.Namespaces[j].Caption
   287  	})
   288  	cookie := decodeCookie(r)
   289  	if !found {
   290  		ns = getConfig(c).DefaultNamespace
   291  		if cfg := getNsConfig(c, cookie.Namespace); cfg != nil && cfg.AccessLevel <= accessLevel {
   292  			ns = cookie.Namespace
   293  		}
   294  		if accessLevel == AccessAdmin {
   295  			ns = adminPage
   296  		}
   297  		if ns != adminPage || !isAdminPage {
   298  			return nil, &ErrRedirect{fmt.Errorf("/%v", ns)}
   299  		}
   300  	}
   301  	if ns != adminPage {
   302  		h.Namespace = ns
   303  		h.ShowSubsystems = getNsConfig(c, ns).Subsystems.Service != nil
   304  		cookie.Namespace = ns
   305  		encodeCookie(w, cookie)
   306  		cached, err := CacheGet(c, r, ns)
   307  		if err != nil {
   308  			return nil, err
   309  		}
   310  		h.BugCounts = &cached.Total
   311  		h.MissingBackports = cached.MissingBackports
   312  		h.ShowCoverageMenu = getNsConfig(c, ns).Coverage != nil
   313  	}
   314  	return h, nil
   315  }
   316  
   317  const cookieName = "syzkaller"
   318  
   319  func decodeCookie(r *http.Request) *cookieData {
   320  	cd := new(cookieData)
   321  	cookie, err := r.Cookie(cookieName)
   322  	if err != nil {
   323  		return cd
   324  	}
   325  	decoded, err := base64.StdEncoding.DecodeString(cookie.Value)
   326  	if err != nil {
   327  		return cd
   328  	}
   329  	json.Unmarshal(decoded, cd)
   330  	return cd
   331  }
   332  
   333  func encodeCookie(w http.ResponseWriter, cd *cookieData) {
   334  	data, err := json.Marshal(cd)
   335  	if err != nil {
   336  		return
   337  	}
   338  	cookie := &http.Cookie{
   339  		Name:    cookieName,
   340  		Value:   base64.StdEncoding.EncodeToString(data),
   341  		Expires: time.Now().Add(time.Hour * 24 * 365),
   342  	}
   343  	http.SetCookie(w, cookie)
   344  }
   345  
   346  var templates = html.CreateGlob("*.html")
   347  
   348  // gzipResponseWriterCloser accumulates the gzipped result.
   349  // In case of error during the handler processing, we'll drop this gzipped data.
   350  // It allows to call http.Error in the middle of the response generation.
   351  //
   352  // For 200 Ok responses we return the compressed data or decompress it depending on the client Accept-Encoding header.
   353  type gzipResponseWriterCloser struct {
   354  	w                 *gzip.Writer
   355  	plainResponseSize int
   356  	buf               *bytes.Buffer
   357  	rw                http.ResponseWriter
   358  }
   359  
   360  func (g *gzipResponseWriterCloser) Write(p []byte) (n int, err error) {
   361  	g.plainResponseSize += len(p)
   362  	return g.w.Write(p)
   363  }
   364  
   365  func (g *gzipResponseWriterCloser) Close() {
   366  	if g.w != nil {
   367  		g.w.Close()
   368  	}
   369  }
   370  
   371  func (g *gzipResponseWriterCloser) Header() http.Header {
   372  	return g.rw.Header()
   373  }
   374  
   375  func (g *gzipResponseWriterCloser) WriteHeader(statusCode int) {
   376  	g.rw.WriteHeader(statusCode)
   377  }
   378  
   379  func (g *gzipResponseWriterCloser) writeResult(r *http.Request) error {
   380  	g.w.Close()
   381  	g.w = nil
   382  	clientSupportsGzip := strings.Contains(r.Header.Get("Accept-Encoding"), "gzip")
   383  	if clientSupportsGzip {
   384  		g.rw.Header().Set("Content-Encoding", "gzip")
   385  		_, err := g.rw.Write(g.buf.Bytes())
   386  		return err
   387  	}
   388  	if g.plainResponseSize > 31<<20 { // 32MB is the AppEngine hard limit for the response size.
   389  		return fmt.Errorf("len(response) > 31M, try to request gzipped: %w", ErrClientBadRequest)
   390  	}
   391  	gzr, err := gzip.NewReader(g.buf)
   392  	if err != nil {
   393  		return fmt.Errorf("gzip.NewReader: %w", err)
   394  	}
   395  	defer gzr.Close()
   396  	_, err = io.Copy(g.rw, gzr)
   397  	return err
   398  }
   399  
   400  func newGzipResponseWriterCloser(w http.ResponseWriter) *gzipResponseWriterCloser {
   401  	buf := &bytes.Buffer{}
   402  	return &gzipResponseWriterCloser{
   403  		w:   gzip.NewWriter(buf),
   404  		buf: buf,
   405  		rw:  w,
   406  	}
   407  }