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:
WeebDataHoarder
2025-04-19 00:42:18 +02:00
committed by WeebDataHoarder
parent 1c7fe1bed9
commit ead41055ca
58 changed files with 3258 additions and 2048 deletions

47
lib/challenge/awaiter.go Normal file
View 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
}

View 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
View 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())
}
}
}

View 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, &params)
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
View 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
View 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, &params)
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
View 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)
}

View 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, &params)
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
}

View 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, &params)
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
View 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)

View 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
View 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
View 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";
}
})();

View File

@@ -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: &notBefore,
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
View 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
}

View File

@@ -111,6 +111,7 @@ type VerifyChallengeInput struct {
type VerifyChallengeOutput uint64
// TODO: expand allowed values
const (
VerifyChallengeOutputOK = VerifyChallengeOutput(iota)
VerifyChallengeOutputFailed

View 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, &params)
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
}

View File

@@ -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")