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(), ¤tURLKey, 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(¤tURLKey).(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 }