Add authorization.

This commit is contained in:
Catherine
2025-09-15 04:13:07 +00:00
parent a3eca4f639
commit 2f59de02e3
3 changed files with 80 additions and 33 deletions

View File

@@ -49,6 +49,12 @@ $ curl http://127.0.0.1:3333/ -H 'Host: codeberg.page'
```
Authorization
-------------
DNS is used for authorization of content updates for custom domain names. Whenever a `PUT` or `POST` request is received at `hostname.tld` that has an `Authorization: Pages <token>` header, the TXT record(s) at `_git-pages-challenge.hostname.tld` are compared with `sha256("hostname.tld <token>")`. If there is a match then updates from any clone URLs are allowed.
Architecture
------------
@@ -66,8 +72,6 @@ This approach has the benefits of being easy to explore and debug, but places a
The specific arrangement used is clearly not optimal; at a minimum it is likely worth it to deduplicate files under `data/tree/` using hardlinks, or perhaps to put objects in a flat, content-addressed store with `data/www/` linking to each individual file. The key practical constraint will likely be the need to attribute excessively large trees to repositories they were built from (and to perform GC), which suggests adding structure and not removing it.
I lack any interesting insight into authentication mechanisms applicable here. It would be straightforward to verify whether a custom domain contains a TXT record specifying the allowed source repositories.
License
-------

64
auth.go Normal file
View File

@@ -0,0 +1,64 @@
package main
import (
"crypto/sha256"
"fmt"
"net"
"net/http"
"slices"
"strings"
)
func getHost(r *http.Request) string {
// FIXME: handle IDNA
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
// dirty but the go stdlib doesn't have a "split port if present" function
host = r.Host
}
return host
}
func authorize(w http.ResponseWriter, r *http.Request) error {
host := getHost(r)
authorization := r.Header.Get("Authorization")
if authorization == "" {
http.Error(w, "missing Authorization header", http.StatusUnauthorized)
return fmt.Errorf("missing Authorization header")
}
scheme, param, success := strings.Cut(authorization, " ")
if !success {
http.Error(w, "malformed Authorization header", http.StatusBadRequest)
return fmt.Errorf("malformed Authorization header")
}
if scheme != "Pages" {
http.Error(w, "unknown Authorization scheme", http.StatusBadRequest)
return fmt.Errorf("unknown Authorization scheme")
}
challengeHostname := fmt.Sprintf("_git-pages-challenge.%s", host)
actualChallenges, err := net.LookupTXT(challengeHostname)
if err != nil {
http.Error(w, "failed to look up DNS challenge", http.StatusUnauthorized)
return fmt.Errorf("failed to look up %s: %s", challengeHostname, err)
}
expectedChallenge := fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%s %s", host, param)))
if !slices.Contains(actualChallenges, expectedChallenge) {
http.Error(w,
fmt.Sprintf("defeated by DNS challenge (%s not in %s)", expectedChallenge, challengeHostname),
http.StatusUnauthorized,
)
return fmt.Errorf(
"challenge mismatch for %s: %s does not contain %s",
challengeHostname,
actualChallenges,
expectedChallenge,
)
}
return nil
}

View File

@@ -2,15 +2,11 @@ package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
@@ -23,16 +19,6 @@ import (
const fetchTimeout = 30 * time.Second
func getHost(r *http.Request) string {
// FIXME: handle IDNA
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
// dirty but the go stdlib doesn't have a "split port if present" function
host = r.Host
}
return host
}
func getPage(dataDir string, w http.ResponseWriter, r *http.Request) error {
host := getHost(r)
@@ -120,6 +106,11 @@ func getProjectName(w http.ResponseWriter, r *http.Request) (string, error) {
func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error {
host := getHost(r)
err := authorize(w, r)
if err != nil {
return err
}
projectName, err := getProjectName(w, r)
if err != nil {
return err
@@ -164,11 +155,14 @@ func putPage(dataDir string, w http.ResponseWriter, r *http.Request) error {
return result.err
}
const hmacSecret = ""
func postPage(dataDir string, w http.ResponseWriter, r *http.Request) error {
host := getHost(r)
err := authorize(w, r)
if err != nil {
return err
}
projectName, err := getProjectName(w, r)
if err != nil {
return err
@@ -189,21 +183,6 @@ func postPage(dataDir string, w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("body read: %s", err)
}
if hmacSecret != "" {
signature, err := hex.DecodeString(r.Header.Get("X-Forgejo-Signature"))
if err != nil {
http.Error(w, "malformed signature", http.StatusBadRequest)
return fmt.Errorf("malformed signature")
}
mac := hmac.New(sha256.New, []byte(hmacSecret))
mac.Write(requestBody)
if !hmac.Equal(mac.Sum(nil), signature) {
http.Error(w, "invalid signature", http.StatusBadRequest)
return fmt.Errorf("invalid hmac")
}
}
var event map[string]any
err = json.NewDecoder(bytes.NewReader(requestBody)).Decode(&event)
if err != nil {