github.com/cozy/cozy-stack@v0.0.0-20240603063001-31110fa4cae1/web/middlewares/secure.go (about)

     1  package middlewares
     2  
     3  import (
     4  	"fmt"
     5  	"net/url"
     6  	"strings"
     7  	"time"
     8  
     9  	build "github.com/cozy/cozy-stack/pkg/config"
    10  	"github.com/cozy/cozy-stack/pkg/config/config"
    11  	"github.com/labstack/echo/v4"
    12  	"golang.org/x/net/idna"
    13  )
    14  
    15  type (
    16  	// CSPSource type are the different types of CSP headers sources definitions.
    17  	// Each source type defines a different acess policy.
    18  	CSPSource int
    19  
    20  	// SecureConfig defines the config for Secure middleware.
    21  	SecureConfig struct {
    22  		HSTSMaxAge time.Duration
    23  
    24  		CSPDefaultSrc     []CSPSource
    25  		CSPScriptSrc      []CSPSource
    26  		CSPFrameSrc       []CSPSource
    27  		CSPConnectSrc     []CSPSource
    28  		CSPFontSrc        []CSPSource
    29  		CSPImgSrc         []CSPSource
    30  		CSPManifestSrc    []CSPSource
    31  		CSPMediaSrc       []CSPSource
    32  		CSPObjectSrc      []CSPSource
    33  		CSPStyleSrc       []CSPSource
    34  		CSPWorkerSrc      []CSPSource
    35  		CSPFrameAncestors []CSPSource
    36  		CSPBaseURI        []CSPSource
    37  		CSPFormAction     []CSPSource
    38  
    39  		CSPDefaultSrcAllowList     string
    40  		CSPScriptSrcAllowList      string
    41  		CSPFrameSrcAllowList       string
    42  		CSPConnectSrcAllowList     string
    43  		CSPFontSrcAllowList        string
    44  		CSPImgSrcAllowList         string
    45  		CSPManifestSrcAllowList    string
    46  		CSPMediaSrcAllowList       string
    47  		CSPObjectSrcAllowList      string
    48  		CSPStyleSrcAllowList       string
    49  		CSPWorkerSrcAllowList      string
    50  		CSPFrameAncestorsAllowList string
    51  		CSPBaseURIAllowList        string
    52  		CSPFormActionAllowList     string
    53  
    54  		// context_name -> source -> allow_list
    55  		CSPPerContext map[string]map[string]string
    56  	}
    57  )
    58  
    59  const (
    60  	// CSPSrcSelf is the 'self' option of a CSP source.
    61  	CSPSrcSelf CSPSource = iota
    62  	// CSPSrcNone is the 'none' option. It denies all domains as an eligible
    63  	// source.
    64  	CSPSrcNone
    65  	// CSPSrcData is the 'data:' option of a CSP source.
    66  	CSPSrcData
    67  	// CSPSrcBlob is the 'blob:' option of a CSP source.
    68  	CSPSrcBlob
    69  	// CSPSrcParent adds the parent domain as an eligible CSP source.
    70  	CSPSrcParent
    71  	// CSPSrcWS adds the parent domain eligible for websocket.
    72  	CSPSrcWS
    73  	// CSPSrcSiblings adds all the siblings subdomains as eligibles CSP
    74  	// sources.
    75  	CSPSrcSiblings
    76  	// CSPSrcAny is the '*' option. It allows any domain as an eligible source.
    77  	CSPSrcAny
    78  	// CSPUnsafeInline is the  'unsafe-inline' option. It allows to have inline
    79  	// styles or scripts to be injected in the page.
    80  	CSPUnsafeInline
    81  	// CSPAllowList inserts a allowList of domains.
    82  	CSPAllowList
    83  )
    84  
    85  // Secure returns a Middlefunc that can be used to define all the necessary
    86  // secure headers. It is configurable with a SecureConfig object.
    87  func Secure(conf *SecureConfig) echo.MiddlewareFunc {
    88  	var hstsHeader string
    89  	if conf.HSTSMaxAge > 0 {
    90  		hstsHeader = fmt.Sprintf("max-age=%.f; includeSubDomains",
    91  			conf.HSTSMaxAge.Seconds())
    92  	}
    93  
    94  	conf.CSPDefaultSrc, conf.CSPDefaultSrcAllowList =
    95  		validCSPList(conf.CSPDefaultSrc, conf.CSPDefaultSrc, conf.CSPDefaultSrcAllowList)
    96  	conf.CSPScriptSrc, conf.CSPScriptSrcAllowList =
    97  		validCSPList(conf.CSPScriptSrc, conf.CSPDefaultSrc, conf.CSPScriptSrcAllowList)
    98  	conf.CSPFrameSrc, conf.CSPFrameSrcAllowList =
    99  		validCSPList(conf.CSPFrameSrc, conf.CSPDefaultSrc, conf.CSPFrameSrcAllowList)
   100  	conf.CSPConnectSrc, conf.CSPConnectSrcAllowList =
   101  		validCSPList(conf.CSPConnectSrc, conf.CSPDefaultSrc, conf.CSPConnectSrcAllowList)
   102  	conf.CSPFontSrc, conf.CSPFontSrcAllowList =
   103  		validCSPList(conf.CSPFontSrc, conf.CSPDefaultSrc, conf.CSPFontSrcAllowList)
   104  	conf.CSPImgSrc, conf.CSPImgSrcAllowList =
   105  		validCSPList(conf.CSPImgSrc, conf.CSPDefaultSrc, conf.CSPImgSrcAllowList)
   106  	conf.CSPManifestSrc, conf.CSPManifestSrcAllowList =
   107  		validCSPList(conf.CSPManifestSrc, conf.CSPDefaultSrc, conf.CSPManifestSrcAllowList)
   108  	conf.CSPMediaSrc, conf.CSPMediaSrcAllowList =
   109  		validCSPList(conf.CSPMediaSrc, conf.CSPDefaultSrc, conf.CSPMediaSrcAllowList)
   110  	conf.CSPObjectSrc, conf.CSPObjectSrcAllowList =
   111  		validCSPList(conf.CSPObjectSrc, nil, conf.CSPObjectSrcAllowList)
   112  	conf.CSPStyleSrc, conf.CSPStyleSrcAllowList =
   113  		validCSPList(conf.CSPStyleSrc, conf.CSPDefaultSrc, conf.CSPStyleSrcAllowList)
   114  	conf.CSPWorkerSrc, conf.CSPWorkerSrcAllowList =
   115  		validCSPList(conf.CSPWorkerSrc, conf.CSPDefaultSrc, conf.CSPWorkerSrcAllowList)
   116  	conf.CSPFormAction, conf.CSPFormActionAllowList =
   117  		validCSPList(conf.CSPFormAction, nil, conf.CSPFormActionAllowList)
   118  
   119  	return func(next echo.HandlerFunc) echo.HandlerFunc {
   120  		return func(c echo.Context) error {
   121  			isSecure := !build.IsDevRelease()
   122  			h := c.Response().Header()
   123  			if isSecure && hstsHeader != "" {
   124  				h.Set(echo.HeaderStrictTransportSecurity, hstsHeader)
   125  			}
   126  			var cspHeader string
   127  			host, err := idna.ToUnicode(c.Request().Host)
   128  			if err != nil {
   129  				return err
   130  			}
   131  			parent, _, siblings := config.SplitCozyHost(host)
   132  			parent, err = idna.ToASCII(parent)
   133  			if err != nil {
   134  				return err
   135  			}
   136  			var contextName string
   137  			if conf.CSPPerContext != nil {
   138  				contextName = GetInstance(c).ContextName
   139  			}
   140  			b := cspBuilder{
   141  				parent:      parent,
   142  				siblings:    siblings,
   143  				isSecure:    isSecure,
   144  				contextName: contextName,
   145  				perContext:  conf.CSPPerContext,
   146  			}
   147  			cspHeader += b.makeCSPHeader("default-src", conf.CSPDefaultSrcAllowList, conf.CSPDefaultSrc)
   148  			cspHeader += b.makeCSPHeader("script-src", conf.CSPScriptSrcAllowList, conf.CSPScriptSrc)
   149  			cspHeader += b.makeCSPHeader("frame-src", conf.CSPFrameSrcAllowList, conf.CSPFrameSrc)
   150  			cspHeader += b.makeCSPHeader("connect-src", conf.CSPConnectSrcAllowList, conf.CSPConnectSrc)
   151  			cspHeader += b.makeCSPHeader("font-src", conf.CSPFontSrcAllowList, conf.CSPFontSrc)
   152  			cspHeader += b.makeCSPHeader("img-src", conf.CSPImgSrcAllowList, conf.CSPImgSrc)
   153  			cspHeader += b.makeCSPHeader("manifest-src", conf.CSPManifestSrcAllowList, conf.CSPManifestSrc)
   154  			cspHeader += b.makeCSPHeader("media-src", conf.CSPMediaSrcAllowList, conf.CSPMediaSrc)
   155  			cspHeader += b.makeCSPHeader("object-src", conf.CSPObjectSrcAllowList, conf.CSPObjectSrc)
   156  			cspHeader += b.makeCSPHeader("style-src", conf.CSPStyleSrcAllowList, conf.CSPStyleSrc)
   157  			cspHeader += b.makeCSPHeader("worker-src", conf.CSPWorkerSrcAllowList, conf.CSPWorkerSrc)
   158  			cspHeader += b.makeCSPHeader("frame-ancestors", conf.CSPFrameAncestorsAllowList, conf.CSPFrameAncestors)
   159  			cspHeader += b.makeCSPHeader("base-uri", conf.CSPBaseURIAllowList, conf.CSPBaseURI)
   160  			cspHeader += b.makeCSPHeader("form-action", conf.CSPFormActionAllowList, conf.CSPFormAction)
   161  			if cspHeader != "" {
   162  				h.Set(echo.HeaderContentSecurityPolicy, cspHeader)
   163  			}
   164  			h.Set(echo.HeaderXContentTypeOptions, "nosniff")
   165  			return next(c)
   166  		}
   167  	}
   168  }
   169  
   170  func validCSPList(sources, defaults []CSPSource, allowList string) ([]CSPSource, string) {
   171  	allowListFields := strings.Fields(allowList)
   172  	allowListFilter := allowListFields[:0]
   173  	for _, s := range allowListFields {
   174  		u, err := url.Parse(s)
   175  		if err != nil {
   176  			continue
   177  		}
   178  		if !build.IsDevRelease() {
   179  			if u.Scheme == "ws" {
   180  				u.Scheme = "wss"
   181  			} else if u.Scheme == "http" {
   182  				u.Scheme = "https"
   183  			}
   184  		}
   185  		if u.Path == "" {
   186  			u.Path = "/"
   187  		}
   188  		// For custom links like cozydrive:, we want to allow the whole protocol
   189  		if !strings.HasPrefix(u.Scheme, "cozy") {
   190  			s = u.String()
   191  		}
   192  		allowListFilter = append(allowListFilter, s)
   193  	}
   194  
   195  	if len(allowListFilter) > 0 {
   196  		allowList = strings.Join(allowListFilter, " ")
   197  		sources = append(sources, CSPAllowList)
   198  	} else {
   199  		allowList = ""
   200  	}
   201  
   202  	if len(sources) == 0 && allowList == "" {
   203  		return nil, ""
   204  	}
   205  
   206  	sources = append(sources, defaults...)
   207  	sourcesUnique := sources[:0]
   208  	for _, source := range sources {
   209  		var found bool
   210  		for _, s := range sourcesUnique {
   211  			if s == source {
   212  				found = true
   213  				break
   214  			}
   215  		}
   216  		if !found {
   217  			sourcesUnique = append(sourcesUnique, source)
   218  		}
   219  	}
   220  
   221  	return sourcesUnique, allowList
   222  }
   223  
   224  type cspBuilder struct {
   225  	parent      string
   226  	siblings    string
   227  	contextName string
   228  	perContext  map[string]map[string]string
   229  	isSecure    bool
   230  }
   231  
   232  func (b cspBuilder) makeCSPHeader(header, cspAllowList string, sources []CSPSource) string {
   233  	headers := make([]string, len(sources), len(sources)+1)
   234  	for i, src := range sources {
   235  		switch src {
   236  		case CSPSrcSelf:
   237  			headers[i] = "'self'"
   238  		case CSPSrcNone:
   239  			headers[i] = "'none'"
   240  		case CSPSrcData:
   241  			headers[i] = "data:"
   242  		case CSPSrcBlob:
   243  			headers[i] = "blob:"
   244  		case CSPSrcParent:
   245  			if b.isSecure {
   246  				headers[i] = "https://" + b.parent
   247  			} else {
   248  				headers[i] = "http://" + b.parent
   249  			}
   250  		case CSPSrcWS:
   251  			if b.isSecure {
   252  				headers[i] = "wss://" + b.parent
   253  			} else {
   254  				headers[i] = "ws://" + b.parent
   255  			}
   256  		case CSPSrcSiblings:
   257  			if b.isSecure {
   258  				headers[i] = "https://" + b.siblings
   259  			} else {
   260  				headers[i] = "http://" + b.siblings
   261  			}
   262  		case CSPSrcAny:
   263  			headers[i] = "*"
   264  		case CSPUnsafeInline:
   265  			headers[i] = "'unsafe-inline'"
   266  		case CSPAllowList:
   267  			headers[i] = cspAllowList
   268  		}
   269  	}
   270  	if b.contextName != "" {
   271  		if context, ok := b.perContext[b.contextName]; ok {
   272  			var src string
   273  			switch header {
   274  			case "default-src":
   275  				src = "default"
   276  			case "img-src":
   277  				src = "img"
   278  			case "script-src":
   279  				src = "script"
   280  			case "connect-src":
   281  				src = "connect"
   282  			case "style-src":
   283  				src = "style"
   284  			case "font-src":
   285  				src = "font"
   286  			case "media-src":
   287  				src = "font"
   288  			case "frame-src":
   289  				src = "frame"
   290  			}
   291  			if list, ok := context[src]; ok && list != "" {
   292  				headers = append(headers, list)
   293  			}
   294  		}
   295  	}
   296  	if len(headers) == 0 {
   297  		return ""
   298  	}
   299  	return header + " " + strings.Join(headers, " ") + ";"
   300  }
   301  
   302  // AppendCSPRule allows to patch inline the CSP headers to add a new rule.
   303  func AppendCSPRule(c echo.Context, ruleType string, appendedValues ...string) {
   304  	currentRules := c.Response().Header().Get(echo.HeaderContentSecurityPolicy)
   305  	newRules := appendCSPRule(currentRules, ruleType, appendedValues...)
   306  	c.Response().Header().Set(echo.HeaderContentSecurityPolicy, newRules)
   307  }
   308  
   309  func appendCSPRule(currentRules, ruleType string, appendedValues ...string) (newRules string) {
   310  	ruleIndex := strings.Index(currentRules, ruleType)
   311  	if ruleIndex >= 0 {
   312  		ruleTerminationIndex := strings.Index(currentRules[ruleIndex:], ";")
   313  		if ruleTerminationIndex <= 0 {
   314  			return
   315  		}
   316  		ruleFields := strings.Fields(currentRules[ruleIndex : ruleIndex+ruleTerminationIndex])
   317  		if len(ruleFields) == 2 && ruleFields[1] == "'none'" {
   318  			ruleFields = ruleFields[:1]
   319  		}
   320  		ruleFields = append(ruleFields, appendedValues...)
   321  		newRules = currentRules[:ruleIndex] + strings.Join(ruleFields, " ") +
   322  			currentRules[ruleIndex+ruleTerminationIndex:]
   323  	} else {
   324  		newRules = currentRules + ruleType + " " + strings.Join(appendedValues, " ") + ";"
   325  	}
   326  	return
   327  }