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 }