Condition, rules, state and action refactor / rewrite
Add nested rules Add backend action, allow wildcard in backends Remove poison from tree, update README with action table Allow defining pass/fail actions on challenge, Remove redirect/referer parameters on backend pass Set challenge cookie tied to host Rewrite DNSBL condition into a challenge Allow passing an arbitrary path for assets to js challenges Optimize programs exhaustively on compilation Activation instead of map for CEL context, faster map access, new network override Return valid host on cookie setting in case Host is an IP address. bug: does not work with IPv6, see https://github.com/golang/go/issues/65521 Apply TLS fingerprinter on GetConfigForClient instead of GetCertificate Cleanup go-away cookies before passing to backend Code action for specifically replying with an HTTP code
This commit is contained in:
committed by
WeebDataHoarder
parent
1c7fe1bed9
commit
ead41055ca
47
lib/challenge/awaiter.go
Normal file
47
lib/challenge/awaiter.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/alphadose/haxmap"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
type awaiterCallback func(result VerifyResult)
|
||||
|
||||
type Awaiter[K ~string | ~int64 | ~uint64] haxmap.Map[K, awaiterCallback]
|
||||
|
||||
func NewAwaiter[T ~string | ~int64 | ~uint64]() *Awaiter[T] {
|
||||
return (*Awaiter[T])(haxmap.New[T, awaiterCallback]())
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) Await(key T, ctx context.Context) VerifyResult {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
var result atomic.Int64
|
||||
|
||||
a.m().Set(key, func(receivedResult VerifyResult) {
|
||||
result.Store(int64(receivedResult))
|
||||
cancel()
|
||||
})
|
||||
// cleanup
|
||||
defer a.m().Del(key)
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
return VerifyResult(result.Load())
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) Solve(key T, result VerifyResult) {
|
||||
if f, ok := a.m().GetAndDel(key); ok && f != nil {
|
||||
f(result)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) m() *haxmap.Map[T, awaiterCallback] {
|
||||
return (*haxmap.Map[T, awaiterCallback])(a)
|
||||
}
|
||||
|
||||
func (a *Awaiter[T]) Close() error {
|
||||
return nil
|
||||
}
|
||||
38
lib/challenge/cookie/cookie.go
Normal file
38
lib/challenge/cookie/cookie.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package cookie
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "cookie"
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
|
||||
uri, err := challenge.RedirectUrl(r, reg)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
http.Redirect(w, r, uri.String(), http.StatusTemporaryRedirect)
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
171
lib/challenge/data.go
Normal file
171
lib/challenge/data.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/google/cel-go/cel"
|
||||
"github.com/google/cel-go/common/types"
|
||||
"github.com/google/cel-go/common/types/traits"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"time"
|
||||
)
|
||||
|
||||
type requestDataContextKey struct {
|
||||
}
|
||||
|
||||
func RequestDataFromContext(ctx context.Context) *RequestData {
|
||||
return ctx.Value(requestDataContextKey{}).(*RequestData)
|
||||
}
|
||||
|
||||
type RequestId [16]byte
|
||||
|
||||
func (id RequestId) String() string {
|
||||
return hex.EncodeToString(id[:])
|
||||
}
|
||||
|
||||
type RequestData struct {
|
||||
Id RequestId
|
||||
Time time.Time
|
||||
ChallengeVerify map[Id]VerifyResult
|
||||
ChallengeState map[Id]VerifyState
|
||||
RemoteAddress net.IP
|
||||
State StateInterface
|
||||
|
||||
r *http.Request
|
||||
|
||||
fp map[string]string
|
||||
header traits.Mapper
|
||||
query traits.Mapper
|
||||
}
|
||||
|
||||
func CreateRequestData(r *http.Request, state StateInterface) (*http.Request, *RequestData) {
|
||||
|
||||
var data RequestData
|
||||
// generate random id, todo: is this fast?
|
||||
_, _ = rand.Read(data.Id[:])
|
||||
data.RemoteAddress = utils.GetRequestAddress(r, state.Settings().ClientIpHeader)
|
||||
data.ChallengeVerify = make(map[Id]VerifyResult, len(state.GetChallenges()))
|
||||
data.ChallengeState = make(map[Id]VerifyState, len(state.GetChallenges()))
|
||||
data.Time = time.Now().UTC()
|
||||
data.State = state
|
||||
data.r = r
|
||||
|
||||
data.fp = make(map[string]string, 2)
|
||||
|
||||
if fp := utils.GetTLSFingerprint(r); fp != nil {
|
||||
if ja3nPtr := fp.JA3N(); ja3nPtr != nil {
|
||||
ja3n := ja3nPtr.String()
|
||||
data.fp["ja3n"] = ja3n
|
||||
r.Header.Set("X-TLS-Fingerprint-JA3N", ja3n)
|
||||
}
|
||||
if ja4Ptr := fp.JA4(); ja4Ptr != nil {
|
||||
ja4 := ja4Ptr.String()
|
||||
data.fp["ja4"] = ja4
|
||||
r.Header.Set("X-TLS-Fingerprint-JA4", ja4)
|
||||
}
|
||||
}
|
||||
|
||||
data.query = condition.NewValuesMap(r.URL.Query())
|
||||
data.header = condition.NewMIMEMap(textproto.MIMEHeader(r.Header))
|
||||
|
||||
r = r.WithContext(context.WithValue(r.Context(), requestDataContextKey{}, &data))
|
||||
|
||||
return r, &data
|
||||
}
|
||||
|
||||
func (d *RequestData) ResolveName(name string) (any, bool) {
|
||||
switch name {
|
||||
case "host":
|
||||
return d.r.Host, true
|
||||
case "method":
|
||||
return d.r.Method, true
|
||||
case "remoteAddress":
|
||||
return d.RemoteAddress, true
|
||||
case "userAgent":
|
||||
return d.r.UserAgent(), true
|
||||
case "path":
|
||||
return d.r.URL.Path, true
|
||||
case "query":
|
||||
return d.query, true
|
||||
case "headers":
|
||||
return d.header, true
|
||||
case "fp":
|
||||
return d.fp, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func (d *RequestData) Parent() cel.Activation {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *RequestData) EvaluateChallenges(w http.ResponseWriter, r *http.Request) {
|
||||
for _, reg := range d.State.GetChallenges() {
|
||||
key := GetChallengeKeyForRequest(d.State, reg, d.Expiration(reg.Duration), r)
|
||||
verifyResult, verifyState, err := reg.VerifyChallengeToken(d.State.PublicKey(), key, r)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
// clear invalid cookie
|
||||
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
|
||||
}
|
||||
|
||||
// prevent evaluating the challenge if not solved
|
||||
if !verifyResult.Ok() && reg.Condition != nil {
|
||||
out, _, err := reg.Condition.Eval(d)
|
||||
// verify eligibility
|
||||
if err != nil {
|
||||
d.State.Logger(r).Error(err.Error(), "challenge", reg.Name)
|
||||
} else if out != nil && out.Type() == types.BoolType {
|
||||
if out.Equal(types.True) != types.True {
|
||||
// skip challenge match due to precondition!
|
||||
verifyResult = VerifyResultSkip
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
d.ChallengeVerify[reg.Id()] = verifyResult
|
||||
d.ChallengeState[reg.Id()] = verifyState
|
||||
}
|
||||
|
||||
if d.State.Settings().BackendIpHeader != "" {
|
||||
if d.State.Settings().ClientIpHeader != "" {
|
||||
r.Header.Del(d.State.Settings().ClientIpHeader)
|
||||
}
|
||||
r.Header.Set(d.State.Settings().BackendIpHeader, d.RemoteAddress.String())
|
||||
}
|
||||
|
||||
// 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("Critical-CH", "Sec-CH-UA, Sec-CH-UA-Platform")
|
||||
}
|
||||
|
||||
func (d *RequestData) Expiration(duration time.Duration) time.Time {
|
||||
return d.Time.Add(duration).Round(duration)
|
||||
}
|
||||
|
||||
func (d *RequestData) HasValidChallenge(id Id) bool {
|
||||
return d.ChallengeVerify[id].Ok()
|
||||
}
|
||||
|
||||
func (d *RequestData) Headers(headers http.Header) {
|
||||
headers.Set("X-Away-Id", d.Id.String())
|
||||
|
||||
for id, result := range d.ChallengeVerify {
|
||||
if result.Ok() {
|
||||
c, ok := d.State.GetChallenge(id)
|
||||
if !ok {
|
||||
panic("challenge not found")
|
||||
}
|
||||
|
||||
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-Result", c.Name), result.String())
|
||||
headers.Set(fmt.Sprintf("X-Away-Challenge-%s-State", c.Name), d.ChallengeState[id].String())
|
||||
}
|
||||
}
|
||||
}
|
||||
149
lib/challenge/dnsbl/dnsbl.go
Normal file
149
lib/challenge/dnsbl/dnsbl.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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/ast"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "dnsbl"
|
||||
|
||||
type Parameters struct {
|
||||
VerifyProbability float64 `yaml:"verify-probability"`
|
||||
Host string `yaml:"dnsbl-host"`
|
||||
Timeout time.Duration `yaml:"dnsbl-timeout"`
|
||||
Decay time.Duration `yaml:"dnsbl-decay"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
VerifyProbability: 0.10,
|
||||
Timeout: time.Second * 1,
|
||||
Decay: time.Hour * 1,
|
||||
Host: "dnsbl.dronebl.org",
|
||||
}
|
||||
|
||||
func lookup(ctx context.Context, decay, timeout time.Duration, dnsbl *utils.DNSBL, decayMap *utils.DecayMap[[net.IPv6len]byte, utils.DNSBLResponse], ip net.IP) (utils.DNSBLResponse, error) {
|
||||
var key [net.IPv6len]byte
|
||||
copy(key[:], ip.To16())
|
||||
|
||||
result, ok := decayMap.Get(key)
|
||||
if ok {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
result, err := dnsbl.Lookup(ctx, ip)
|
||||
if err != nil {
|
||||
|
||||
}
|
||||
decayMap.Set(key, result, decay)
|
||||
|
||||
return result, err
|
||||
}
|
||||
|
||||
type closer chan struct{}
|
||||
|
||||
func (c closer) Close() error {
|
||||
select {
|
||||
case <-c:
|
||||
default:
|
||||
close(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Host == "" {
|
||||
return errors.New("empty host")
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
if params.VerifyProbability <= 0 {
|
||||
//20% default
|
||||
params.VerifyProbability = 0.20
|
||||
} else if params.VerifyProbability > 1.0 {
|
||||
params.VerifyProbability = 1.0
|
||||
}
|
||||
reg.VerifyProbability = params.VerifyProbability
|
||||
|
||||
decayMap := utils.NewDecayMap[[net.IPv6len]byte, utils.DNSBLResponse]()
|
||||
|
||||
dnsbl := utils.NewDNSBL(params.Host, &net.Resolver{
|
||||
PreferGo: true,
|
||||
})
|
||||
|
||||
ob := make(closer)
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(params.Timeout / 3)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
decayMap.Decay()
|
||||
case <-ob:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// allow freeing the ticker/decay map
|
||||
reg.Object = ob
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
|
||||
result, err := lookup(r.Context(), params.Decay, params.Timeout, dnsbl, decayMap, data.RemoteAddress)
|
||||
if err != nil {
|
||||
data.State.Logger(r).Debug("dnsbl lookup failed", "address", data.RemoteAddress.String(), "result", result, "err", err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
if result.Bad() {
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, false)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultNotOK
|
||||
} else {
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, nil, expiry, true)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultOK
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
164
lib/challenge/helper.go
Normal file
164
lib/challenge/helper.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
func NewKeyVerifier() (verify VerifyFunc, issue func(key Key) string) {
|
||||
return func(key Key, token []byte, r *http.Request) (VerifyResult, error) {
|
||||
expectedKey, err := hex.DecodeString(string(token))
|
||||
if err != nil {
|
||||
return VerifyResultFail, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(key[:], expectedKey) == 1 {
|
||||
return VerifyResultOK, nil
|
||||
}
|
||||
return VerifyResultFail, errors.New("invalid token")
|
||||
}, func(key Key) string {
|
||||
return hex.EncodeToString(key[:])
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
QueryArgPrefix = "__goaway"
|
||||
QueryArgReferer = QueryArgPrefix + "_referer"
|
||||
QueryArgRedirect = QueryArgPrefix + "_redirect"
|
||||
QueryArgRequestId = QueryArgPrefix + "_id"
|
||||
QueryArgChallenge = QueryArgPrefix + "_challenge"
|
||||
QueryArgToken = QueryArgPrefix + "_token"
|
||||
)
|
||||
|
||||
const MakeChallengeUrlSuffix = "/make-challenge"
|
||||
const VerifyChallengeUrlSuffix = "/verify-challenge"
|
||||
|
||||
func GetVerifyInformation(r *http.Request, reg *Registration) (requestId RequestId, redirect, token string, err error) {
|
||||
|
||||
if r.FormValue(QueryArgChallenge) != reg.Name {
|
||||
return RequestId{}, "", "", fmt.Errorf("unexpected challenge: got %s", r.FormValue(QueryArgChallenge))
|
||||
}
|
||||
|
||||
requestIdHex := r.FormValue(QueryArgRequestId)
|
||||
|
||||
if len(requestId) != hex.DecodedLen(len(requestIdHex)) {
|
||||
return RequestId{}, "", "", errors.New("invalid request id")
|
||||
}
|
||||
n, err := hex.Decode(requestId[:], []byte(requestIdHex))
|
||||
if err != nil {
|
||||
return RequestId{}, "", "", err
|
||||
} else if n != len(requestId) {
|
||||
return RequestId{}, "", "", errors.New("invalid request id")
|
||||
}
|
||||
|
||||
token = r.FormValue(QueryArgToken)
|
||||
redirect, err = utils.EnsureNoOpenRedirect(r.FormValue(QueryArgRedirect))
|
||||
if err != nil {
|
||||
return RequestId{}, "", "", err
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func VerifyUrl(r *http.Request, reg *Registration, token string) (*url.URL, error) {
|
||||
|
||||
redirectUrl, err := RedirectUrl(r, reg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
uri := new(url.URL)
|
||||
uri.Path = reg.Path + VerifyChallengeUrlSuffix
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
values := uri.Query()
|
||||
values.Set(QueryArgRequestId, data.Id.String())
|
||||
values.Set(QueryArgRedirect, redirectUrl.String())
|
||||
values.Set(QueryArgToken, token)
|
||||
values.Set(QueryArgChallenge, reg.Name)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
func RedirectUrl(r *http.Request, reg *Registration) (*url.URL, error) {
|
||||
uri, err := url.ParseRequestURI(r.URL.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := RequestDataFromContext(r.Context())
|
||||
values := uri.Query()
|
||||
values.Set(QueryArgRequestId, data.Id.String())
|
||||
values.Set(QueryArgReferer, r.Referer())
|
||||
values.Set(QueryArgChallenge, reg.Name)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
return uri, nil
|
||||
}
|
||||
|
||||
func VerifyHandlerChallengeResponseFunc(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string) {
|
||||
if err != nil {
|
||||
state.ErrorPage(w, r, http.StatusBadRequest, err, redirect)
|
||||
return
|
||||
} else if !verifyResult.Ok() {
|
||||
state.ErrorPage(w, r, http.StatusForbidden, fmt.Errorf("access denied: failed challenge"), redirect)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, redirect, http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func VerifyHandlerFunc(state StateInterface, reg *Registration, verify VerifyFunc, responseFunc func(state StateInterface, data *RequestData, w http.ResponseWriter, r *http.Request, verifyResult VerifyResult, err error, redirect string)) http.HandlerFunc {
|
||||
if verify == nil {
|
||||
verify = reg.Verify
|
||||
}
|
||||
if responseFunc == nil {
|
||||
responseFunc = VerifyHandlerChallengeResponseFunc
|
||||
}
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
requestId, redirect, token, err := GetVerifyInformation(r, reg)
|
||||
if err != nil {
|
||||
state.ChallengeFailed(r, reg, err, "", nil)
|
||||
responseFunc(state, data, w, r, VerifyResultFail, fmt.Errorf("internal error: %w", err), "")
|
||||
return
|
||||
}
|
||||
data.Id = requestId
|
||||
|
||||
err = func() (err error) {
|
||||
expiration := data.Expiration(reg.Duration)
|
||||
key := GetChallengeKeyForRequest(state, reg, expiration, r)
|
||||
|
||||
verifyResult, err := verify(key, []byte(token), r)
|
||||
if err != nil {
|
||||
return err
|
||||
} else if !verifyResult.Ok() {
|
||||
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
|
||||
state.ChallengeFailed(r, reg, nil, redirect, nil)
|
||||
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||
return nil
|
||||
}
|
||||
|
||||
challengeToken, err := reg.IssueChallengeToken(state.PrivateKey(), key, []byte(token), expiration, true)
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
|
||||
} else {
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, challengeToken, expiration, w, r)
|
||||
}
|
||||
data.ChallengeVerify[reg.id] = verifyResult
|
||||
state.ChallengePassed(r, reg, redirect, nil)
|
||||
|
||||
responseFunc(state, data, w, r, verifyResult, nil, redirect)
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
utils.ClearCookie(utils.CookiePrefix+reg.Name, w, r)
|
||||
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)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
158
lib/challenge/http/http.go
Normal file
158
lib/challenge/http/http.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"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/ast"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "http"
|
||||
|
||||
type Parameters struct {
|
||||
VerifyProbability float64 `yaml:"verify-probability"`
|
||||
|
||||
HttpMethod string `yaml:"http-method"`
|
||||
HttpCode int `yaml:"http-code"`
|
||||
HttpCookie string `yaml:"http-cookie"`
|
||||
Url string `yaml:"http-url"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
VerifyProbability: 0.20,
|
||||
HttpMethod: http.MethodGet,
|
||||
HttpCode: http.StatusOK,
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if params.Url == "" {
|
||||
return errors.New("empty url")
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
bindAuthValue := func(key challenge.Key, r *http.Request) ([]byte, error) {
|
||||
var cookieValue string
|
||||
if cookie, err := r.Cookie(params.HttpCookie); err != nil || cookie == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return nil, http.ErrNoCookie
|
||||
} else {
|
||||
cookieValue = cookie.Value
|
||||
}
|
||||
|
||||
// bind hash of cookie contents
|
||||
sum := sha256.New()
|
||||
sum.Write([]byte(cookieValue))
|
||||
sum.Write([]byte{0})
|
||||
sum.Write(key[:])
|
||||
return sum.Sum(nil), nil
|
||||
}
|
||||
|
||||
if params.VerifyProbability <= 0 {
|
||||
//20% default
|
||||
params.VerifyProbability = 0.20
|
||||
} else if params.VerifyProbability > 1.0 {
|
||||
params.VerifyProbability = 1.0
|
||||
}
|
||||
reg.VerifyProbability = params.VerifyProbability
|
||||
|
||||
if params.HttpCookie != "" {
|
||||
// re-verify the cookie value
|
||||
// TODO: configure to verify with backend
|
||||
reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
|
||||
sum, err := bindAuthValue(key, r)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail, err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(sum, token) == 1 {
|
||||
return challenge.VerifyResultOK, nil
|
||||
}
|
||||
return challenge.VerifyResultFail, errors.New("invalid cookie value")
|
||||
}
|
||||
}
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
var sum []byte
|
||||
if params.HttpCookie != "" {
|
||||
if c, err := r.Cookie(params.HttpCookie); err != nil || c == nil {
|
||||
// skip check if we don't have cookie or it's expired
|
||||
return challenge.VerifyResultSkip
|
||||
} else {
|
||||
sum, err = bindAuthValue(key, r)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(params.HttpMethod, params.Url, nil)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
var excludeHeaders = []string{"Host", "Content-Length"}
|
||||
for k, v := range r.Header {
|
||||
if slices.Contains(excludeHeaders, k) {
|
||||
// skip these parameters
|
||||
continue
|
||||
}
|
||||
request.Header[k] = v
|
||||
}
|
||||
// set id
|
||||
request.Header.Set("X-Away-Id", challenge.RequestDataFromContext(r.Context()).Id.String())
|
||||
|
||||
// set request info in X headers
|
||||
request.Header.Set("X-Away-Host", r.Host)
|
||||
request.Header.Set("X-Away-Path", r.URL.Path)
|
||||
request.Header.Set("X-Away-Query", r.URL.RawQuery)
|
||||
|
||||
response, err := state.Client().Do(request)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
defer response.Body.Close()
|
||||
defer io.Copy(io.Discard, response.Body)
|
||||
|
||||
if response.StatusCode != params.HttpCode {
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, false)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultNotOK
|
||||
} else {
|
||||
token, err := reg.IssueChallengeToken(state.PrivateKey(), key, sum, expiry, true)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
utils.SetCookie(utils.CookiePrefix+reg.Name, token, expiry, w, r)
|
||||
return challenge.VerifyResultOK
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
80
lib/challenge/key.go
Normal file
80
lib/challenge/key.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Key [KeySize]byte
|
||||
|
||||
const KeySize = sha256.Size
|
||||
|
||||
func (k *Key) Set(flags KeyFlags) {
|
||||
(*k)[0] |= uint8(flags)
|
||||
}
|
||||
func (k *Key) Get(flags KeyFlags) KeyFlags {
|
||||
return KeyFlags((*k)[0] & uint8(flags))
|
||||
}
|
||||
func (k *Key) Unset(flags KeyFlags) {
|
||||
(*k)[0] = (*k)[0] & ^(uint8(flags))
|
||||
}
|
||||
|
||||
type KeyFlags uint8
|
||||
|
||||
const (
|
||||
KeyFlagIsIPv4 = KeyFlags(1 << iota)
|
||||
)
|
||||
|
||||
func KeyFromString(s string) (Key, error) {
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return Key{}, err
|
||||
}
|
||||
if len(b) != KeySize {
|
||||
return Key{}, errors.New("invalid challenge key")
|
||||
}
|
||||
return Key(b), nil
|
||||
}
|
||||
|
||||
func GetChallengeKeyForRequest(state StateInterface, reg *Registration, until time.Time, r *http.Request) Key {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
address := data.RemoteAddress
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte("challenge\x00"))
|
||||
hasher.Write([]byte(reg.Name))
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(address.To16())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
// specific headers
|
||||
for _, k := range []string{
|
||||
"Accept-Language",
|
||||
// General browser information
|
||||
"User-Agent",
|
||||
// TODO: not sent in preload
|
||||
//"Sec-Ch-Ua",
|
||||
//"Sec-Ch-Ua-Platform",
|
||||
} {
|
||||
hasher.Write([]byte(r.Header.Get(k)))
|
||||
hasher.Write([]byte{0})
|
||||
}
|
||||
hasher.Write([]byte{0})
|
||||
_ = binary.Write(hasher, binary.LittleEndian, until.UTC().Unix())
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write(state.PublicKey())
|
||||
hasher.Write([]byte{0})
|
||||
|
||||
sum := Key(hasher.Sum(nil))
|
||||
|
||||
sum[0] = 0
|
||||
|
||||
if address.To4() != nil {
|
||||
// Is IPv4, mark
|
||||
sum.Set(KeyFlagIsIPv4)
|
||||
}
|
||||
return Key(sum)
|
||||
}
|
||||
128
lib/challenge/preload-link/preload-link.go
Normal file
128
lib/challenge/preload-link/preload-link.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package preload_link
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes[Key] = FillRegistration
|
||||
}
|
||||
|
||||
const Key = "preload-link"
|
||||
|
||||
type Parameters struct {
|
||||
Deadline time.Duration `yaml:"preload-early-hint-deadline"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
Deadline: time.Second * 3,
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
verifier, issuer := challenge.NewKeyVerifier()
|
||||
reg.Verify = verifier
|
||||
|
||||
reg.Class = challenge.ClassTransparent
|
||||
|
||||
ob := challenge.NewAwaiter[string]()
|
||||
|
||||
reg.Object = ob
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
// this only works on HTTP/2 and HTTP/3
|
||||
|
||||
if r.ProtoMajor < 2 {
|
||||
// this can happen if we are an upgraded request from HTTP/1.1 to HTTP/2 in H2C
|
||||
if _, ok := w.(http.Pusher); !ok {
|
||||
return challenge.VerifyResultSkip
|
||||
}
|
||||
}
|
||||
|
||||
issuerKey := issuer(key)
|
||||
|
||||
uri, err := challenge.VerifyUrl(r, reg, issuerKey)
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
// remove redirect args
|
||||
values := uri.Query()
|
||||
values.Del(challenge.QueryArgRedirect)
|
||||
uri.RawQuery = values.Encode()
|
||||
|
||||
// Redirect URI must be absolute to work
|
||||
uri.Scheme = utils.GetRequestScheme(r)
|
||||
uri.Host = r.Host
|
||||
|
||||
w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"preload\"; as=\"style\"; fetchpriority=high", uri.String()))
|
||||
defer func() {
|
||||
// remove old header so it won't show on response!
|
||||
w.Header().Del("Link")
|
||||
}()
|
||||
w.WriteHeader(http.StatusEarlyHints)
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), params.Deadline)
|
||||
defer cancel()
|
||||
if result := ob.Await(issuerKey, ctx); result.Ok() {
|
||||
// this should serve!
|
||||
return challenge.VerifyResultOK
|
||||
} else if result == challenge.VerifyResultNone {
|
||||
// we hit timeout
|
||||
return challenge.VerifyResultFail
|
||||
} else {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||
w.Header().Set("Content-Length", "0")
|
||||
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
|
||||
issuerKey := issuer(key)
|
||||
|
||||
_, _, token, err := challenge.GetVerifyInformation(r, reg)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
verifyResult, _ := verifier(key, []byte(token), r)
|
||||
if !verifyResult.Ok() {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
ob.Solve(issuerKey, verifyResult)
|
||||
if !verifyResult.Ok() {
|
||||
// also give data on other failure when mismatched
|
||||
ob.Solve(token, verifyResult)
|
||||
}
|
||||
})
|
||||
reg.Handler = mux
|
||||
|
||||
return nil
|
||||
}
|
||||
64
lib/challenge/refresh/refresh.go
Normal file
64
lib/challenge/refresh/refresh.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package refresh
|
||||
|
||||
import (
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes["refresh"] = FillRegistration
|
||||
}
|
||||
|
||||
type Parameters struct {
|
||||
Mode string `yaml:"refresh-mode"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
Mode: "header",
|
||||
}
|
||||
|
||||
func FillRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
verifier, issuer := challenge.NewKeyVerifier()
|
||||
reg.Verify = verifier
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
uri, err := challenge.VerifyUrl(r, reg, issuer(key))
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
if params.Mode == "meta" {
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"Meta": map[string]string{
|
||||
"refresh": "0; url=" + uri.String(),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
// self redirect!
|
||||
w.Header().Set("Refresh", "0; url="+uri.String())
|
||||
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, nil)
|
||||
}
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
252
lib/challenge/register.go
Normal file
252
lib/challenge/register.go
Normal file
@@ -0,0 +1,252 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/condition"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/go-jose/go-jose/v4/jwt"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"github.com/google/cel-go/cel"
|
||||
"io"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Register map[Id]*Registration
|
||||
|
||||
func (r Register) Get(id Id) (*Registration, bool) {
|
||||
c, ok := r[id]
|
||||
return c, ok
|
||||
}
|
||||
|
||||
func (r Register) GetByName(name string) (*Registration, Id, bool) {
|
||||
for id, c := range r {
|
||||
if c.Name == name {
|
||||
return c, id, true
|
||||
}
|
||||
}
|
||||
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
var idCounter Id
|
||||
|
||||
// DefaultDuration TODO: adjust
|
||||
const DefaultDuration = time.Hour * 24 * 7
|
||||
|
||||
func (r Register) Create(state StateInterface, name string, pol policy.Challenge, replacer *strings.Replacer) (*Registration, Id, error) {
|
||||
runtime, ok := Runtimes[pol.Runtime]
|
||||
if !ok {
|
||||
return nil, 0, fmt.Errorf("unknown challenge runtime %s", pol.Runtime)
|
||||
}
|
||||
|
||||
reg := &Registration{
|
||||
Name: name,
|
||||
Path: path.Join(state.UrlPath(), "challenge", name),
|
||||
Duration: pol.Duration,
|
||||
}
|
||||
|
||||
if reg.Duration == 0 {
|
||||
reg.Duration = DefaultDuration
|
||||
}
|
||||
|
||||
// allow nesting
|
||||
var conditions []string
|
||||
for _, cond := range pol.Conditions {
|
||||
if replacer != nil {
|
||||
cond = replacer.Replace(cond)
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
|
||||
if len(conditions) > 0 {
|
||||
ast, err := condition.FromStrings(state.ProgramEnv(), condition.OperatorOr, conditions...)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error compiling conditions: %v", err)
|
||||
}
|
||||
reg.Condition, err = condition.Program(state.ProgramEnv(), ast)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error compiling program: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, oldId, ok := r.GetByName(reg.Name); ok {
|
||||
reg.id = oldId
|
||||
} else {
|
||||
idCounter++
|
||||
reg.id = idCounter
|
||||
}
|
||||
|
||||
err := runtime(state, reg, pol.Parameters)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("error filling registration: %v", err)
|
||||
}
|
||||
r[reg.id] = reg
|
||||
return reg, reg.id, nil
|
||||
}
|
||||
|
||||
func (r Register) Add(c *Registration) Id {
|
||||
if _, oldId, ok := r.GetByName(c.Name); ok {
|
||||
c.id = oldId
|
||||
r[oldId] = c
|
||||
return oldId
|
||||
} else {
|
||||
idCounter++
|
||||
c.id = idCounter
|
||||
r[idCounter] = c
|
||||
return idCounter
|
||||
}
|
||||
}
|
||||
|
||||
type Registration struct {
|
||||
// id The assigned internal identifier
|
||||
id Id
|
||||
|
||||
// Name The unique name for this challenge
|
||||
Name string
|
||||
|
||||
// Class whether this challenge is transparent or otherwise
|
||||
Class Class
|
||||
|
||||
// Condition A CEL condition which is passed the same environment as general rules.
|
||||
// If nil, always true
|
||||
// If non-nil, must return true for this challenge to be allowed to be executed
|
||||
Condition cel.Program
|
||||
|
||||
// Path The url path that this challenge is hosted under for the Handler to be called.
|
||||
Path string
|
||||
|
||||
// Duration How long this challenge will be valid when passed
|
||||
Duration time.Duration
|
||||
|
||||
// Handler An HTTP handler for all requests coming on the Path
|
||||
// This handler will need to handle MakeChallengeUrlSuffix and VerifyChallengeUrlSuffix as well if needed
|
||||
// Recommended to use http.ServeMux
|
||||
Handler http.Handler
|
||||
|
||||
// Verify Verify an issued token
|
||||
Verify VerifyFunc
|
||||
VerifyProbability float64
|
||||
|
||||
// IssueChallenge Issues a challenge to a request.
|
||||
// If Class is ClassTransparent and VerifyResult is !VerifyResult.Ok(), continue with other challenges
|
||||
// TODO: have this return error as well
|
||||
IssueChallenge func(w http.ResponseWriter, r *http.Request, key Key, expiry time.Time) VerifyResult
|
||||
|
||||
// Object used to handle state or similar
|
||||
// Can be nil if no state is needed
|
||||
// If non-nil must implement io.Closer even if there's nothing to do
|
||||
Object io.Closer
|
||||
}
|
||||
|
||||
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 {
|
||||
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(utils.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
|
||||
|
||||
var Runtimes = make(map[string]FillRegistration)
|
||||
55
lib/challenge/resource-load/resource-load.go
Normal file
55
lib/challenge/resource-load/resource-load.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package resource_load
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes["resource-load"] = FillRegistrationHeader
|
||||
}
|
||||
|
||||
func FillRegistrationHeader(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
verifier, issuer := challenge.NewKeyVerifier()
|
||||
reg.Verify = verifier
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
uri, err := challenge.VerifyUrl(r, reg, issuer(key))
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail
|
||||
}
|
||||
|
||||
// self redirect!
|
||||
//TODO: adjust deadline
|
||||
w.Header().Set("Refresh", "2; url="+r.URL.String())
|
||||
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"HeaderTags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<link href=\"%s\" rel=\"stylesheet\" crossorigin=\"use-credentials\">", uri.String())),
|
||||
},
|
||||
})
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, func(state challenge.StateInterface, data *challenge.RequestData, w http.ResponseWriter, r *http.Request, verifyResult challenge.VerifyResult, err error, redirect string) {
|
||||
//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-Length", "0")
|
||||
if !verifyResult.Ok() {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
}))
|
||||
reg.Handler = mux
|
||||
|
||||
return nil
|
||||
}
|
||||
41
lib/challenge/script.go
Normal file
41
lib/challenge/script.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"net/http"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
//go:embed script.mjs
|
||||
var scriptData []byte
|
||||
|
||||
var scriptTemplate = template.Must(template.New("script.mjs").Parse(string(scriptData)))
|
||||
|
||||
func ServeChallengeScript(w http.ResponseWriter, r *http.Request, reg *Registration, params any, script string) {
|
||||
data := RequestDataFromContext(r.Context())
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
|
||||
|
||||
paramData, err := json.Marshal(params)
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
panic(err)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
err = scriptTemplate.Execute(w, map[string]any{
|
||||
"Id": data.Id.String(),
|
||||
"Path": reg.Path,
|
||||
"Parameters": paramData,
|
||||
"Random": utils.CacheBust(),
|
||||
"Challenge": reg.Name,
|
||||
"ChallengeScript": script,
|
||||
})
|
||||
if err != nil {
|
||||
//TODO: log
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
70
lib/challenge/script.mjs
Normal file
70
lib/challenge/script.mjs
Normal file
@@ -0,0 +1,70 @@
|
||||
import {setup, challenge} from "{{ .ChallengeScript }}";
|
||||
|
||||
|
||||
// from Xeact
|
||||
const u = (url = "", params = {}) => {
|
||||
let result = new URL(url, window.location.href);
|
||||
Object.entries(params).forEach((kv) => {
|
||||
let [k, v] = kv;
|
||||
result.searchParams.set(k, v);
|
||||
});
|
||||
return result.toString();
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const status = document.getElementById('status');
|
||||
const title = document.getElementById('title');
|
||||
const spinner = document.getElementById('spinner');
|
||||
|
||||
status.innerText = 'Starting challenge {{ .Challenge }}...';
|
||||
|
||||
try {
|
||||
const info = await setup({
|
||||
Path: "{{ .Path }}",
|
||||
Parameters: "{{ .Parameters }}"
|
||||
});
|
||||
|
||||
if (info != "") {
|
||||
status.innerText = 'Calculating... ' + info
|
||||
} else {
|
||||
status.innerText = 'Calculating...';
|
||||
}
|
||||
} catch (err) {
|
||||
title.innerHTML = "Oh no!";
|
||||
status.innerHTML = `Failed to initialize: ${err.message}`;
|
||||
spinner.innerHTML = "";
|
||||
spinner.style.display = "none";
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const { result, info } = await challenge();
|
||||
const t1 = Date.now();
|
||||
console.log({ result, info });
|
||||
|
||||
title.innerHTML = "Challenge success!";
|
||||
if (info != "") {
|
||||
status.innerHTML = `Done! Took ${t1 - t0}ms, ${info}`;
|
||||
} else {
|
||||
status.innerHTML = `Done! Took ${t1 - t0}ms`;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
const redir = window.location.href;
|
||||
window.location.href = u("{{ .Path }}/verify-challenge", {
|
||||
__goaway_token: result,
|
||||
__goaway_challenge: "{{ .Challenge }}",
|
||||
__goaway_redirect: redir,
|
||||
__goaway_id: "{{ .Id }}",
|
||||
__goaway_elapsedTime: t1 - t0,
|
||||
});
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
title.innerHTML = "Oh no!";
|
||||
status.innerHTML = `Failed to challenge: ${err.message}`;
|
||||
spinner.innerHTML = "";
|
||||
spinner.style.display = "none";
|
||||
}
|
||||
})();
|
||||
@@ -1,176 +0,0 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"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"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Result int
|
||||
|
||||
const (
|
||||
// ResultStop Stop testing other challenges and return
|
||||
ResultStop = Result(iota)
|
||||
// ResultContinue Test next
|
||||
ResultContinue
|
||||
// ResultPass passed, return and proxy
|
||||
ResultPass
|
||||
)
|
||||
|
||||
type Id int
|
||||
|
||||
type Challenge struct {
|
||||
Id Id
|
||||
Program cel.Program
|
||||
Name string
|
||||
Path string
|
||||
|
||||
Verify func(key []byte, result string, r *http.Request) (bool, error)
|
||||
VerifyProbability float64
|
||||
|
||||
ServeStatic http.Handler
|
||||
|
||||
ServeChallenge func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) Result
|
||||
|
||||
ServeScript http.Handler
|
||||
ServeScriptPath string
|
||||
|
||||
ServeMakeChallenge http.Handler
|
||||
ServeVerifyChallenge http.Handler
|
||||
}
|
||||
|
||||
type Token struct {
|
||||
Name string `json:"name"`
|
||||
Key []byte `json:"key"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
|
||||
Expiry *jwt.NumericDate `json:"exp,omitempty"`
|
||||
NotBefore *jwt.NumericDate `json:"nbf,omitempty"`
|
||||
IssuedAt *jwt.NumericDate `json:"iat,omitempty"`
|
||||
}
|
||||
|
||||
func (c Challenge) IssueChallengeToken(privateKey ed25519.PrivateKey, key, result []byte, until time.Time) (token string, err error) {
|
||||
signer, err := jose.NewSigner(jose.SigningKey{
|
||||
Algorithm: jose.EdDSA,
|
||||
Key: privateKey,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
expiry := jwt.NumericDate(until.Unix())
|
||||
notBefore := jwt.NumericDate(time.Now().UTC().AddDate(0, 0, -1).Unix())
|
||||
issuedAt := jwt.NumericDate(time.Now().UTC().Unix())
|
||||
|
||||
token, err = jwt.Signed(signer).Claims(Token{
|
||||
Name: c.Name,
|
||||
Key: key,
|
||||
Result: result,
|
||||
Expiry: &expiry,
|
||||
NotBefore: ¬Before,
|
||||
IssuedAt: &issuedAt,
|
||||
}).Serialize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
type VerifyResult int
|
||||
|
||||
const (
|
||||
VerifyResultNONE = VerifyResult(iota)
|
||||
VerifyResultFAIL
|
||||
VerifyResultSKIP
|
||||
|
||||
// VerifyResultPASS Client just passed this challenge
|
||||
VerifyResultPASS
|
||||
VerifyResultOK
|
||||
VerifyResultBRIEF
|
||||
VerifyResultFULL
|
||||
)
|
||||
|
||||
func (r VerifyResult) Ok() bool {
|
||||
return r >= VerifyResultPASS
|
||||
}
|
||||
|
||||
func (r VerifyResult) String() string {
|
||||
switch r {
|
||||
case VerifyResultNONE:
|
||||
return "NONE"
|
||||
case VerifyResultFAIL:
|
||||
return "FAIL"
|
||||
case VerifyResultSKIP:
|
||||
return "SKIP"
|
||||
case VerifyResultPASS:
|
||||
return "PASS"
|
||||
case VerifyResultOK:
|
||||
return "OK"
|
||||
case VerifyResultBRIEF:
|
||||
return "BRIEF"
|
||||
case VerifyResultFULL:
|
||||
return "FULL"
|
||||
default:
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
var ErrVerifyKeyMismatch = errors.New("verify: key mismatch")
|
||||
var ErrVerifyVerifyMismatch = errors.New("verify: verification mismatch")
|
||||
|
||||
func (c Challenge) VerifyChallengeToken(publicKey ed25519.PublicKey, expectedKey []byte, r *http.Request) (VerifyResult, error) {
|
||||
cookie, err := r.Cookie(utils.CookiePrefix + c.Name)
|
||||
if err != nil {
|
||||
return VerifyResultNONE, err
|
||||
}
|
||||
if cookie == nil {
|
||||
return VerifyResultNONE, http.ErrNoCookie
|
||||
}
|
||||
|
||||
token, err := jwt.ParseSigned(cookie.Value, []jose.SignatureAlgorithm{jose.EdDSA})
|
||||
if err != nil {
|
||||
return VerifyResultFAIL, err
|
||||
}
|
||||
|
||||
var i Token
|
||||
err = token.Claims(publicKey, &i)
|
||||
if err != nil {
|
||||
return VerifyResultFAIL, err
|
||||
}
|
||||
|
||||
if i.Name != c.Name {
|
||||
return VerifyResultFAIL, errors.New("token invalid name")
|
||||
}
|
||||
if i.Expiry == nil && i.Expiry.Time().Compare(time.Now()) < 0 {
|
||||
return VerifyResultFAIL, errors.New("token expired")
|
||||
}
|
||||
if i.NotBefore == nil && i.NotBefore.Time().Compare(time.Now()) > 0 {
|
||||
return VerifyResultFAIL, errors.New("token not valid yet")
|
||||
}
|
||||
|
||||
if bytes.Compare(expectedKey, i.Key) != 0 {
|
||||
return VerifyResultFAIL, ErrVerifyKeyMismatch
|
||||
}
|
||||
|
||||
if c.Verify != nil {
|
||||
if rand.Float64() < c.VerifyProbability {
|
||||
// random spot check
|
||||
if ok, err := c.Verify(expectedKey, string(i.Result), r); err != nil {
|
||||
return VerifyResultFAIL, err
|
||||
} else if !ok {
|
||||
return VerifyResultFAIL, ErrVerifyVerifyMismatch
|
||||
}
|
||||
return VerifyResultFULL, nil
|
||||
} else {
|
||||
return VerifyResultBRIEF, nil
|
||||
}
|
||||
}
|
||||
return VerifyResultOK, nil
|
||||
}
|
||||
112
lib/challenge/types.go
Normal file
112
lib/challenge/types.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package challenge
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"git.gammaspectra.live/git/go-away/lib/policy"
|
||||
"github.com/google/cel-go/cel"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type Id int64
|
||||
|
||||
type Class uint8
|
||||
|
||||
const (
|
||||
// ClassTransparent Transparent challenges work inline in the execution process.
|
||||
// These can pass or continue, so more challenges or requests can ve served afterward.
|
||||
ClassTransparent = Class(iota)
|
||||
|
||||
// ClassBlocking Blocking challenges must serve a different response to challenge the requester.
|
||||
// These can pass or stop, for example, due to serving a challenge
|
||||
ClassBlocking
|
||||
)
|
||||
|
||||
type VerifyState uint8
|
||||
|
||||
const (
|
||||
VerifyStateNone = VerifyState(iota)
|
||||
// VerifyStatePass Challenge was just passed on this request
|
||||
VerifyStatePass
|
||||
// VerifyStateBrief Challenge token was verified but didn't check the challenge
|
||||
VerifyStateBrief
|
||||
// VerifyStateFull Challenge token was verified and challenge verification was done
|
||||
VerifyStateFull
|
||||
)
|
||||
|
||||
func (r VerifyState) String() string {
|
||||
switch r {
|
||||
case VerifyStatePass:
|
||||
return "PASS"
|
||||
case VerifyStateBrief:
|
||||
return "BRIEF"
|
||||
case VerifyStateFull:
|
||||
return "FULL"
|
||||
default:
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
type VerifyResult uint8
|
||||
|
||||
const (
|
||||
// VerifyResultNone A negative pass result, without a token
|
||||
VerifyResultNone = VerifyResult(iota)
|
||||
// VerifyResultFail A negative pass result, with an invalid token
|
||||
VerifyResultFail
|
||||
// VerifyResultSkip Challenge was skipped due to precondition
|
||||
VerifyResultSkip
|
||||
// VerifyResultNotOK A negative pass result, with a valid token
|
||||
VerifyResultNotOK
|
||||
|
||||
// VerifyResultOK A positive pass result, with a valid token
|
||||
VerifyResultOK
|
||||
)
|
||||
|
||||
func (r VerifyResult) Ok() bool {
|
||||
return r >= VerifyResultOK
|
||||
}
|
||||
|
||||
func (r VerifyResult) String() string {
|
||||
switch r {
|
||||
case VerifyResultNone:
|
||||
return "None"
|
||||
case VerifyResultFail:
|
||||
return "Fail"
|
||||
case VerifyResultSkip:
|
||||
return "Skip"
|
||||
case VerifyResultNotOK:
|
||||
return "NotOK"
|
||||
case VerifyResultOK:
|
||||
return "OK"
|
||||
default:
|
||||
panic("unsupported")
|
||||
}
|
||||
}
|
||||
|
||||
type StateInterface interface {
|
||||
ProgramEnv() *cel.Env
|
||||
|
||||
Client() *http.Client
|
||||
PrivateKey() ed25519.PrivateKey
|
||||
PublicKey() ed25519.PublicKey
|
||||
|
||||
UrlPath() string
|
||||
|
||||
ChallengeFailed(r *http.Request, reg *Registration, err error, redirect string, logger *slog.Logger)
|
||||
ChallengePassed(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
|
||||
ChallengeIssued(r *http.Request, reg *Registration, redirect string, logger *slog.Logger)
|
||||
|
||||
Logger(r *http.Request) *slog.Logger
|
||||
|
||||
ChallengePage(w http.ResponseWriter, r *http.Request, status int, reg *Registration, params map[string]any)
|
||||
ErrorPage(w http.ResponseWriter, r *http.Request, status int, err error, redirect string)
|
||||
|
||||
GetChallenge(id Id) (*Registration, bool)
|
||||
GetChallengeByName(name string) (*Registration, bool)
|
||||
GetChallenges() Register
|
||||
|
||||
Settings() policy.Settings
|
||||
|
||||
GetBackend(host string) http.Handler
|
||||
}
|
||||
@@ -111,6 +111,7 @@ type VerifyChallengeInput struct {
|
||||
|
||||
type VerifyChallengeOutput uint64
|
||||
|
||||
// TODO: expand allowed values
|
||||
const (
|
||||
VerifyChallengeOutputOK = VerifyChallengeOutput(iota)
|
||||
VerifyChallengeOutputFailed
|
||||
|
||||
186
lib/challenge/wasm/registration.go
Normal file
186
lib/challenge/wasm/registration.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package wasm
|
||||
|
||||
import (
|
||||
"codeberg.org/meta/gzipped/v2"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.gammaspectra.live/git/go-away/embed"
|
||||
"git.gammaspectra.live/git/go-away/lib/challenge"
|
||||
_interface "git.gammaspectra.live/git/go-away/lib/challenge/wasm/interface"
|
||||
"git.gammaspectra.live/git/go-away/utils"
|
||||
"git.gammaspectra.live/git/go-away/utils/inline"
|
||||
"github.com/goccy/go-yaml"
|
||||
"github.com/goccy/go-yaml/ast"
|
||||
"github.com/tetratelabs/wazero/api"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
func init() {
|
||||
challenge.Runtimes["js"] = FillJavaScriptRegistration
|
||||
}
|
||||
|
||||
type Parameters struct {
|
||||
Path string `yaml:"path"`
|
||||
// Loader path to js/mjs file to use as challenge issuer
|
||||
Loader string `yaml:"js-loader"`
|
||||
|
||||
// Runtime path to WASM wasip1 runtime
|
||||
Runtime string `yaml:"wasm-runtime"`
|
||||
|
||||
Settings map[string]string `yaml:"wasm-runtime-settings"`
|
||||
|
||||
NativeCompiler bool `yaml:"wasm-native-compiler"`
|
||||
|
||||
VerifyProbability float64 `yaml:"verify-probability"`
|
||||
}
|
||||
|
||||
var DefaultParameters = Parameters{
|
||||
VerifyProbability: 0.1,
|
||||
NativeCompiler: true,
|
||||
}
|
||||
|
||||
func FillJavaScriptRegistration(state challenge.StateInterface, reg *challenge.Registration, parameters ast.Node) error {
|
||||
params := DefaultParameters
|
||||
|
||||
if parameters != nil {
|
||||
ymlData, err := parameters.MarshalYAML()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = yaml.Unmarshal(ymlData, ¶ms)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
reg.Class = challenge.ClassBlocking
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
if params.Path == "" {
|
||||
params.Path = reg.Name
|
||||
}
|
||||
|
||||
assetsFs, err := embed.GetFallbackFS(embed.ChallengeFs, params.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if params.VerifyProbability <= 0 {
|
||||
//10% default
|
||||
params.VerifyProbability = 0.1
|
||||
} else if params.VerifyProbability > 1.0 {
|
||||
params.VerifyProbability = 1.0
|
||||
}
|
||||
|
||||
reg.VerifyProbability = params.VerifyProbability
|
||||
|
||||
ob := NewRunner(params.NativeCompiler)
|
||||
reg.Object = ob
|
||||
|
||||
wasmData, err := assetsFs.ReadFile(path.Join("runtime", params.Runtime))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not load runtime: %w", err)
|
||||
}
|
||||
|
||||
err = ob.Compile("runtime", wasmData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("compiling runtime: %w", err)
|
||||
}
|
||||
|
||||
reg.IssueChallenge = func(w http.ResponseWriter, r *http.Request, key challenge.Key, expiry time.Time) challenge.VerifyResult {
|
||||
state.ChallengePage(w, r, state.Settings().ChallengeResponseCode, reg, map[string]any{
|
||||
"EndTags": []template.HTML{
|
||||
template.HTML(fmt.Sprintf("<script async type=\"module\" src=\"%s?cacheBust=%s\"></script>", reg.Path+"/script.mjs", utils.CacheBust())),
|
||||
},
|
||||
})
|
||||
return challenge.VerifyResultNone
|
||||
}
|
||||
|
||||
reg.Verify = func(key challenge.Key, token []byte, r *http.Request) (challenge.VerifyResult, error) {
|
||||
var ok bool
|
||||
err = ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
|
||||
in := _interface.VerifyChallengeInput{
|
||||
Key: key[:],
|
||||
Parameters: params.Settings,
|
||||
Result: token,
|
||||
}
|
||||
|
||||
out, err := VerifyChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == _interface.VerifyChallengeOutputError {
|
||||
return errors.New("error checking challenge")
|
||||
}
|
||||
ok = out == _interface.VerifyChallengeOutputOK
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return challenge.VerifyResultFail, err
|
||||
}
|
||||
if ok {
|
||||
return challenge.VerifyResultOK, nil
|
||||
}
|
||||
return challenge.VerifyResultFail, nil
|
||||
}
|
||||
|
||||
// serve assets if existent
|
||||
if staticFs, err := fs.Sub(assetsFs, "static"); err != nil {
|
||||
return fmt.Errorf("no static assets: %w", err)
|
||||
} else {
|
||||
mux.Handle("GET "+reg.Path+"/static/", http.StripPrefix(reg.Path+"/static/", gzipped.FileServer(gzipped.FS(staticFs))))
|
||||
}
|
||||
|
||||
mux.HandleFunc(reg.Path+challenge.MakeChallengeUrlSuffix, func(w http.ResponseWriter, r *http.Request) {
|
||||
data := challenge.RequestDataFromContext(r.Context())
|
||||
err := ob.Instantiate("runtime", func(ctx context.Context, mod api.Module) (err error) {
|
||||
key := challenge.GetChallengeKeyForRequest(state, reg, data.Expiration(reg.Duration), r)
|
||||
|
||||
in := _interface.MakeChallengeInput{
|
||||
Key: key[:],
|
||||
Parameters: params.Settings,
|
||||
Headers: inline.MIMEHeader(r.Header),
|
||||
}
|
||||
in.Data, err = io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := MakeChallengeCall(ctx, mod, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set output headers
|
||||
for k, v := range out.Headers {
|
||||
w.Header()[k] = v
|
||||
}
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(out.Data)))
|
||||
w.WriteHeader(out.Code)
|
||||
_, _ = w.Write(out.Data)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
state.ErrorPage(w, r, http.StatusInternalServerError, err, "")
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
mux.HandleFunc(reg.Path+challenge.VerifyChallengeUrlSuffix, challenge.VerifyHandlerFunc(state, reg, nil, nil))
|
||||
|
||||
mux.HandleFunc("GET "+reg.Path+"/script.mjs", func(w http.ResponseWriter, r *http.Request) {
|
||||
challenge.ServeChallengeScript(w, r, reg, params.Settings, path.Join(reg.Path, "static", params.Loader))
|
||||
})
|
||||
|
||||
reg.Handler = mux
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -94,11 +94,13 @@ func (r *Runner) Compile(key string, binary []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Runner) Close() {
|
||||
func (r *Runner) Close() error {
|
||||
for _, module := range r.modules {
|
||||
module.Close(r.context)
|
||||
if err := module.Close(r.context); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
r.runtime.Close(r.context)
|
||||
return r.runtime.Close(r.context)
|
||||
}
|
||||
|
||||
var ErrModuleNotFound = errors.New("module not found")
|
||||
|
||||
Reference in New Issue
Block a user