github.com/google/go-safeweb@v0.0.0-20231219055052-64d8cfc90fbb/safehttp/plugins/cors/cors.go (about)

     1  // Copyright 2020 Google LLC
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //	https://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package cors provides a safehttp.Interceptor that handles CORS requests.
    16  package cors
    17  
    18  import (
    19  	"log"
    20  	"net/textproto"
    21  	"strconv"
    22  	"strings"
    23  
    24  	"github.com/google/go-safeweb/safehttp"
    25  )
    26  
    27  const requiredHeader string = "X-Cors"
    28  
    29  var disallowedContentTypes = map[string]bool{
    30  	"application/x-www-form-urlencoded": true,
    31  	"multipart/form-data":               true,
    32  	"text/plain":                        true,
    33  }
    34  
    35  // Interceptor handles CORS requests based on its settings.
    36  //
    37  // For more info about CORS, see: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
    38  //
    39  // # Constraints
    40  //
    41  // The content types "application/x-www-form-urlencoded", "multipart/form-data"
    42  // and "text/plain" are banned and will result in a 415 Unsupported Media Type
    43  // response.
    44  //
    45  // Each CORS request must contain the header "X-Cors: 1".
    46  //
    47  // The HEAD request method is disallowed.
    48  //
    49  // All of this is to prevent XSRF.
    50  type Interceptor struct {
    51  	// AllowedOrigins determines which origins should be allowed in the
    52  	// Access-Control-Allow-Origin header.
    53  	AllowedOrigins map[string]bool
    54  	// ExposedHeaders determines which headers should be set in the
    55  	// Access-Control-Expose-Headers header. This controls which headers are
    56  	//  accessible by JavaScript in the response.
    57  	//
    58  	// If ExposedHeaders is nil, then the header is not set, meaning that nothing
    59  	// is exposed.
    60  	ExposedHeaders []string
    61  	// AllowCredentials determines if Access-Control-Allow-Credentials should be
    62  	// set to true, which would allow cookies to be attached to requests.
    63  	AllowCredentials bool
    64  	// MaxAge sets the Access-Control-Max-Age header, indicating how many seconds
    65  	// the results of a preflight request can be cached.
    66  	//
    67  	// MaxAge=0 results in MaxAge: 5, the default used by Chromium according to
    68  	// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Max-Age
    69  	MaxAge         int
    70  	allowedHeaders map[string]bool
    71  }
    72  
    73  var _ safehttp.Interceptor = &Interceptor{}
    74  
    75  // Default creates a CORS Interceptor with default settings.
    76  // Those defaults are:
    77  //   - No Exposed Headers
    78  //   - No Allowed Headers
    79  //   - AllowCredentials: false
    80  //   - MaxAge: 5 seconds
    81  func Default(allowedOrigins ...string) *Interceptor {
    82  	ao := map[string]bool{}
    83  	for _, o := range allowedOrigins {
    84  		ao[o] = true
    85  	}
    86  	return &Interceptor{
    87  		AllowedOrigins: ao,
    88  		allowedHeaders: map[string]bool{},
    89  	}
    90  }
    91  
    92  // SetAllowedHeaders sets the headers allowed in the Access-Control-Allow-Headers
    93  // header. The headers are first canonicalized using textproto.CanonicalMIMEHeaderKey.
    94  // The wildcard "*" is not allowed.
    95  func (it *Interceptor) SetAllowedHeaders(headers ...string) {
    96  	it.allowedHeaders = map[string]bool{}
    97  	for _, h := range headers {
    98  		if h == "*" {
    99  			continue
   100  		}
   101  		it.allowedHeaders[textproto.CanonicalMIMEHeaderKey(h)] = true
   102  	}
   103  }
   104  
   105  // Before handles the IncomingRequest according to the settings specified in the
   106  // Interceptor and sets the appropriate subset of the following headers:
   107  //
   108  //   - Access-Control-Allow-Credentials
   109  //   - Access-Control-Allow-Headers
   110  //   - Access-Control-Allow-Methods
   111  //   - Access-Control-Allow-Origin
   112  //   - Access-Control-Expose-Headers
   113  //   - Access-Control-Max-Age
   114  //   - Vary
   115  func (it *Interceptor) Before(w safehttp.ResponseWriter, r *safehttp.IncomingRequest, _ safehttp.InterceptorConfig) safehttp.Result {
   116  	origin := r.Header.Get("Origin")
   117  	if origin != "" && !it.AllowedOrigins[origin] {
   118  		if safehttp.IsLocalDev() {
   119  			log.Println("cors plugin blocked a request due to a mismatching Origin header.")
   120  		}
   121  		return w.WriteError(safehttp.StatusForbidden)
   122  	}
   123  	h := w.Header()
   124  	allowOrigin := h.Claim("Access-Control-Allow-Origin")
   125  	if h.IsClaimed("Vary") {
   126  		if safehttp.IsLocalDev() {
   127  			log.Println("cors plugin failed to claim the Vary header")
   128  		}
   129  		return w.WriteError(safehttp.StatusInternalServerError)
   130  	}
   131  
   132  	allowCredentials := h.Claim("Access-Control-Allow-Credentials")
   133  
   134  	var status safehttp.StatusCode
   135  	switch r.Method() {
   136  	case safehttp.MethodOptions:
   137  		status = it.preflight(w, r)
   138  	case safehttp.MethodHead:
   139  		status = safehttp.StatusMethodNotAllowed
   140  	default:
   141  		status = it.request(w, r)
   142  	}
   143  
   144  	if status != 0 && status != safehttp.StatusNoContent {
   145  		if safehttp.IsLocalDev() {
   146  			log.Println("cors plugin blocked a potentially malicious request")
   147  		}
   148  		return w.WriteError(status)
   149  	}
   150  
   151  	if origin != "" {
   152  		allowOrigin([]string{origin})
   153  		appendToVary(w, "Origin")
   154  	}
   155  	if r.Header.Get("Cookie") != "" && it.AllowCredentials {
   156  		// TODO: handle other credentials than cookies:
   157  		// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials
   158  		allowCredentials([]string{"true"})
   159  	}
   160  
   161  	if status == safehttp.StatusNoContent {
   162  		return w.Write(safehttp.NoContentResponse{})
   163  	}
   164  	return safehttp.NotWritten()
   165  }
   166  
   167  // Commit is a no-op, required to satisfy the safehttp.Interceptor interface.
   168  func (it *Interceptor) Commit(w safehttp.ResponseHeadersWriter, r *safehttp.IncomingRequest, resp safehttp.Response, cfg safehttp.InterceptorConfig) {
   169  }
   170  
   171  // Match returns false since there are no supported configurations.
   172  func (*Interceptor) Match(safehttp.InterceptorConfig) bool {
   173  	return false
   174  }
   175  
   176  func appendToVary(w safehttp.ResponseWriter, val string) {
   177  	h := w.Header()
   178  	if curr := h.Get("Vary"); curr != "" {
   179  		h.Set("Vary", curr+", "+val)
   180  	} else {
   181  		h.Set("Vary", val)
   182  	}
   183  }
   184  
   185  // preflight handles requests that have the method OPTIONS.
   186  func (it *Interceptor) preflight(w safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.StatusCode {
   187  	rh := r.Header
   188  	if rh.Get("Origin") == "" {
   189  		return safehttp.StatusForbidden
   190  	}
   191  
   192  	method := rh.Get("Access-Control-Request-Method")
   193  	if method == "" || method == safehttp.MethodHead {
   194  		return safehttp.StatusForbidden
   195  	}
   196  
   197  	headers := rh.Get("Access-Control-Request-Headers")
   198  	if headers != "" {
   199  		for _, h := range strings.Split(headers, ", ") {
   200  			h = textproto.CanonicalMIMEHeaderKey(h)
   201  			if !it.allowedHeaders[h] && h != requiredHeader {
   202  				return safehttp.StatusForbidden
   203  			}
   204  		}
   205  	}
   206  
   207  	wh := w.Header()
   208  	allowMethods := wh.Claim("Access-Control-Allow-Methods")
   209  	allowHeaders := wh.Claim("Access-Control-Allow-Headers")
   210  	maxAge := wh.Claim("Access-Control-Max-Age")
   211  
   212  	allowMethods([]string{method})
   213  	if headers != "" {
   214  		allowHeaders([]string{headers})
   215  	}
   216  	n := it.MaxAge
   217  	if n == 0 {
   218  		n = 5
   219  	}
   220  	maxAge([]string{strconv.Itoa(n)})
   221  	return safehttp.StatusNoContent
   222  }
   223  
   224  // request handles all requests that are not preflight requests.
   225  func (it *Interceptor) request(w safehttp.ResponseWriter, r *safehttp.IncomingRequest) safehttp.StatusCode {
   226  	h := r.Header
   227  	if h.Get(requiredHeader) != "1" {
   228  		return safehttp.StatusPreconditionFailed
   229  	}
   230  
   231  	if ct := h.Get("Content-Type"); ct == "" || disallowedContentTypes[ct] {
   232  		return safehttp.StatusUnsupportedMediaType
   233  	}
   234  
   235  	if exposeHeaders := w.Header().Claim("Access-Control-Expose-Headers"); it.ExposedHeaders != nil {
   236  		exposeHeaders([]string{strings.Join(it.ExposedHeaders, ", ")})
   237  	}
   238  	return 0
   239  }