challenges: prevent unbounded growth of stored cookies by bundling all state onto a single JWT token
This commit is contained in:
@@ -29,6 +29,7 @@ func (a Block) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Reques
|
|||||||
|
|
||||||
w.Header().Set("Content-Type", "text/plain")
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
w.Header().Set("Connection", "close")
|
w.Header().Set("Connection", "close")
|
||||||
|
data.ResponseHeaders(w)
|
||||||
w.WriteHeader(a.Code)
|
w.WriteHeader(a.Code)
|
||||||
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))
|
_, _ = w.Write([]byte(fmt.Errorf("access blocked: blocked by administrative rule %s/%s", data.Id.String(), a.RuleHash).Error()))
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ type CodeSettings struct {
|
|||||||
type Code int
|
type Code int
|
||||||
|
|
||||||
func (a Code) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
func (a Code) Handle(logger *slog.Logger, w http.ResponseWriter, r *http.Request, done func() (backend http.Handler)) (next bool, err error) {
|
||||||
|
challenge.RequestDataFromContext(r.Context()).ResponseHeaders(w)
|
||||||
|
|
||||||
w.WriteHeader(int(a))
|
w.WriteHeader(int(a))
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package cookie
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
"git.gammaspectra.live/git/go-away/utils"
|
|
||||||
"github.com/goccy/go-yaml/ast"
|
"github.com/goccy/go-yaml/ast"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -18,18 +17,15 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
|||||||
reg.Class = challenge.ClassBlocking
|
reg.Class = challenge.ClassBlocking
|
||||||
|
|
||||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
|
data := challenge.RequestDataFromContext(r.Context())
|
||||||
if err != nil {
|
data.IssueChallengeToken(reg, key, nil, expiry, true)
|
||||||
return challenge.VerifyResultFail
|
|
||||||
}
|
|
||||||
|
|
||||||
utils.SetCookie(challenge.RequestDataFromContext(r.Context()).CookiePrefix+reg.Name, token, expiry, w, r)
|
|
||||||
|
|
||||||
uri, err := challenge.RedirectUrl(r, reg)
|
uri, err := challenge.RedirectUrl(r, reg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return challenge.VerifyResultFail
|
return challenge.VerifyResultFail
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data.ResponseHeaders(w)
|
||||||
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
||||||
return challenge.VerifyResultNone
|
return challenge.VerifyResultNone
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package challenge
|
package challenge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
http_cel "codeberg.org/gone/http-cel"
|
http_cel "codeberg.org/gone/http-cel"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -9,10 +10,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.gammaspectra.live/git/go-away/utils"
|
"git.gammaspectra.live/git/go-away/utils"
|
||||||
|
"github.com/go-jose/go-jose/v4"
|
||||||
|
"github.com/go-jose/go-jose/v4/jwt"
|
||||||
"github.com/google/cel-go/cel"
|
"github.com/google/cel-go/cel"
|
||||||
"github.com/google/cel-go/common/types"
|
"github.com/google/cel-go/common/types"
|
||||||
"github.com/google/cel-go/common/types/traits"
|
"github.com/google/cel-go/common/types/traits"
|
||||||
"maps"
|
"maps"
|
||||||
|
unsaferand "math/rand/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"net/textproto"
|
"net/textproto"
|
||||||
@@ -42,9 +46,13 @@ type RequestData struct {
|
|||||||
Time time.Time
|
Time time.Time
|
||||||
ChallengeVerify map[Id]VerifyResult
|
ChallengeVerify map[Id]VerifyResult
|
||||||
ChallengeState map[Id]VerifyState
|
ChallengeState map[Id]VerifyState
|
||||||
|
ChallengeMap TokenChallengeMap
|
||||||
|
challengeMapModified bool
|
||||||
|
|
||||||
RemoteAddress netip.AddrPort
|
RemoteAddress netip.AddrPort
|
||||||
State StateInterface
|
State StateInterface
|
||||||
CookiePrefix string
|
cookieName string
|
||||||
|
issuedChallenge string
|
||||||
|
|
||||||
ExtraHeaders http.Header
|
ExtraHeaders http.Header
|
||||||
|
|
||||||
@@ -84,6 +92,11 @@ func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *R
|
|||||||
}
|
}
|
||||||
|
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
|
|
||||||
|
if q.Has(QueryArgChallenge) {
|
||||||
|
data.issuedChallenge = q.Get(QueryArgChallenge)
|
||||||
|
}
|
||||||
|
|
||||||
// delete query parameters that were set by go-away
|
// delete query parameters that were set by go-away
|
||||||
for k := range q {
|
for k := range q {
|
||||||
if strings.HasPrefix(k, QueryArgPrefix) {
|
if strings.HasPrefix(k, QueryArgPrefix) {
|
||||||
@@ -95,19 +108,12 @@ func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *R
|
|||||||
data.header = http_cel.NewMIMEMap(textproto.MIMEHeader(r.Header))
|
data.header = http_cel.NewMIMEMap(textproto.MIMEHeader(r.Header))
|
||||||
data.opts = make(map[string]string)
|
data.opts = make(map[string]string)
|
||||||
|
|
||||||
sum := sha256.New()
|
|
||||||
sum.Write([]byte(r.Host))
|
|
||||||
sum.Write([]byte{0})
|
|
||||||
sum.Write(data.NetworkPrefix().AsSlice())
|
|
||||||
sum.Write([]byte{0})
|
|
||||||
sum.Write(state.PublicKey())
|
|
||||||
sum.Write([]byte{0})
|
|
||||||
data.CookiePrefix = utils.CookiePrefix + hex.EncodeToString(sum.Sum(nil)[:6]) + "-"
|
|
||||||
|
|
||||||
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
|
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
|
||||||
r = utils.SetRemoteAddress(r, data.RemoteAddress)
|
r = utils.SetRemoteAddress(r, data.RemoteAddress)
|
||||||
data.r = r
|
data.r = r
|
||||||
|
|
||||||
|
data.cookieName = utils.DefaultCookiePrefix + hex.EncodeToString(data.cookieHostKey()) + "-state"
|
||||||
|
|
||||||
return r, &data
|
return r, &data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,18 +200,70 @@ func (d *RequestData) BackendHost() (http.Handler, string) {
|
|||||||
return d.State.GetBackend(host), host
|
return d.State.GetBackend(host), host
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
func (d *RequestData) ClearChallengeToken(reg *Registration) {
|
||||||
q := r.URL.Query()
|
delete(d.ChallengeMap, reg.Name)
|
||||||
var issuedChallenge string
|
d.challengeMapModified = true
|
||||||
if q.Has(QueryArgChallenge) {
|
}
|
||||||
issuedChallenge = q.Get(QueryArgChallenge)
|
|
||||||
|
func (d *RequestData) IssueChallengeToken(reg *Registration, key Key, result []byte, until time.Time, ok bool) {
|
||||||
|
d.ChallengeMap[reg.Name] = TokenChallenge{
|
||||||
|
Key: key[:],
|
||||||
|
Result: result,
|
||||||
|
Ok: ok,
|
||||||
|
Expiry: jwt.NumericDate(until.Unix()),
|
||||||
|
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
||||||
}
|
}
|
||||||
for _, reg := range d.State.GetChallenges() {
|
d.challengeMapModified = true
|
||||||
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
|
}
|
||||||
verifyResult, verifyState, err := reg.VerifyChallengeToken(d.State.PublicKey(), key, r)
|
|
||||||
|
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
||||||
|
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
||||||
|
var ErrTokenExpired = errors.New("token: expired")
|
||||||
|
|
||||||
|
func (d *RequestData) VerifyChallengeToken(reg *Registration, token TokenChallenge, expectedKey Key) (VerifyResult, VerifyState, error) {
|
||||||
|
if token.Expiry.Time().Compare(time.Now()) < 0 {
|
||||||
|
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
|
||||||
|
}
|
||||||
|
if token.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||||
|
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.Compare(expectedKey[:], token.Key) != 0 {
|
||||||
|
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
if reg.Verify != nil {
|
||||||
|
if unsaferand.Float64() < reg.VerifyProbability {
|
||||||
|
// random spot check
|
||||||
|
if ok, err := reg.Verify(expectedKey, token.Result, d.r); err != nil {
|
||||||
|
return VerifyResultFail, VerifyStateFull, err
|
||||||
|
} else if ok == VerifyResultNotOK {
|
||||||
|
return VerifyResultNotOK, VerifyStateFull, nil
|
||||||
|
} else if !ok.Ok() {
|
||||||
|
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
|
||||||
|
} else {
|
||||||
|
return ok, VerifyStateFull, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !token.Ok {
|
||||||
|
return VerifyResultNotOK, VerifyStateBrief, nil
|
||||||
|
}
|
||||||
|
return VerifyResultOK, VerifyStateBrief, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) verifyChallenge(reg *Registration, key Key) (verifyResult VerifyResult, verifyState VerifyState, err error) {
|
||||||
|
|
||||||
|
token, ok := d.ChallengeMap[reg.Name]
|
||||||
|
if !ok {
|
||||||
|
verifyResult = VerifyResultFail
|
||||||
|
verifyState = VerifyStateNone
|
||||||
|
} else {
|
||||||
|
verifyResult, verifyState, err = d.VerifyChallengeToken(reg, token, key)
|
||||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||||
// clear invalid cookie
|
// clear invalid state
|
||||||
utils.ClearCookie(d.CookiePrefix+reg.Name, w, r)
|
d.ClearChallengeToken(reg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// prevent evaluating the challenge if not solved
|
// prevent evaluating the challenge if not solved
|
||||||
@@ -213,20 +271,46 @@ func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request)
|
|||||||
out, _, err := reg.Condition.Eval(d)
|
out, _, err := reg.Condition.Eval(d)
|
||||||
// verify eligibility
|
// verify eligibility
|
||||||
if err != nil {
|
if err != nil {
|
||||||
d.State.Logger(r).Error(err.Error(), "challenge", reg.Name)
|
d.State.Logger(d.r).Error(err.Error(), "challenge", reg.Name)
|
||||||
} else if out != nil && out.Type() == types.BoolType {
|
} else if out != nil && out.Type() == types.BoolType {
|
||||||
if out.Equal(types.True) != types.True {
|
if out.Equal(types.True) != types.True {
|
||||||
// skip challenge match due to precondition!
|
// skip challenge match due to precondition!
|
||||||
verifyResult = VerifyResultSkip
|
verifyResult = VerifyResultSkip
|
||||||
continue
|
return verifyResult, verifyState, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !verifyResult.Ok() && issuedChallenge == reg.Name {
|
if !verifyResult.Ok() && d.issuedChallenge == reg.Name {
|
||||||
// we issued the challenge, must skip to prevent loops
|
// we issued the challenge, must skip to prevent loops
|
||||||
verifyResult = VerifyResultSkip
|
verifyResult = VerifyResultSkip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return verifyResult, verifyState, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
challengeMap, err := d.verifyChallengeState()
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, http.ErrNoCookie) {
|
||||||
|
//clear invalid cookie and continue
|
||||||
|
utils.ClearCookie(d.cookieName, w, r)
|
||||||
|
}
|
||||||
|
challengeMap = make(TokenChallengeMap)
|
||||||
|
}
|
||||||
|
d.ChallengeMap = challengeMap
|
||||||
|
|
||||||
|
for _, reg := range d.State.GetChallenges() {
|
||||||
|
|
||||||
|
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
|
||||||
|
verifyResult, verifyState, err := d.verifyChallenge(reg, key)
|
||||||
|
if err != nil {
|
||||||
|
// clear invalid state
|
||||||
|
d.ClearChallengeToken(reg)
|
||||||
|
}
|
||||||
|
|
||||||
d.ChallengeVerify[reg.Id()] = verifyResult
|
d.ChallengeVerify[reg.Id()] = verifyResult
|
||||||
d.ChallengeState[reg.Id()] = verifyState
|
d.ChallengeState[reg.Id()] = verifyState
|
||||||
}
|
}
|
||||||
@@ -240,13 +324,22 @@ func (d *RequestData) HasValidChallenge(id Id) bool {
|
|||||||
return d.ChallengeVerify[id].Ok()
|
return d.ChallengeVerify[id].Ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *RequestData) ResponseHeaders(headers http.Header) {
|
func (d *RequestData) ResponseHeaders(w http.ResponseWriter) {
|
||||||
// send these to client so we consistently get the headers
|
// send these to client so we consistently get the headers
|
||||||
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
//w.Header().Set("Accept-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||||
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
//w.Header().Set("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||||
|
|
||||||
if d.State.Settings().MainName != "" {
|
if d.State.Settings().MainName != "" {
|
||||||
headers.Add("Via", fmt.Sprintf("%s %s@%s", d.r.Proto, d.State.Settings().MainName, d.State.Settings().MainVersion))
|
w.Header().Add("Via", fmt.Sprintf("%s %s@%s", d.r.Proto, d.State.Settings().MainName, d.State.Settings().MainVersion))
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.challengeMapModified {
|
||||||
|
expiration := d.Expiration(DefaultDuration)
|
||||||
|
if token, err := d.issueChallengeState(expiration); err == nil {
|
||||||
|
utils.SetCookie(d.cookieName, token, expiration, w, d.r)
|
||||||
|
} else {
|
||||||
|
d.State.Logger(d.r).Error("error while issuing cookie", "error", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,3 +375,111 @@ func (d *RequestData) RequestHeaders(headers http.Header) {
|
|||||||
|
|
||||||
maps.Copy(headers, d.ExtraHeaders)
|
maps.Copy(headers, d.ExtraHeaders)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Token struct {
|
||||||
|
State TokenChallengeMap `json:"state"`
|
||||||
|
|
||||||
|
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
||||||
|
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
||||||
|
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenChallengeMap map[string]TokenChallenge
|
||||||
|
|
||||||
|
type TokenChallenge struct {
|
||||||
|
Key []byte `json:"key"`
|
||||||
|
Result []byte `json:"result,omitempty"`
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
|
||||||
|
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
||||||
|
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
||||||
|
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) verifyChallengeState() (TokenChallengeMap, error) {
|
||||||
|
cookie, err := d.r.Cookie(d.cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if cookie == nil {
|
||||||
|
return nil, http.ErrNoCookie
|
||||||
|
}
|
||||||
|
encryptedToken, err := jwt.ParseSignedAndEncrypted(cookie.Value,
|
||||||
|
[]jose.KeyAlgorithm{jose.DIRECT},
|
||||||
|
[]jose.ContentEncryption{jose.A256GCM},
|
||||||
|
[]jose.SignatureAlgorithm{jose.EdDSA},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
signedToken, err := encryptedToken.Decrypt(d.cookieKey())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var i Token
|
||||||
|
err = signedToken.Claims(d.State.PublicKey(), &i)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||||
|
return nil, ErrTokenExpired
|
||||||
|
}
|
||||||
|
if i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||||
|
return nil, errors.New("token not valid yet")
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.State, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) issueChallengeState(until time.Time) (string, error) {
|
||||||
|
signer, err := jose.NewSigner(jose.SigningKey{
|
||||||
|
Algorithm: jose.EdDSA,
|
||||||
|
Key: d.State.PrivateKey(),
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypter, err := jose.NewEncrypter(jose.A256GCM, jose.Recipient{
|
||||||
|
Algorithm: jose.DIRECT,
|
||||||
|
Key: d.cookieKey(),
|
||||||
|
}, (&jose.EncrypterOptions{
|
||||||
|
Compression: jose.DEFLATE,
|
||||||
|
}).WithContentType("JWT"))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.SignedAndEncrypted(signer, encrypter).Claims(Token{
|
||||||
|
State: d.ChallengeMap,
|
||||||
|
Expiry: jwt.NumericDate(until.Unix()),
|
||||||
|
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
|
||||||
|
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
||||||
|
}).Serialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) cookieKey() []byte {
|
||||||
|
sum := sha256.New()
|
||||||
|
sum.Write([]byte(d.r.Host))
|
||||||
|
sum.Write([]byte{0})
|
||||||
|
sum.Write(d.NetworkPrefix().AsSlice())
|
||||||
|
sum.Write([]byte{0})
|
||||||
|
sum.Write(d.State.PrivateKey())
|
||||||
|
sum.Write([]byte{0})
|
||||||
|
// version/compressor
|
||||||
|
sum.Write([]byte("1.0/DEFLATE"))
|
||||||
|
sum.Write([]byte{0})
|
||||||
|
|
||||||
|
return sum.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *RequestData) cookieHostKey() []byte {
|
||||||
|
sum := sha256.New()
|
||||||
|
sum.Write([]byte(d.r.Host))
|
||||||
|
sum.Write([]byte{0})
|
||||||
|
sum.Write(d.NetworkPrefix().AsSlice())
|
||||||
|
sum.Write([]byte{0})
|
||||||
|
|
||||||
|
return sum.Sum(nil)[:6]
|
||||||
|
}
|
||||||
|
|||||||
@@ -125,18 +125,10 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
|||||||
}
|
}
|
||||||
|
|
||||||
if result.Bad() {
|
if result.Bad() {
|
||||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, false)
|
data.IssueChallengeToken(reg, key, nil, expiry, false)
|
||||||
if err != nil {
|
|
||||||
return challenge.VerifyResultFail
|
|
||||||
}
|
|
||||||
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
|
||||||
return challenge.VerifyResultNotOK
|
return challenge.VerifyResultNotOK
|
||||||
} else {
|
} else {
|
||||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
|
data.IssueChallengeToken(reg, key, nil, expiry, true)
|
||||||
if err != nil {
|
|
||||||
return challenge.VerifyResultFail
|
|
||||||
}
|
|
||||||
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
|
||||||
return challenge.VerifyResultOK
|
return challenge.VerifyResultOK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,8 @@ func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData,
|
|||||||
}
|
}
|
||||||
reqUri.RawQuery = q.Encode()
|
reqUri.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
data.ResponseHeaders(w)
|
||||||
|
|
||||||
http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect)
|
http.Redirect(w, r, reqUri.String(), http.StatusTemporaryRedirect)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -147,6 +149,7 @@ func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData,
|
|||||||
state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
|
state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
data.ResponseHeaders(w)
|
||||||
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,18 +178,12 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if !verifyResult.Ok() {
|
} else if !verifyResult.Ok() {
|
||||||
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
|
|
||||||
state.ChallengeFailed(r, reg, nil, redirect, nil)
|
state.ChallengeFailed(r, reg, nil, redirect, nil)
|
||||||
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
challengeToken, err := reg.IssueChallengeToken(state.PrivateKey(), key, []byte(token), expiration, true)
|
data.IssueChallengeToken(reg, key, []byte(token), expiration, true)
|
||||||
if err != nil {
|
|
||||||
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
|
|
||||||
} else {
|
|
||||||
utils.SetCookie(data.CookiePrefix+reg.Name, challengeToken, expiration, w, r)
|
|
||||||
}
|
|
||||||
data.ChallengeVerify[reg.id] = verifyResult
|
data.ChallengeVerify[reg.id] = verifyResult
|
||||||
state.ChallengePassed(r, reg, redirect, nil)
|
state.ChallengePassed(r, reg, redirect, nil)
|
||||||
|
|
||||||
@@ -194,7 +191,6 @@ func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFun
|
|||||||
return nil
|
return nil
|
||||||
}()
|
}()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ClearCookie(data.CookiePrefix+reg.Name, w, r)
|
|
||||||
state.ChallengeFailed(r, reg, err, redirect, nil)
|
state.ChallengeFailed(r, reg, err, redirect, nil)
|
||||||
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("access denied: error in challenge %s: %w", reg.Name, err), redirect)
|
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("access denied: error in challenge %s: %w", reg.Name, err), redirect)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"errors"
|
"errors"
|
||||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||||
"git.gammaspectra.live/git/go-away/utils"
|
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
"github.com/goccy/go-yaml/ast"
|
"github.com/goccy/go-yaml/ast"
|
||||||
"io"
|
"io"
|
||||||
@@ -140,18 +139,10 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
|||||||
data := challenge.RequestDataFromContext(r.Context())
|
data := challenge.RequestDataFromContext(r.Context())
|
||||||
|
|
||||||
if response.StatusCode != params.HttpCode {
|
if response.StatusCode != params.HttpCode {
|
||||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, false)
|
data.IssueChallengeToken(reg, key, sum, expiry, false)
|
||||||
if err != nil {
|
|
||||||
return challenge.VerifyResultFail
|
|
||||||
}
|
|
||||||
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
|
||||||
return challenge.VerifyResultNotOK
|
return challenge.VerifyResultNotOK
|
||||||
} else {
|
} else {
|
||||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, true)
|
data.IssueChallengeToken(reg, key, sum, expiry, true)
|
||||||
if err != nil {
|
|
||||||
return challenge.VerifyResultFail
|
|
||||||
}
|
|
||||||
utils.SetCookie(data.CookiePrefix+reg.Name, token, expiry, w, r)
|
|
||||||
return challenge.VerifyResultOK
|
return challenge.VerifyResultOK
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until ti
|
|||||||
hasher.Write([]byte{0})
|
hasher.Write([]byte{0})
|
||||||
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
||||||
hasher.Write([]byte{0})
|
hasher.Write([]byte{0})
|
||||||
hasher.Write(state.PublicKey())
|
hasher.Write(state.PrivateKeyFingerprint())
|
||||||
hasher.Write([]byte{0})
|
hasher.Write([]byte{0})
|
||||||
|
|
||||||
sum := Key(hasher.Sum(nil))
|
sum := Key(hasher.Sum(nil))
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ func FillRegistration(state challenge.StateInterface, reg *challenge.Registratio
|
|||||||
}
|
}
|
||||||
|
|
||||||
verifyResult, _ := verifier(key, []byte(token), r)
|
verifyResult, _ := verifier(key, []byte(token), r)
|
||||||
|
|
||||||
|
data.ResponseHeaders(w)
|
||||||
|
|
||||||
if !verifyResult.Ok() {
|
if !verifyResult.Ok() {
|
||||||
w.WriteHeader(http.StatusUnauthorized)
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,18 +1,12 @@
|
|||||||
package challenge
|
package challenge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
http_cel "codeberg.org/gone/http-cel"
|
http_cel "codeberg.org/gone/http-cel"
|
||||||
"crypto/ed25519"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||||
"github.com/go-jose/go-jose/v4"
|
|
||||||
"github.com/go-jose/go-jose/v4/jwt"
|
|
||||||
"github.com/goccy/go-yaml/ast"
|
"github.com/goccy/go-yaml/ast"
|
||||||
"github.com/google/cel-go/cel"
|
"github.com/google/cel-go/cel"
|
||||||
"io"
|
"io"
|
||||||
"math/rand/v2"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -145,104 +139,10 @@ type Registration struct {
|
|||||||
|
|
||||||
type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error)
|
type VerifyFunc func(key Key, token []byte, r *http.Request) (VerifyResult, error)
|
||||||
|
|
||||||
type Token struct {
|
|
||||||
Name string `json:"name"`
|
|
||||||
Key []byte `json:"key"`
|
|
||||||
Result []byte `json:"result,omitempty"`
|
|
||||||
Ok bool `json:"ok"`
|
|
||||||
|
|
||||||
Expiry jwt.NumericDate `json:"exp,omitempty"`
|
|
||||||
NotBefore jwt.NumericDate `json:"nbf,omitempty"`
|
|
||||||
IssuedAt jwt.NumericDate `json:"iat,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (reg Registration) Id() Id {
|
func (reg Registration) Id() Id {
|
||||||
return reg.id
|
return reg.id
|
||||||
}
|
}
|
||||||
|
|
||||||
func (reg Registration) IssueChallengeToken(privateKey ed25519.PrivateKey, key Key, result []byte, until time.Time, ok bool) (token string, err error) {
|
|
||||||
signer, err := jose.NewSigner(jose.SigningKey{
|
|
||||||
Algorithm: jose.EdDSA,
|
|
||||||
Key: privateKey,
|
|
||||||
}, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err = jwt.Signed(signer).Claims(Token{
|
|
||||||
Name: reg.Name,
|
|
||||||
Key: key[:],
|
|
||||||
Result: result,
|
|
||||||
Ok: ok,
|
|
||||||
Expiry: jwt.NumericDate(until.Unix()),
|
|
||||||
NotBefore: jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix()),
|
|
||||||
IssuedAt: jwt.NumericDate(time.Now().UTC().Unix()),
|
|
||||||
}).Serialize()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return token, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
|
||||||
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
|
||||||
var ErrTokenExpired = errors.New("token: expired")
|
|
||||||
|
|
||||||
func (reg Registration) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey Key, r *http.Request) (VerifyResult, VerifyState, error) {
|
|
||||||
cookie, err := r.Cookie(RequestDataFromContext(r.Context()).CookiePrefix + reg.Name)
|
|
||||||
if err != nil {
|
|
||||||
return VerifyResultNone, VerifyStateNone, err
|
|
||||||
}
|
|
||||||
if cookie == nil {
|
|
||||||
return VerifyResultNone, VerifyStateNone, http.ErrNoCookie
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
|
|
||||||
if err != nil {
|
|
||||||
return VerifyResultFail, VerifyStateNone, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var i Token
|
|
||||||
err = token.Claims(publicKey, &i)
|
|
||||||
if err != nil {
|
|
||||||
return VerifyResultFail, VerifyStateNone, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if i.Name != reg.Name {
|
|
||||||
return VerifyResultFail, VerifyStateNone, errors.New("token invalid name")
|
|
||||||
}
|
|
||||||
if i.Expiry.Time().Compare(time.Now()) < 0 {
|
|
||||||
return VerifyResultFail, VerifyStateNone, ErrTokenExpired
|
|
||||||
}
|
|
||||||
if i.NotBefore.Time().Compare(time.Now()) > 0 {
|
|
||||||
return VerifyResultFail, VerifyStateNone, errors.New("token not valid yet")
|
|
||||||
}
|
|
||||||
|
|
||||||
if bytes.Compare(expectedKey[:], i.Key) != 0 {
|
|
||||||
return VerifyResultFail, VerifyStateNone, ErrVerifyKeyMismatch
|
|
||||||
}
|
|
||||||
|
|
||||||
if reg.Verify != nil {
|
|
||||||
if rand.Float64() < reg.VerifyProbability {
|
|
||||||
// random spot check
|
|
||||||
if ok, err := reg.Verify(expectedKey, i.Result, r); err != nil {
|
|
||||||
return VerifyResultFail, VerifyStateFull, err
|
|
||||||
} else if ok == VerifyResultNotOK {
|
|
||||||
return VerifyResultNotOK, VerifyStateFull, nil
|
|
||||||
} else if !ok.Ok() {
|
|
||||||
return ok, VerifyStateFull, ErrVerifyVerifyMismatch
|
|
||||||
} else {
|
|
||||||
return ok, VerifyStateFull, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !i.Ok {
|
|
||||||
return VerifyResultNotOK, VerifyStateBrief, nil
|
|
||||||
}
|
|
||||||
return VerifyResultOK, VerifyStateBrief, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
|
type FillRegistration func(state StateInterface, reg *Registration, parameters ast.Node) error
|
||||||
|
|
||||||
var Runtimes = make(map[string]FillRegistration)
|
var Runtimes = make(map[string]FillRegistration)
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Regis
|
|||||||
//TODO: add other types inside css that need to be loaded!
|
//TODO: add other types inside css that need to be loaded!
|
||||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
w.Header().Set("Content-Length", "0")
|
w.Header().Set("Content-Length", "0")
|
||||||
|
|
||||||
|
data.ResponseHeaders(w)
|
||||||
|
|
||||||
if !verifyResult.Ok() {
|
if !verifyResult.Ok() {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registrat
|
|||||||
//TODO: log
|
//TODO: log
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
|
data.ResponseHeaders(w)
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
err = scriptTemplate.Execute(w, map[string]any{
|
err = scriptTemplate.Execute(w, map[string]any{
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ type StateInterface interface {
|
|||||||
RegisterCondition(operator string, conditions ...string) (cel.Program, error)
|
RegisterCondition(operator string, conditions ...string) (cel.Program, error)
|
||||||
|
|
||||||
Client() *http.Client
|
Client() *http.Client
|
||||||
|
PrivateKeyFingerprint() []byte
|
||||||
PrivateKey() ed25519.PrivateKey
|
PrivateKey() ed25519.PrivateKey
|
||||||
PublicKey() ed25519.PublicKey
|
PublicKey() ed25519.PublicKey
|
||||||
|
|
||||||
|
|||||||
@@ -267,10 +267,13 @@ func (state *State) handleRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
cookies := r.Cookies()
|
cookies := r.Cookies()
|
||||||
r.Header.Del("Cookie")
|
r.Header.Del("Cookie")
|
||||||
for _, c := range cookies {
|
for _, c := range cookies {
|
||||||
if !strings.HasPrefix(c.Name, utils.CookiePrefix) {
|
if !strings.HasPrefix(c.Name, utils.DefaultCookiePrefix) {
|
||||||
r.AddCookie(c)
|
r.AddCookie(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// set response headers
|
||||||
|
data.ResponseHeaders(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, rule := range state.rules {
|
for _, rule := range state.rules {
|
||||||
@@ -323,7 +326,5 @@ func (state *State) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
data.EvaluateChallenges(w, r)
|
data.EvaluateChallenges(w, r)
|
||||||
|
|
||||||
data.ResponseHeaders(w.Header())
|
|
||||||
|
|
||||||
state.Mux.ServeHTTP(w, r)
|
state.Mux.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ func (state *State) PrivateKey() ed25519.PrivateKey {
|
|||||||
return state.privateKey
|
return state.privateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (state *State) PrivateKeyFingerprint() []byte {
|
||||||
|
return state.privateKeyFingerprint
|
||||||
|
}
|
||||||
|
|
||||||
func (state *State) PublicKey() ed25519.PublicKey {
|
func (state *State) PublicKey() ed25519.PublicKey {
|
||||||
return state.publicKey
|
return state.publicKey
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ type RuleState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) {
|
func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strings.Replacer, parent *RuleState) (RuleState, error) {
|
||||||
fp := sha256.Sum256(state.PrivateKey())
|
|
||||||
hasher := sha256.New()
|
hasher := sha256.New()
|
||||||
if parent != nil {
|
if parent != nil {
|
||||||
hasher.Write([]byte(parent.Name))
|
hasher.Write([]byte(parent.Name))
|
||||||
@@ -38,7 +37,7 @@ func NewRuleState(state challenge.StateInterface, r policy.Rule, replacer *strin
|
|||||||
}
|
}
|
||||||
hasher.Write([]byte(r.Name))
|
hasher.Write([]byte(r.Name))
|
||||||
hasher.Write([]byte{0})
|
hasher.Write([]byte{0})
|
||||||
hasher.Write(fp[:])
|
hasher.Write(state.PrivateKeyFingerprint())
|
||||||
sum := hasher.Sum(nil)
|
sum := hasher.Sum(nil)
|
||||||
|
|
||||||
rule := RuleState{
|
rule := RuleState{
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ type State struct {
|
|||||||
|
|
||||||
publicKey ed25519.PublicKey
|
publicKey ed25519.PublicKey
|
||||||
privateKey ed25519.PrivateKey
|
privateKey ed25519.PrivateKey
|
||||||
|
privateKeyFingerprint []byte
|
||||||
|
|
||||||
opt settings.Settings
|
opt settings.Settings
|
||||||
settings policy.StateSettings
|
settings policy.StateSettings
|
||||||
@@ -101,6 +102,9 @@ func NewState(p policy.Policy, opt settings.Settings, settings policy.StateSetti
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fp := sha256.Sum256(state.privateKey)
|
||||||
|
state.privateKeyFingerprint = fp[:]
|
||||||
|
|
||||||
if templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"] == nil {
|
if templates["challenge-"+state.opt.ChallengeTemplate+".gohtml"] == nil {
|
||||||
|
|
||||||
if data, err := os.ReadFile(state.opt.ChallengeTemplate); err == nil && len(data) > 0 {
|
if data, err := os.ReadFile(state.opt.ChallengeTemplate); err == nil && len(data) > 0 {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ func (state *State) ChallengePage(w http.ResponseWriter, r *http.Request, status
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||||
} else {
|
} else {
|
||||||
|
data.ResponseHeaders(w)
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_, _ = w.Write(buf.Bytes())
|
_, _ = w.Write(buf.Bytes())
|
||||||
}
|
}
|
||||||
@@ -141,6 +142,7 @@ func (state *State) ErrorPage(w http.ResponseWriter, r *http.Request, status int
|
|||||||
// nested errors!
|
// nested errors!
|
||||||
panic(err2)
|
panic(err2)
|
||||||
} else {
|
} else {
|
||||||
|
data.ResponseHeaders(w)
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_, _ = w.Write(buf.Bytes())
|
_, _ = w.Write(buf.Bytes())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var CookiePrefix = ".go-away-"
|
var DefaultCookiePrefix = ".go-away-"
|
||||||
|
|
||||||
// getValidHost Gets a valid host for an http.Cookie Domain field
|
// getValidHost Gets a valid host for an http.Cookie Domain field
|
||||||
// TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521
|
// TODO: bug: does not work with IPv6, see https://github.com/golang/go/issues/65521
|
||||||
|
|||||||
Reference in New Issue
Block a user