github.com/safing/portbase@v0.19.5/api/router.go (about) 1 package api 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "net/http" 8 "net/url" 9 "path" 10 "runtime/debug" 11 "strings" 12 "sync" 13 "time" 14 15 "github.com/gorilla/mux" 16 17 "github.com/safing/portbase/log" 18 "github.com/safing/portbase/utils" 19 ) 20 21 // EnableServer defines if the HTTP server should be started. 22 var EnableServer = true 23 24 var ( 25 // mainMux is the main mux router. 26 mainMux = mux.NewRouter() 27 28 // server is the main server. 29 server = &http.Server{ 30 ReadHeaderTimeout: 10 * time.Second, 31 } 32 handlerLock sync.RWMutex 33 34 allowedDevCORSOrigins = []string{ 35 "127.0.0.1", 36 "localhost", 37 } 38 ) 39 40 // RegisterHandler registers a handler with the API endpoint. 41 func RegisterHandler(path string, handler http.Handler) *mux.Route { 42 handlerLock.Lock() 43 defer handlerLock.Unlock() 44 return mainMux.Handle(path, handler) 45 } 46 47 // RegisterHandleFunc registers a handle function with the API endpoint. 48 func RegisterHandleFunc(path string, handleFunc func(http.ResponseWriter, *http.Request)) *mux.Route { 49 handlerLock.Lock() 50 defer handlerLock.Unlock() 51 return mainMux.HandleFunc(path, handleFunc) 52 } 53 54 func startServer() { 55 // Check if server is enabled. 56 if !EnableServer { 57 return 58 } 59 60 // Configure server. 61 server.Addr = listenAddressConfig() 62 server.Handler = &mainHandler{ 63 // TODO: mainMux should not be modified anymore. 64 mux: mainMux, 65 } 66 67 // Start server manager. 68 module.StartServiceWorker("http server manager", 0, serverManager) 69 } 70 71 func stopServer() error { 72 // Check if server is enabled. 73 if !EnableServer { 74 return nil 75 } 76 77 if server.Addr != "" { 78 return server.Shutdown(context.Background()) 79 } 80 81 return nil 82 } 83 84 // Serve starts serving the API endpoint. 85 func serverManager(_ context.Context) error { 86 // start serving 87 log.Infof("api: starting to listen on %s", server.Addr) 88 backoffDuration := 10 * time.Second 89 for { 90 // always returns an error 91 err := module.RunWorker("http endpoint", func(ctx context.Context) error { 92 return server.ListenAndServe() 93 }) 94 // return on shutdown error 95 if errors.Is(err, http.ErrServerClosed) { 96 return nil 97 } 98 // log error and restart 99 log.Errorf("api: http endpoint failed: %s - restarting in %s", err, backoffDuration) 100 time.Sleep(backoffDuration) 101 } 102 } 103 104 type mainHandler struct { 105 mux *mux.Router 106 } 107 108 func (mh *mainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 109 _ = module.RunWorker("http request", func(_ context.Context) error { 110 return mh.handle(w, r) 111 }) 112 } 113 114 func (mh *mainHandler) handle(w http.ResponseWriter, r *http.Request) error { 115 // Setup context trace logging. 116 ctx, tracer := log.AddTracer(r.Context()) 117 // Add request context. 118 apiRequest := &Request{ 119 Request: r, 120 } 121 ctx = context.WithValue(ctx, RequestContextKey, apiRequest) 122 // Add context back to request. 123 r = r.WithContext(ctx) 124 lrw := NewLoggingResponseWriter(w, r) 125 126 tracer.Tracef("api request: %s ___ %s %s", r.RemoteAddr, lrw.Request.Method, r.RequestURI) 127 defer func() { 128 // Log request status. 129 if lrw.Status != 0 { 130 // If lrw.Status is 0, the request may have been hijacked. 131 tracer.Debugf("api request: %s %d %s %s", lrw.Request.RemoteAddr, lrw.Status, lrw.Request.Method, lrw.Request.RequestURI) 132 } 133 tracer.Submit() 134 }() 135 136 // Add security headers. 137 w.Header().Set("Referrer-Policy", "same-origin") 138 w.Header().Set("X-Content-Type-Options", "nosniff") 139 w.Header().Set("X-Frame-Options", "deny") 140 w.Header().Set("X-XSS-Protection", "1; mode=block") 141 w.Header().Set("X-DNS-Prefetch-Control", "off") 142 143 // Add CSP Header in production mode. 144 if !devMode() { 145 w.Header().Set( 146 "Content-Security-Policy", 147 "default-src 'self'; "+ 148 "connect-src https://*.safing.io 'self'; "+ 149 "style-src 'self' 'unsafe-inline'; "+ 150 "img-src 'self' data: blob:", 151 ) 152 } 153 154 // Check Cross-Origin Requests. 155 origin := r.Header.Get("Origin") 156 isPreflighCheck := false 157 if origin != "" { 158 159 // Parse origin URL. 160 originURL, err := url.Parse(origin) 161 if err != nil { 162 tracer.Warningf("api: denied request from %s: failed to parse origin header: %s", r.RemoteAddr, err) 163 http.Error(lrw, "Invalid Origin.", http.StatusForbidden) 164 return nil 165 } 166 167 // Check if the Origin matches the Host. 168 switch { 169 case originURL.Host == r.Host: 170 // Origin (with port) matches Host. 171 case originURL.Hostname() == r.Host: 172 // Origin (without port) matches Host. 173 case originURL.Scheme == "chrome-extension": 174 // Allow access for the browser extension 175 // TODO(ppacher): 176 // This currently allows access from any browser extension. 177 // Can we reduce that to only our browser extension? 178 // Also, what do we need to support Firefox? 179 case devMode() && 180 utils.StringInSlice(allowedDevCORSOrigins, originURL.Hostname()): 181 // We are in dev mode and the request is coming from the allowed 182 // development origins. 183 default: 184 // Origin and Host do NOT match! 185 tracer.Warningf("api: denied request from %s: Origin (`%s`) and Host (`%s`) do not match", r.RemoteAddr, origin, r.Host) 186 http.Error(lrw, "Cross-Origin Request Denied.", http.StatusForbidden) 187 return nil 188 189 // If the Host header has a port, and the Origin does not, requests will 190 // also end up here, as we cannot properly check for equality. 191 } 192 193 // Add Cross-Site Headers now as we need them in any case now. 194 w.Header().Set("Access-Control-Allow-Origin", origin) 195 w.Header().Set("Access-Control-Allow-Methods", "*") 196 w.Header().Set("Access-Control-Allow-Headers", "*") 197 w.Header().Set("Access-Control-Allow-Credentials", "true") 198 w.Header().Set("Access-Control-Expose-Headers", "*") 199 w.Header().Set("Access-Control-Max-Age", "60") 200 w.Header().Add("Vary", "Origin") 201 202 // if there's a Access-Control-Request-Method header this is a Preflight check. 203 // In that case, we will just check if the preflighMethod is allowed and then return 204 // success here 205 if preflighMethod := r.Header.Get("Access-Control-Request-Method"); r.Method == http.MethodOptions && preflighMethod != "" { 206 isPreflighCheck = true 207 } 208 } 209 210 // Clean URL. 211 cleanedRequestPath := cleanRequestPath(r.URL.Path) 212 213 // If the cleaned URL differs from the original one, redirect to there. 214 if r.URL.Path != cleanedRequestPath { 215 redirURL := *r.URL 216 redirURL.Path = cleanedRequestPath 217 http.Redirect(lrw, r, redirURL.String(), http.StatusMovedPermanently) 218 return nil 219 } 220 221 // Get handler for request. 222 // Gorilla does not support handling this on our own very well. 223 // See github.com/gorilla/mux.ServeHTTP for reference. 224 var match mux.RouteMatch 225 var handler http.Handler 226 if mh.mux.Match(r, &match) { 227 handler = match.Handler 228 apiRequest.Route = match.Route 229 apiRequest.URLVars = match.Vars 230 } 231 switch { 232 case match.MatchErr == nil: 233 // All good. 234 case errors.Is(match.MatchErr, mux.ErrMethodMismatch): 235 http.Error(lrw, "Method not allowed.", http.StatusMethodNotAllowed) 236 return nil 237 default: 238 tracer.Debug("api: no handler registered for this path") 239 http.Error(lrw, "Not found.", http.StatusNotFound) 240 return nil 241 } 242 243 // Be sure that URLVars always is a map. 244 if apiRequest.URLVars == nil { 245 apiRequest.URLVars = make(map[string]string) 246 } 247 248 // Check method. 249 _, readMethod, ok := getEffectiveMethod(r) 250 if !ok { 251 http.Error(lrw, "Method not allowed.", http.StatusMethodNotAllowed) 252 return nil 253 } 254 255 // At this point we know the method is allowed and there's a handler for the request. 256 // If this is just a CORS-Preflight, we'll accept the request with StatusOK now. 257 // There's no point in trying to authenticate the request because the Browser will 258 // not send authentication along a preflight check. 259 if isPreflighCheck && handler != nil { 260 lrw.WriteHeader(http.StatusOK) 261 return nil 262 } 263 264 // Check authentication. 265 apiRequest.AuthToken = authenticateRequest(lrw, r, handler, readMethod) 266 if apiRequest.AuthToken == nil { 267 // Authenticator already replied. 268 return nil 269 } 270 271 // Wait for the owning module to be ready. 272 if moduleHandler, ok := handler.(ModuleHandler); ok { 273 if !moduleIsReady(moduleHandler.BelongsTo()) { 274 http.Error(lrw, "The API endpoint is not ready yet. Reload (F5) to try again.", http.StatusServiceUnavailable) 275 return nil 276 } 277 } 278 279 // Check if we have a handler. 280 if handler == nil { 281 http.Error(lrw, "Not found.", http.StatusNotFound) 282 return nil 283 } 284 285 // Format panics in handler. 286 defer func() { 287 if panicValue := recover(); panicValue != nil { 288 // Report failure via module system. 289 me := module.NewPanicError("api request", "custom", panicValue) 290 me.Report() 291 // Respond with a server error. 292 if devMode() { 293 http.Error( 294 lrw, 295 fmt.Sprintf( 296 "Internal Server Error: %s\n\n%s", 297 panicValue, 298 debug.Stack(), 299 ), 300 http.StatusInternalServerError, 301 ) 302 } else { 303 http.Error(lrw, "Internal Server Error.", http.StatusInternalServerError) 304 } 305 } 306 }() 307 308 // Handle with registered handler. 309 handler.ServeHTTP(lrw, r) 310 311 return nil 312 } 313 314 // cleanRequestPath cleans and returns a request URL. 315 func cleanRequestPath(requestPath string) string { 316 // If the request URL is empty, return a request for "root". 317 if requestPath == "" || requestPath == "/" { 318 return "/" 319 } 320 // If the request URL does not start with a slash, prepend it. 321 if !strings.HasPrefix(requestPath, "/") { 322 requestPath = "/" + requestPath 323 } 324 325 // Clean path to remove any relative parts. 326 cleanedRequestPath := path.Clean(requestPath) 327 // Because path.Clean removes a trailing slash, we need to add it back here 328 // if the original URL had one. 329 if strings.HasSuffix(requestPath, "/") { 330 cleanedRequestPath += "/" 331 } 332 333 return cleanedRequestPath 334 }