linstrom/web/public/middleware/authFetchCheck.go
mstar 627926460c
All checks were successful
/ docker (push) Successful in 4m14s
Auth fetch verification (cavage) works now
- Verifying inbound requests signed with Cavage are now checked as
  expected
- Fixed a bug where the signature header is not generated correctly
- Extended config to include settings for what requests to verify
- Fixed new server in main not using internal port from config
2025-04-22 15:27:24 +02:00

291 lines
8.6 KiB
Go

package webmiddleware
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"net/http"
"net/url"
"regexp"
"strings"
"time"
webutils "git.mstar.dev/mstar/goutils/http"
"git.mstar.dev/mstar/goutils/other"
"github.com/rs/zerolog/hlog"
"gorm.io/gorm"
"git.mstar.dev/mstar/linstrom/activitypub"
"git.mstar.dev/mstar/linstrom/config"
"git.mstar.dev/mstar/linstrom/storage-new"
"git.mstar.dev/mstar/linstrom/storage-new/dbgen"
"git.mstar.dev/mstar/linstrom/storage-new/models"
webshared "git.mstar.dev/mstar/linstrom/web/shared"
)
const signatureRegexString = `keyId="([a-zA-Z0-9_\-\.:@\/\?&=#%\+\[\]!$\(\)\*,;]+)",(?:algorithm="([a-z0-9-]+)",)?headers="([a-z0-9\-_\(\) ]+)",(?:algorithm="([a-z0-9-]+)",)?signature="(.+)"`
var publicPaths = []*regexp.Regexp{
regexp.MustCompile(`/\.well-known/.+^`),
// regexp.MustCompile(``),
}
var signatureRegex = regexp.MustCompile(signatureRegexString)
// Builder for the authorized fetch check middleware.
// forNonGet determines whether the check should happen for non-GET requests.
// forGet determines whether the check should also happen for GET requests.
// forGet being true implicitly sets forNonGet true too.
// Requests to the server actor and other, required public resources
// will never be checked
func BuildAuthorizedFetchCheck(forNonGet bool, forGet bool) webutils.HandlerBuilder {
return func(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log := hlog.FromRequest(r)
log.Info().Msg("AuthFetch middleware")
defer log.Info().Msg("AuhFetch completed")
path := r.URL.Path
// Check always open path first
for _, re := range publicPaths {
if re.Match([]byte(path)) {
h.ServeHTTP(w, r)
return
}
}
// WARN: This check could potentially pose a security risk where an AP request
// could get around providing a valid signature by using the server actor's
// ID somewhere in the path.
// However, I suspect that this risk is mostly mitigated by Go's router
// already cleaning paths up and the only other target being one specific
// note that most likely won't ever even exist due to the large random value space
// offered by UUIDs
// So either you get the server actor, which is intended behaviour, or access to
// one specific note that most likely won't even exist
if !config.GlobalConfig.Experimental.AuthFetchForServerActor &&
strings.Contains(path, storage.ServerActorId) {
log.Info().Msg("Server actor requested, no auth")
h.ServeHTTP(w, r)
return
}
// Not an always open path, check methods
if r.Method == "GET" && !forGet {
log.Info().Msg("Get request to AP resources don't need signature")
h.ServeHTTP(w, r)
return
} else if !forGet && !forNonGet {
log.Info().Msg("Requests to AP resources don't need signature")
h.ServeHTTP(w, r)
return
}
log.Info().Msg("Need signature for AP request")
rawDate := r.Header.Get("Date")
date, err := http.ParseTime(rawDate)
if err != nil {
webutils.ProblemDetails(
w,
http.StatusBadRequest,
"/errors/bad-date",
"no or invalid date header",
nil,
nil,
)
return
}
if time.Since(date) > time.Hour+time.Minute*5 {
webutils.ProblemDetails(
w,
http.StatusUnauthorized,
"/errors/invalid-auth-signature",
"invalid authorization signature",
other.IntoPointer("Request is outdated"),
nil,
)
return
}
signatureHeader := r.Header.Get("Signature")
if signatureHeader == "" {
log.Info().Msg("Received AP request without signature header where one is required")
webutils.ProblemDetails(
w,
http.StatusUnauthorized,
"/errors/invalid-auth-signature",
"invalid authorization signature",
other.IntoPointer("Missing Signature header for authorization"),
nil,
)
return
}
log.Debug().
Str("signature-header", signatureHeader).
Msg("Signature header of inbound AP request")
match := signatureRegex.FindStringSubmatch(signatureHeader)
if len(match) <= 1 {
log.Info().
Str("header", signatureHeader).
Msg("Received signature with invalid pattern")
webutils.ProblemDetails(
w,
http.StatusUnauthorized,
"/errors/invalid-auth-signature",
"invalid authorization signature",
other.IntoPointer("Invalid signature header format"),
map[string]any{
"used signature regex": signatureRegexString,
"signature format": `keyId="<url to actor with used key>",headers="<list of headers used, including pseudo header (request-target)>",(optional: algorithm="<one of rsa-sha256, hs2019>", (defaults to rsa-sha256 if not set))signature="signed message"`,
},
)
return
}
// fullMatch = match[0]
rawKeyId := match[1]
rawAlgorithm1 := match[2]
rawHeaders := match[3]
rawAlgorithm2 := match[4]
signature, err := base64.StdEncoding.DecodeString(match[5])
if err != nil {
webutils.ProblemDetails(
w,
http.StatusUnauthorized,
"/errors/invalid-auth-signature",
"invalid authorization signature",
other.IntoPointer(
"Signature not decodable as bas64",
),
nil,
)
return
}
var rawAlgorithm string
if rawAlgorithm2 != "" {
rawAlgorithm = rawAlgorithm2
} else if rawAlgorithm1 != "" {
rawAlgorithm = rawAlgorithm1
} else {
rawAlgorithm = "hs2019"
}
_, err = url.Parse(rawKeyId)
if err != nil {
log.Warn().Err(err).Msg("Key id is not an url")
webutils.ProblemDetails(
w,
http.StatusUnauthorized,
"/errors/invalid-auth-signature",
"invalid authorization signature",
other.IntoPointer("keyId must be a valid url"),
nil,
)
return
}
_ = rawAlgorithm
stringToCheck := buildStringToCheck(r, rawHeaders)
log.Warn().Str("string-to-check", stringToCheck).Send()
requestingActor, err := getRequestingActor(rawKeyId)
if err != nil {
log.Error().Err(err).Msg("Failed to get requesting actor")
webutils.ProblemDetails(
w,
http.StatusUnauthorized,
"/errors/invalid-auth-signature",
"invalid authorization signature",
other.IntoPointer(
"Failed to get the signing account for signature verification",
),
nil,
)
return
}
log.Debug().
Str("id", requestingActor.ID).
Str("username", requestingActor.Username).
Msg("Got requesting actor")
key, err := x509.ParsePKCS1PublicKey(requestingActor.PublicKeyRsa)
if err != nil {
rawKey, err := x509.ParsePKIXPublicKey(requestingActor.PublicKeyRsa)
if err != nil {
log.Warn().Err(err).Msg("Failed to parse public key of requesting actor")
webutils.ProblemDetails(
w,
http.StatusUnauthorized,
"/errors/invalid-auth-signature",
"invalid authorization signature",
other.IntoPointer("Key is not a valid PKCS1 marshalled key"),
nil,
)
return
}
var ok bool
key, ok = rawKey.(*rsa.PublicKey)
if !ok {
log.Warn().Msg("Received public key is not rsa")
webutils.ProblemDetails(
w,
http.StatusUnauthorized,
"/errors/invalid-auth-signature",
"invalid authorization signature",
other.IntoPointer("Received public key is not rsa"),
nil,
)
return
}
}
hash := sha256.Sum256([]byte(stringToCheck))
err = rsa.VerifyPKCS1v15(key, crypto.SHA256, hash[:], []byte(signature))
if err != nil {
log.Warn().Err(err).Msg("Signature verification failed")
webutils.ProblemDetails(
w,
http.StatusUnauthorized,
"/errors/invalid-auth-signature",
"invalid authorization signature",
other.IntoPointer(
"Verification of the given signature with the user's public key failed",
),
nil,
)
return
}
h.ServeHTTP(w, r)
})
}
}
func buildStringToCheck(r *http.Request, rawSignedHeaders string) string {
headersToUse := strings.Split(rawSignedHeaders, " ")
// Doesn't matter here if convert failed as path doesn't have prefix in that case
path, ok := r.Context().Value((FullPathContextKey)).(string)
if !ok {
path = r.URL.Path
}
return webshared.GenerateStringToSign(
r.Method,
config.GlobalConfig.General.GetFullDomain(),
path,
r.Header,
headersToUse,
)
}
func getRequestingActor(keyId string) (*models.User, error) {
// Cut away key id to get AP url
keyId = strings.Split(keyId, "#")[0]
acc, err := dbgen.User.GetRemoteAccountByApUrl(keyId)
switch err {
case gorm.ErrRecordNotFound:
acc, err = activitypub.ImportRemoteAccountByAPUrl(keyId)
if err != nil {
return nil, err
}
return acc, nil
case nil:
return acc, nil
default:
return nil, err
}
}