mirror of
https://codeberg.org/git-pages/git-pages.git
synced 2026-05-17 04:31:38 +00:00
263 lines
7.7 KiB
Go
263 lines
7.7 KiB
Go
package git_pages
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/textproto"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
|
|
"codeberg.org/git-pages/go-headers"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
var ErrHeaderNotAllowed = errors.New("custom header not allowed")
|
|
var ErrBasicAuthNotAllowed = errors.New("basic authorization not allowed")
|
|
|
|
const HeadersFileName string = "_headers"
|
|
|
|
// Lifted from https://docs.netlify.com/manage/routing/headers/, except for `Set-Cookie`
|
|
// the rationale for which does not apply in our environment.
|
|
var unsafeHeaders []string = []string{
|
|
"Accept-Ranges",
|
|
"Age",
|
|
"Allow",
|
|
"Alt-Svc",
|
|
"Connection",
|
|
"Content-Encoding",
|
|
"Content-Length",
|
|
"Content-Range",
|
|
"Date",
|
|
"Location", // use `_redirects` instead
|
|
"Server",
|
|
"Trailer",
|
|
"Transfer-Encoding",
|
|
"Upgrade",
|
|
}
|
|
|
|
func IsAllowedCustomHeader(header string) bool {
|
|
header = textproto.CanonicalMIMEHeaderKey(header)
|
|
switch {
|
|
case slices.Contains(unsafeHeaders, header):
|
|
return false // explicitly unsafe
|
|
case slices.Contains(config.Limits.AllowedCustomHeaders, header):
|
|
return true // explicitly allowlisted
|
|
default:
|
|
return false // deny by default; we don't know what the future holds
|
|
}
|
|
}
|
|
|
|
func validateHeaderRule(rule headers.Rule) error {
|
|
url, err := url.Parse(rule.Path)
|
|
if err != nil {
|
|
return fmt.Errorf("malformed path")
|
|
}
|
|
if url.Scheme != "" {
|
|
return fmt.Errorf("path must not contain a scheme")
|
|
}
|
|
if !strings.HasPrefix(url.Path, "/") {
|
|
return fmt.Errorf("path must start with a /")
|
|
}
|
|
// Per Netlify documentation:
|
|
// > Wildcards (*) can be used at any place inside of a path segment to match any character.
|
|
// However, we currently do not implement this, for simplicity. Instead we implement a strict
|
|
// subset of the syntactically allowed wildcards.
|
|
if strings.Contains(url.Path, "*") && !strings.HasSuffix(url.Path, "/*") {
|
|
return fmt.Errorf("splat * must be its own final segment of the path")
|
|
}
|
|
// Note that this isn't our only line of defense against forbidden headers;
|
|
// the purpose of this check is just to inform the uploader of a problem.
|
|
// If the validation rules change after a manifest is uploaded, we could
|
|
// still end up attempting to serve a forbidden header.
|
|
for header := range rule.Headers {
|
|
if slices.Contains(unsafeHeaders, header) {
|
|
return fmt.Errorf("rule sets header %q (fundamentally unsafe)", header)
|
|
}
|
|
switch header {
|
|
case "Basic-Auth":
|
|
if !config.Limits.AllowBasicAuth {
|
|
return fmt.Errorf("rule sets header %q (forbidden by policy)", header)
|
|
}
|
|
default:
|
|
if !slices.Contains(config.Limits.AllowedCustomHeaders, header) {
|
|
return fmt.Errorf("rule sets header %q (not allowlisted)", header)
|
|
}
|
|
if !IsAllowedCustomHeader(header) { // make sure we don't desync
|
|
panic(errors.New("header check inconsistency"))
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Parses redirects file and injects rules into the manifest.
|
|
func ProcessHeadersFile(ctx context.Context, manifest *Manifest) error {
|
|
headersEntry := manifest.Contents[HeadersFileName]
|
|
delete(manifest.Contents, HeadersFileName)
|
|
if headersEntry == nil {
|
|
return nil
|
|
}
|
|
|
|
data, err := GetEntryContents(ctx, headersEntry)
|
|
if errors.Is(err, ErrNotRegularFile) {
|
|
return AddProblem(manifest, HeadersFileName,
|
|
"not a regular file")
|
|
} else if err != nil {
|
|
return err
|
|
}
|
|
|
|
rules, err := headers.ParseString(string(data))
|
|
if err != nil {
|
|
return AddProblem(manifest, HeadersFileName,
|
|
"syntax error: %s", err)
|
|
}
|
|
|
|
for index, rule := range rules {
|
|
if err := validateHeaderRule(rule); err != nil {
|
|
AddProblem(manifest, HeadersFileName,
|
|
"rule #%d %q: %s", index+1, rule.Path, err)
|
|
continue
|
|
}
|
|
headerMap := []*Header{}
|
|
credentials := []*BasicCredential{}
|
|
hasBasicAuth := false
|
|
for header, values := range rule.Headers {
|
|
switch header {
|
|
case "Basic-Auth":
|
|
hasBasicAuth = true
|
|
for _, value := range values {
|
|
for _, usernamePassword := range strings.Split(value, " ") {
|
|
if usernamePassword == "" {
|
|
continue
|
|
}
|
|
if username, password, found := strings.Cut(usernamePassword, ":"); !found {
|
|
AddProblem(manifest, HeadersFileName,
|
|
"rule #%d %q: malformed Basic-Auth credential", index+1, rule.Path)
|
|
continue
|
|
} else {
|
|
credentials = append(credentials, &BasicCredential{
|
|
Username: proto.String(username),
|
|
Password: proto.String(password),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
headerMap = append(headerMap, &Header{
|
|
Name: proto.String(header),
|
|
Values: values,
|
|
})
|
|
}
|
|
}
|
|
// Note that we may add an empty `headerMap` here even if only credentials are defined.
|
|
// This is intentional: in `_headers` files processing terminates at the first matching
|
|
// clause, and Netlify mixes Basic-Auth with all the other headers.
|
|
manifest.Headers = append(manifest.Headers, &HeaderRule{
|
|
Path: proto.String(rule.Path),
|
|
HeaderMap: headerMap,
|
|
})
|
|
// We're using `hasBasicAuth` instead of `len(credentials) > 0` so that if a `_headers`
|
|
// file defines only malformed credentials, we still add a rule (that in effect always
|
|
// denies access).
|
|
if hasBasicAuth {
|
|
manifest.BasicAuth = append(manifest.BasicAuth, &BasicAuthRule{
|
|
Path: proto.String(rule.Path),
|
|
Credentials: credentials,
|
|
})
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func CollectHeadersFile(manifest *Manifest) string {
|
|
var headersRules []headers.Rule
|
|
for _, manifestRule := range manifest.GetHeaders() {
|
|
headersRule := headers.Rule{
|
|
Path: manifestRule.GetPath(),
|
|
Headers: http.Header{},
|
|
}
|
|
for _, manifestHeader := range manifestRule.GetHeaderMap() {
|
|
headersRule.Headers[manifestHeader.GetName()] = manifestHeader.GetValues()
|
|
}
|
|
headersRules = append(headersRules, headersRule)
|
|
}
|
|
return headers.Must(headers.UnparseString(headersRules))
|
|
}
|
|
|
|
func matchPathRules[
|
|
Rule interface{ GetPath() string },
|
|
](rules []Rule, url *url.URL) (matched Rule) {
|
|
fromSegments := pathSegments(url.Path)
|
|
next:
|
|
for _, rule := range rules {
|
|
// check if the rule matches url
|
|
ruleURL, _ := url.Parse(rule.GetPath()) // pre-validated in `validateHeaderRule`
|
|
ruleSegments := pathSegments(ruleURL.Path)
|
|
if ruleSegments[len(ruleSegments)-1] != "*" {
|
|
if len(ruleSegments) < len(fromSegments) {
|
|
continue
|
|
}
|
|
}
|
|
for index, ruleFromSegment := range ruleSegments {
|
|
if ruleFromSegment == "*" {
|
|
break
|
|
}
|
|
if len(fromSegments) <= index {
|
|
continue next
|
|
}
|
|
if fromSegments[index] != ruleFromSegment {
|
|
continue next
|
|
}
|
|
}
|
|
matched = rule
|
|
break
|
|
}
|
|
return
|
|
}
|
|
|
|
func ApplyHeaderRules(manifest *Manifest, url *url.URL) (
|
|
headers http.Header, err error,
|
|
) {
|
|
headers = http.Header{}
|
|
if rule := matchPathRules(manifest.Headers, url); rule != nil {
|
|
// the rule has matched url, validate headers against up-to-date policy
|
|
for _, header := range rule.GetHeaderMap() {
|
|
name := header.GetName()
|
|
if !IsAllowedCustomHeader(name) {
|
|
return nil, fmt.Errorf("%w: %s", ErrHeaderNotAllowed, name)
|
|
}
|
|
for _, value := range header.GetValues() {
|
|
headers.Add(name, value)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
func ApplyBasicAuthRules(manifest *Manifest, url *url.URL, r *http.Request) (bool, error) {
|
|
if rule := matchPathRules(manifest.BasicAuth, url); rule == nil {
|
|
// no matches, authorized by default
|
|
return true, nil
|
|
} else {
|
|
// the rule has matched url, check that basic auth is allowed per up-to-date policy
|
|
if !config.Limits.AllowBasicAuth {
|
|
// basic auth configured in the past but not allowed any more
|
|
return false, ErrBasicAuthNotAllowed
|
|
}
|
|
if username, password, ok := r.BasicAuth(); ok {
|
|
// request has credentials, check them
|
|
for _, credential := range rule.GetCredentials() {
|
|
if credential.GetUsername() == username && credential.GetPassword() == password {
|
|
// authorized!
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|
|
// request has no credentials, unauthorized
|
|
return false, nil
|
|
}
|
|
}
|