feaT: Allow specifying custom headers in the rio.json

This commit is contained in:
2024-02-03 15:18:37 +01:00
parent 8630855374
commit cf85380ddb
8 changed files with 227 additions and 182 deletions

View File

@@ -1,119 +0,0 @@
package repo
import (
"fmt"
"io"
"net/http"
"time"
"code.gitea.io/sdk/gitea"
"git.polynom.me/rio/internal/dns"
)
// Returns true if the repository at <username>/<repository> exists, false if it
// does not.
type GetRepositoryMethod func(username, repositoryName string) (Repository, error)
// Returns <file content>, nil if the file exists at path <path> (relative to the repository) in
// <username>/<repository>@<branch> exists. If not, returns "", error.
type GetFileMethod func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error)
// Returns the result of a CNAME lookup for @domain.
type LookupCNAMEMethod func(domain string) (string, error)
// Return the repository the domain should point to by looking up the TXT record
// "_rio-pages.<domain>".
type LookupRepoTXTMethod func(domain string) (string, error)
// Check of the repository <username>/<repositoryName> contains the specified
// branch.
type HasBranchMethod func(username, repositoryName, branchName string) bool
// Check if the specified username exists.
type HasUserMethod func(username string) bool
type Repository struct {
Name string
}
type GiteaClient struct {
getRepository GetRepositoryMethod
hasBranch HasBranchMethod
hasUser HasUserMethod
GetFile GetFileMethod
lookupCNAME LookupCNAMEMethod
lookupRepoTXT LookupRepoTXTMethod
}
func NewGiteaClient(giteaUrl string, giteaClient *gitea.Client) GiteaClient {
return GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) {
repo, _, err := giteaClient.GetRepo(username, repositoryName)
if err != nil {
return Repository{}, err
}
return Repository{
Name: repo.Name,
}, nil
},
hasBranch: func(username, repositoryName, branchName string) bool {
res, _, err := giteaClient.ListRepoBranches(username, repositoryName, gitea.ListRepoBranchesOptions{})
if err != nil {
return false
}
for _, branch := range res {
if branch.Name == branchName {
return true
}
}
return false
},
hasUser: func(username string) bool {
_, _, err := giteaClient.GetUserInfo(username)
return err == nil
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
// We have to do the raw request manually because the Gitea SDK does not allow
// passing the If-Modfied-Since header.
apiUrl := fmt.Sprintf(
"%s/api/v1/repos/%s/%s/raw/%s?ref=%s",
giteaUrl,
username,
repositoryName,
path,
branch,
)
client := &http.Client{}
req, err := http.NewRequest("GET", apiUrl, nil)
if since != nil {
sinceFormat := since.Format(time.RFC1123)
req.Header.Add("If-Modified-Since", sinceFormat)
}
resp, err := client.Do(req)
if err != nil {
return []byte{}, true, err
}
defer resp.Body.Close()
content, err := io.ReadAll(resp.Body)
if err != nil {
return []byte{}, true, err
} else if resp.StatusCode == 302 {
return []byte{}, false, nil
} else if resp.StatusCode == 404 {
return []byte{}, false, fmt.Errorf("File does not exist")
} else {
return content, true, err
}
},
lookupCNAME: func(domain string) (string, error) {
return dns.LookupCNAME(domain)
},
lookupRepoTXT: func(domain string) (string, error) {
return dns.LookupRepoTXT(domain)
},
}
}

View File

@@ -3,54 +3,56 @@ package repo
//go:generate mockgen -destination mock_repo_test.go -package repo code.gitea.io/sdk/gitea Client
import (
"encoding/json"
"errors"
"slices"
"strings"
"time"
"git.polynom.me/rio/internal/constants"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/gitea"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
)
var (
ForbiddenHeaders = []string{
"content-length",
"content-type",
"date",
"location",
"strict-transport-security",
"set-cookie",
}
)
var (
pathCache = cache.New(1*time.Hour, 1*time.Hour)
// Caching the existence of an user
userCache = cache.New(24*time.Hour, 12*time.Hour)
// Caches the existence of a Content-Security-Policy
// Mapping: Repository key -> CSPCacheEntry
cspCache = cache.New(24*time.Hour, 12*time.Hour)
)
type PageCacheEntry struct {
Repository Repository
Repository gitea.Repository
Path string
}
type CSPCacheEntry struct {
CSP string
LastRequested time.Time
}
func makePageCacheKey(domain, path string) string {
return domain + "/" + path
}
func makeCSPCacheKey(username, repositoryName string) string {
return username + ":" + repositoryName
}
func lookupRepositoryAndCache(username, reponame, branchName, host, domain, path, cname string, giteaClient *GiteaClient) (*Repository, error) {
func lookupRepositoryAndCache(username, reponame, branchName, host, domain, path, cname string, giteaClient *gitea.GiteaClient) (*gitea.Repository, error) {
log.Debugf("CNAME: %s", cname)
log.Debugf("Looking up repository %s/%s", username, reponame)
repo, err := giteaClient.getRepository(username, reponame)
repo, err := giteaClient.GetRepository(username, reponame)
if err != nil {
return nil, err
}
if !giteaClient.hasBranch(username, reponame, branchName) {
if !giteaClient.HasBranch(username, reponame, branchName) {
return nil, errors.New("Specified branch does not exist")
}
@@ -101,7 +103,7 @@ func lookupRepositoryAndCache(username, reponame, branchName, host, domain, path
// host is the domain name we're accessed from. cname is the domain that host is pointing
// if, if we're accessed via a CNAME. If not, then cname is "".
func RepoFromPath(username, host, cname, path string, giteaClient *GiteaClient) (*Repository, string, error) {
func RepoFromPath(username, host, cname, path string, giteaClient *gitea.GiteaClient) (*gitea.Repository, string, error) {
domain := host
// Guess the repository
@@ -115,7 +117,7 @@ func RepoFromPath(username, host, cname, path string, giteaClient *GiteaClient)
// Allow specifying the repository name in the TXT record
reponame := ""
if cname != "" {
repoLookup, err := giteaClient.lookupRepoTXT(host)
repoLookup, err := giteaClient.LookupRepoTXT(host)
if err == nil && repoLookup != "" {
log.Infof(
"TXT lookup for %s resulted in choosing repository %s",
@@ -166,52 +168,66 @@ func RepoFromPath(username, host, cname, path string, giteaClient *GiteaClient)
// Checks if the username exists as an organisation or an user on the Gitea
// instance, so that an attacker can't just request certificates for random
// usernames.
func CanRequestCertificate(username string, giteaClient *GiteaClient) bool {
func CanRequestCertificate(username string, giteaClient *gitea.GiteaClient) bool {
if _, found := userCache.Get(username); found {
return true
}
hasUser := giteaClient.hasUser(username)
hasUser := giteaClient.HasUser(username)
if hasUser {
userCache.Set(username, true, cache.DefaultExpiration)
}
return hasUser
}
// Checks the repository username/repository@PagesBranch for a file named CSP. If it exists,
// read it and return the value. If it does not exist, return defaultCsp.
func GetCSPForRepository(username, repositoryName, defaultCsp string, giteaClient *GiteaClient) string {
key := makeCSPCacheKey(username, repositoryName)
cachedCsp, found := cspCache.Get(key)
var since time.Time
if found {
since = cachedCsp.(CSPCacheEntry).LastRequested
}
func filterHeaders(headers map[string]string) map[string]string {
newHeaders := make(map[string]string)
fetchedCsp, changed, err := giteaClient.GetFile(
username,
repositoryName,
constants.PagesBranch,
"CSP",
&since,
)
csp := ""
if err != nil {
if found {
return cachedCsp.(CSPCacheEntry).CSP
for key, value := range headers {
if slices.Contains[[]string, string](ForbiddenHeaders, strings.ToLower(key)) {
continue
}
csp = defaultCsp
} else {
csp = string(fetchedCsp)
if !found || changed {
cspCache.Set(key, CSPCacheEntry{
CSP: csp,
LastRequested: time.Now(),
}, cache.DefaultExpiration)
}
newHeaders[key] = value
}
return csp
return newHeaders
}
func GetRepositoryInformation(owner, repoName string, ctx *context.GlobalContext) *context.RepositoryInformation {
res := ctx.Cache.GetRepositoryInformation(owner, repoName)
if res != nil {
return res
}
fetchedConfig, _, err := ctx.Gitea.GetFile(
owner,
repoName,
constants.PagesBranch,
"rio.json",
nil,
)
if err != nil {
log.Errorf("Failed to request rio.json for %s/%s:%v", owner, repoName, err)
return nil
}
var payload map[string]interface{}
err = json.Unmarshal(fetchedConfig, &payload)
if err != nil {
log.Errorf("Failed to unmarshal rio.json for %s/%s:%v", owner, repoName, err)
return nil
}
headers, found := payload["headers"]
if !found {
log.Warnf("Did not find headers key in rio.json for %s/%s", owner, repoName)
headers = make(map[string]string)
}
info := context.RepositoryInformation{
Headers: filterHeaders(headers.(map[string]string)),
}
ctx.Cache.SetRepositoryInformation(owner, repoName, info)
return &info
}

View File

@@ -2,28 +2,42 @@ package repo
import (
"errors"
"net/http"
"strings"
"testing"
"time"
"code.gitea.io/sdk/gitea"
"git.polynom.me/rio/internal/gitea"
log "github.com/sirupsen/logrus"
)
func clearCache() {
pathCache.Flush()
userCache.Flush()
cspCache.Flush()
func TestHeaderFilter(t *testing.T) {
map1 := filterHeaders(
map[string]string{
"Content-Type": "hallo",
"content-Type": "welt",
"content-type": "uwu",
"CONTENT-TYPE": "lol",
"Content-Security-Policy": "none",
},
)
if len(map1) != 1 {
t.Fatalf("filterHeaders allowed %d != 1 headers", len(map1))
}
for key := range map1 {
if strings.ToLower(key) == "content-type" {
t.Fatalf("filterHeaders allowed Content-Type")
}
}
}
func TestPickingCorrectRepositoryDefault(t *testing.T) {
// Test that we default to the <username>.<pages domain> repository, if we have only
// one path component.
defer clearCache()
log.SetLevel(log.DebugLevel)
client := GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) {
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username != "example-user" {
t.Fatalf("Called with unknown user %s", username)
}
@@ -31,9 +45,9 @@ func TestPickingCorrectRepositoryDefault(t *testing.T) {
t.Fatalf("Called with unknown repository %s", repositoryName)
}
return Repository{}, nil
return gitea.Repository{}, nil
},
hasBranch: func(username, repositoryName, branchName string) bool {
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true
}
@@ -44,12 +58,12 @@ func TestPickingCorrectRepositoryDefault(t *testing.T) {
t.Fatal("getFile called")
return []byte{}, true, nil
},
lookupCNAME: func(domain string) (string, error) {
t.Fatal("lookupCNAME called")
LookupCNAME: func(domain string) (string, error) {
t.Fatal("LookupCNAME called")
return "", nil
},
lookupRepoTXT: func(domain string) (string, error) {
t.Fatal("lookupRepoTXT called")
LookupRepoTXT: func(domain string) (string, error) {
t.Fatal("LookupRepoTXT called")
return "", nil
},
}
@@ -69,24 +83,22 @@ func TestPickingCorrectRepositoryDefault(t *testing.T) {
func TestPickingCorrectRepositoryDefaultSubdirectory(t *testing.T) {
// Test that we return the default repository when the first path component does
// not correspong to an existing repository.
defer clearCache()
log.SetLevel(log.DebugLevel)
client := GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) {
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username != "example-user" {
t.Fatalf("Called with unknown user %s", username)
}
if repositoryName == "assets" {
return Repository{}, errors.New("Repository does not exist")
return gitea.Repository{}, errors.New("gitea.Repository does not exist")
} else if repositoryName == "example-user.pages.example.org" {
return Repository{}, nil
return gitea.Repository{}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil
return gitea.Repository{}, nil
}
},
hasBranch: func(username, repositoryName, branchName string) bool {
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true
}
@@ -97,12 +109,12 @@ func TestPickingCorrectRepositoryDefaultSubdirectory(t *testing.T) {
t.Fatal("getFile called")
return []byte{}, true, nil
},
lookupCNAME: func(domain string) (string, error) {
t.Fatal("lookupCNAME called")
LookupCNAME: func(domain string) (string, error) {
t.Fatal("LookupCNAME called")
return "", nil
},
lookupRepoTXT: func(domain string) (string, error) {
t.Fatal("lookupRepoTXT called")
LookupRepoTXT: func(domain string) (string, error) {
t.Fatal("LookupRepoTXT called")
return "", nil
},
}
@@ -122,28 +134,26 @@ func TestPickingCorrectRepositoryDefaultSubdirectory(t *testing.T) {
func TestPickingCorrectRepositorySubdirectoryNoPagesBranch(t *testing.T) {
// Test that we're picking the correct repository when the first path component
// returns a repository without a pages branch.
defer clearCache()
log.SetLevel(log.DebugLevel)
client := GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) {
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username != "example-user" {
t.Fatalf("Called with unknown user %s", username)
}
if repositoryName == "blog" {
return Repository{
return gitea.Repository{
Name: "blog",
}, nil
} else if repositoryName == "example-user.pages.example.org" {
return Repository{
return gitea.Repository{
Name: "example-user.pages.example.org",
}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil
return gitea.Repository{}, nil
}
},
hasBranch: func(username, repositoryName, branchName string) bool {
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true
}
@@ -154,12 +164,12 @@ func TestPickingCorrectRepositorySubdirectoryNoPagesBranch(t *testing.T) {
t.Fatal("getFile called")
return []byte{}, true, nil
},
lookupCNAME: func(domain string) (string, error) {
t.Fatal("lookupCNAME called")
LookupCNAME: func(domain string) (string, error) {
t.Fatal("LookupCNAME called")
return "", nil
},
lookupRepoTXT: func(domain string) (string, error) {
t.Fatal("lookupRepoTXT called")
LookupRepoTXT: func(domain string) (string, error) {
t.Fatal("LookupRepoTXT called")
return "", nil
},
}
@@ -181,21 +191,19 @@ func TestPickingCorrectRepositorySubdirectoryNoPagesBranch(t *testing.T) {
func TestPickingNoRepositoryInvalidCNAME(t *testing.T) {
// Test that we're not picking a repository if the CNAME validation fails.
defer clearCache()
log.SetLevel(log.DebugLevel)
client := GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) {
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "example-user.pages.example.org" {
return Repository{
return gitea.Repository{
Name: "example-user.pages.example.org",
}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil
return gitea.Repository{}, nil
}
},
hasBranch: func(username, repositoryName, branchName string) bool {
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true
}
@@ -210,37 +218,35 @@ func TestPickingNoRepositoryInvalidCNAME(t *testing.T) {
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
lookupCNAME: func(domain string) (string, error) {
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
lookupRepoTXT: func(domain string) (string, error) {
LookupRepoTXT: func(domain string) (string, error) {
return "", nil
},
}
_, _, err := RepoFromPath("example-user", "example-user.pages.example.org", "example-user.local", "index.html", &client)
if err == nil {
t.Fatal("Repository returned even though CNAME validation should fail")
t.Fatal("gitea.Repository returned even though CNAME validation should fail")
}
}
func TestPickingRepositoryValidCNAME(t *testing.T) {
// Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds.
defer clearCache()
log.SetLevel(log.DebugLevel)
client := GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) {
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "example-user.local" {
return Repository{
return gitea.Repository{
Name: "example-user.local",
}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil
return gitea.Repository{}, nil
}
},
hasBranch: func(username, repositoryName, branchName string) bool {
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.local" && branchName == "pages" {
return true
}
@@ -255,10 +261,10 @@ func TestPickingRepositoryValidCNAME(t *testing.T) {
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
lookupCNAME: func(domain string) (string, error) {
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
lookupRepoTXT: func(domain string) (string, error) {
LookupRepoTXT: func(domain string) (string, error) {
return "", nil
},
}
@@ -275,21 +281,19 @@ func TestPickingRepositoryValidCNAME(t *testing.T) {
func TestPickingRepositoryValidCNAMEWithTXTLookup(t *testing.T) {
// Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds
// and the TXT lookup returns something different.
defer clearCache()
log.SetLevel(log.DebugLevel)
client := GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) {
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" {
return Repository{
return gitea.Repository{
Name: "some-different-repository",
}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return Repository{}, nil
return gitea.Repository{}, nil
}
},
hasBranch: func(username, repositoryName, branchName string) bool {
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true
}
@@ -304,10 +308,10 @@ func TestPickingRepositoryValidCNAMEWithTXTLookup(t *testing.T) {
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
lookupCNAME: func(domain string) (string, error) {
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
lookupRepoTXT: func(domain string) (string, error) {
LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" {
return "some-different-repository", nil
}
@@ -327,20 +331,18 @@ func TestPickingRepositoryValidCNAMEWithTXTLookup(t *testing.T) {
func TestPickingRepositoryValidCNAMEWithTXTLookupAndSubdirectory(t *testing.T) {
// Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds
// and the TXT lookup returns something different. Additionally, we now have a subdirectory
defer clearCache()
log.SetLevel(log.DebugLevel)
client := GiteaClient{
getRepository: func(username, repositoryName string) (Repository, error) {
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" {
return Repository{
return gitea.Repository{
Name: "some-different-repository",
}, nil
}
return Repository{}, errors.New("Unknown repository")
return gitea.Repository{}, errors.New("Unknown repository")
},
hasBranch: func(username, repositoryName, branchName string) bool {
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true
}
@@ -355,10 +357,10 @@ func TestPickingRepositoryValidCNAMEWithTXTLookupAndSubdirectory(t *testing.T) {
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
lookupCNAME: func(domain string) (string, error) {
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
lookupRepoTXT: func(domain string) (string, error) {
LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" {
return "some-different-repository", nil
}
@@ -374,32 +376,3 @@ func TestPickingRepositoryValidCNAMEWithTXTLookupAndSubdirectory(t *testing.T) {
t.Fatalf("Invalid repository name returned: %s", repo.Name)
}
}
func TestGetCSPForRepositoryNegativeIntegration(t *testing.T) {
defer clearCache()
httpClient := http.Client{Timeout: 10 * time.Second}
giteaClient, err := gitea.NewClient(
"https://git.polynom.me",
gitea.SetHTTPClient(&httpClient),
gitea.SetToken(""),
gitea.SetUserAgent("rio-testing"),
)
if err != nil {
t.Fatalf("Failed to create Gitea client: %v", err)
}
client := NewGiteaClient("https://git.polynom.me", giteaClient)
// The repository has no CSP file, so it should return the invalid value
defaultValue := "<INVALID>"
csp := GetCSPForRepository(
"papatutuwawa",
"rio",
defaultValue,
&client,
)
if csp != defaultValue {
t.Fatalf("Unexpected CSP returned: %s", csp)
}
}