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  }