All checks were successful
/ docker (push) Successful in 4m14s
- 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
291 lines
8.6 KiB
Go
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
|
|
}
|
|
}
|