[mirror] Scalable static site server for Git forges (like GitHub Pages)
at main 5.2 kB view raw
1package git_pages 2 3import ( 4 "errors" 5 "fmt" 6 "net/http" 7 "net/textproto" 8 "net/url" 9 "slices" 10 "strings" 11 12 "codeberg.org/git-pages/go-headers" 13 "google.golang.org/protobuf/proto" 14) 15 16var ErrHeaderNotAllowed = errors.New("custom header not allowed") 17 18const HeadersFileName string = "_headers" 19 20// Lifted from https://docs.netlify.com/manage/routing/headers/, except for `Set-Cookie` 21// the rationale for which does not apply in our environment. 22var unsafeHeaders []string = []string{ 23 "Accept-Ranges", 24 "Age", 25 "Allow", 26 "Alt-Svc", 27 "Connection", 28 "Content-Encoding", 29 "Content-Length", 30 "Content-Range", 31 "Date", 32 "Location", // use `_redirects` instead 33 "Server", 34 "Trailer", 35 "Transfer-Encoding", 36 "Upgrade", 37} 38 39func IsAllowedCustomHeader(header string) bool { 40 header = textproto.CanonicalMIMEHeaderKey(header) 41 switch { 42 case slices.Contains(unsafeHeaders, header): 43 return false // explicitly unsafe 44 case slices.Contains(config.Limits.AllowedCustomHeaders, header): 45 return true // explicitly allowlisted 46 default: 47 return false // deny by default; we don't know what the future holds 48 } 49} 50 51func validateHeaderRule(rule headers.Rule) error { 52 url, err := url.Parse(rule.Path) 53 if err != nil { 54 return fmt.Errorf("malformed path") 55 } 56 if url.Scheme != "" { 57 return fmt.Errorf("path must not contain a scheme") 58 } 59 if !strings.HasPrefix(url.Path, "/") { 60 return fmt.Errorf("path must start with a /") 61 } 62 // Per Netlify documentation: 63 // > Wildcards (*) can be used at any place inside of a path segment to match any character. 64 // However, we currently do not implement this, for simplicity. Instead we implement a strict 65 // subset of the syntactically allowed wildcards. 66 if strings.Contains(url.Path, "*") && !strings.HasSuffix(url.Path, "/*") { 67 return fmt.Errorf("splat * must be its own final segment of the path") 68 } 69 // Note that this isn't our only line of defense against forbidden headers; 70 // the purpose of this check is just to inform the uploader of a problem. 71 // If the validation rules change after a manifest is uploaded, we could 72 // still end up attempting to serve a forbidden header. 73 for header := range rule.Headers { 74 if slices.Contains(unsafeHeaders, header) { 75 return fmt.Errorf("rule sets header %q (fundamentally unsafe)", header) 76 } 77 if !slices.Contains(config.Limits.AllowedCustomHeaders, header) { 78 return fmt.Errorf("rule sets header %q (not allowlisted)", header) 79 } 80 if !IsAllowedCustomHeader(header) { // make sure we don't desync 81 panic(errors.New("header check inconsistency")) 82 } 83 } 84 return nil 85} 86 87// Parses redirects file and injects rules into the manifest. 88func ProcessHeadersFile(manifest *Manifest) error { 89 headersEntry := manifest.Contents[HeadersFileName] 90 delete(manifest.Contents, HeadersFileName) 91 if headersEntry == nil { 92 return nil 93 } else if headersEntry.GetType() != Type_InlineFile { 94 return AddProblem(manifest, HeadersFileName, 95 "not a regular file") 96 } 97 98 rules, err := headers.ParseString(string(headersEntry.GetData())) 99 if err != nil { 100 return AddProblem(manifest, HeadersFileName, 101 "syntax error: %s", err) 102 } 103 104 for index, rule := range rules { 105 if err := validateHeaderRule(rule); err != nil { 106 AddProblem(manifest, HeadersFileName, 107 "rule #%d %q: %s", index+1, rule.Path, err) 108 continue 109 } 110 headerMap := []*Header{} 111 for header, values := range rule.Headers { 112 headerMap = append(headerMap, &Header{ 113 Name: proto.String(header), 114 Values: values, 115 }) 116 } 117 manifest.Headers = append(manifest.Headers, &HeaderRule{ 118 Path: proto.String(rule.Path), 119 HeaderMap: headerMap, 120 }) 121 } 122 return nil 123} 124 125func CollectHeadersFile(manifest *Manifest) string { 126 var headersRules []headers.Rule 127 for _, manifestRule := range manifest.GetHeaders() { 128 headersRule := headers.Rule{ 129 Path: manifestRule.GetPath(), 130 Headers: http.Header{}, 131 } 132 for _, manifestHeader := range manifestRule.GetHeaderMap() { 133 headersRule.Headers[manifestHeader.GetName()] = manifestHeader.GetValues() 134 } 135 headersRules = append(headersRules, headersRule) 136 } 137 return headers.Must(headers.UnparseString(headersRules)) 138} 139 140func ApplyHeaderRules(manifest *Manifest, url *url.URL) (headers http.Header, err error) { 141 headers = http.Header{} 142 fromSegments := pathSegments(url.Path) 143next: 144 for _, rule := range manifest.Headers { 145 // check if the rule matches url 146 ruleURL, _ := url.Parse(*rule.Path) // pre-validated in `validateHeaderRule` 147 ruleSegments := pathSegments(ruleURL.Path) 148 if ruleSegments[len(ruleSegments)-1] != "*" { 149 if len(ruleSegments) < len(fromSegments) { 150 continue 151 } 152 } 153 for index, ruleFromSegment := range ruleSegments { 154 if ruleFromSegment == "*" { 155 break 156 } 157 if len(fromSegments) <= index { 158 continue next 159 } 160 if fromSegments[index] != ruleFromSegment { 161 continue next 162 } 163 } 164 // the rule has matched url, validate headers against up-to-date policy 165 for _, header := range rule.HeaderMap { 166 name := header.GetName() 167 if !IsAllowedCustomHeader(name) { 168 return nil, fmt.Errorf("%w: %s", ErrHeaderNotAllowed, name) 169 } 170 for _, value := range header.GetValues() { 171 headers.Add(name, value) 172 } 173 } 174 break 175 } 176 return 177}