[mirror] Scalable static site server for Git forges (like GitHub Pages)
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}