diff --git a/cmd/go-away/main.go b/cmd/go-away/main.go index 0ad3716..4bcc6d3 100644 --- a/cmd/go-away/main.go +++ b/cmd/go-away/main.go @@ -84,6 +84,8 @@ func (v *MultiVar) Set(value string) error { func newServer(handler http.Handler) *http.Server { h2s := &http2.Server{} + // TODO: use Go 1.24 Server.Protocols to add H2C + // https://pkg.go.dev/net/http#Server.Protocols h1s := &http.Server{ Handler: h2c.NewHandler(handler, h2s), } diff --git a/examples/forgejo.yml b/examples/forgejo.yml index d6ebb79..975e290 100644 --- a/examples/forgejo.yml +++ b/examples/forgejo.yml @@ -112,7 +112,17 @@ challenges: self-cookie: mode: "cookie" - # Challenges with a redirect via header (non-JS, requires HTTP parsing and logic) + + # Challenges with a redirect via Link header with rel=preload and early hints (non-JS, requires HTTP parsing, fetching and logic) + # Works on HTTP/2 and above! + self-preload-link: + mode: "preload-link" + runtime: + # verifies that result = key + mode: "key" + probability: 0.1 + + # Challenges with a redirect via Refresh header (non-JS, requires HTTP parsing and logic) self-header-refresh: mode: "header-refresh" runtime: @@ -120,7 +130,7 @@ challenges: mode: "key" probability: 0.1 - # Challenges with a redirect via meta (non-JS, requires HTML parsing and logic) + # Challenges with a redirect via Refresh meta (non-JS, requires HTML parsing and logic) self-meta-refresh: mode: "meta-refresh" runtime: @@ -186,6 +196,7 @@ conditions: # Golang proxy and initial fetch - 'userAgent.startsWith("GoModuleMirror/")' - 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"' + - '"Git-Protocol" in headers && headers["Git-Protocol"] == "version=2"' is-git-path: - 'path.matches("^/[^/]+/[^/]+/(git-upload-pack|git-receive-pack|HEAD|info/refs|info/lfs|objects)")' @@ -299,8 +310,12 @@ rules: - name: suspicious-crawlers/1 conditions: ['($is-suspicious-crawler)'] action: check - challenges: [self-header-refresh] + challenges: [self-preload-link] - name: suspicious-crawlers/2 + conditions: ['($is-suspicious-crawler)'] + action: check + challenges: [self-header-refresh] + - name: suspicious-crawlers/3 conditions: ['($is-suspicious-crawler)'] action: check challenges: [self-resource-load] @@ -396,7 +411,7 @@ rules: # check a sequence of challenges - name: heavy-operations/0 action: check - challenges: [self-header-refresh, js-pow-sha256, http-cookie-check] + challenges: [self-preload-link, self-header-refresh, js-pow-sha256, http-cookie-check] conditions: ['($is-heavy-resource)'] - name: heavy-operations/1 action: check @@ -430,6 +445,6 @@ rules: - name: standard-browser action: challenge - challenges: [http-cookie-check, self-meta-refresh, self-resource-load, js-pow-sha256] + challenges: [http-cookie-check, self-preload-link, self-meta-refresh, self-resource-load, js-pow-sha256] conditions: - '($is-generic-browser)' diff --git a/go.mod b/go.mod index cd6d389..3efe2df 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/klauspost/compress v1.18.0 github.com/tetratelabs/wazero v1.9.0 github.com/yl2chen/cidranger v1.0.2 + golang.org/x/net v0.26.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -24,6 +25,7 @@ require ( github.com/stoewer/go-strcase v1.3.0 // indirect golang.org/x/crypto v0.33.0 // indirect golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // indirect + golang.org/x/text v0.22.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 // indirect google.golang.org/protobuf v1.36.6 // indirect @@ -37,7 +39,7 @@ replace golang.org/x/exp v0.0.0 => ./utils/exp // Pin latest versions to support Go 1.22 to prevent a package update from changing them // TODO: remove this when Go 1.22+ is supported by other higher users replace ( + golang.org/x/crypto => golang.org/x/crypto v0.33.0 google.golang.org/genproto/googleapis/api => google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 google.golang.org/genproto/googleapis/rpc => google.golang.org/genproto/googleapis/rpc v0.0.0-20240826202546-f6391c0de4c7 - golang.org/x/crypto => golang.org/x/crypto v0.33.0 -) \ No newline at end of file +) diff --git a/go.sum b/go.sum index 0abd322..a90747b 100644 --- a/go.sum +++ b/go.sum @@ -46,6 +46,8 @@ golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw= diff --git a/lib/state.go b/lib/state.go index d712981..385c55d 100644 --- a/lib/state.go +++ b/lib/state.go @@ -36,6 +36,8 @@ import ( "path" "strconv" "strings" + "sync" + "sync/atomic" "time" ) @@ -59,8 +61,36 @@ type State struct { privateKey ed25519.PrivateKey Poison map[string][]byte + + ChallengeSolve sync.Map } +func (state *State) AwaitChallenge(key []byte, ctx context.Context) challenge.VerifyResult { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var result atomic.Int64 + + state.ChallengeSolve.Store(string(key), ChallengeCallback(func(receivedResult challenge.VerifyResult) { + result.Store(int64(receivedResult)) + cancel() + })) + + <-ctx.Done() + + return challenge.VerifyResult(result.Load()) +} + +func (state *State) SolveChallenge(key []byte, result challenge.VerifyResult) { + if f, ok := state.ChallengeSolve.LoadAndDelete(string(key)); ok && f != nil { + if cb, ok := f.(ChallengeCallback); ok { + cb(result) + } + } +} + +type ChallengeCallback func(result challenge.VerifyResult) + type RuleState struct { Name string Hash string @@ -273,6 +303,9 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error) if response.StatusCode != httpCode { utils.ClearCookie(utils.CookiePrefix+c.Name, w) // continue other challenges! + + //TODO: negatively cache failure + return challenge.ResultContinue } else { // bind hash of cookie contents @@ -282,7 +315,6 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error) sum.Write(key) sum.Write([]byte{0}) sum.Write(state.publicKey) - token, err := c.IssueChallengeToken(state.privateKey, key, sum.Sum(nil), expiry) if err != nil { utils.ClearCookie(utils.CookiePrefix+c.Name, w) @@ -350,6 +382,50 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error) return challenge.ResultStop } + case "preload-link": + deadline := time.Second * 5 + + c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result { + // this only works on HTTP/2 and HTTP/3 + + if r.ProtoMajor < 2 { + // this can happen if we are an upgraded request + if _, ok := w.(http.Pusher); !ok { + return challenge.ResultContinue + } + } + + data := RequestDataFromContext(r.Context()) + redirectUri := new(url.URL) + redirectUri.Path = c.Path + "/verify-challenge" + + values := make(url.Values) + values.Set("result", hex.EncodeToString(key)) + values.Set("redirect", r.URL.String()) + values.Set("requestId", r.Header.Get("X-Away-Id")) + + redirectUri.RawQuery = values.Encode() + + w.Header().Set("Link", fmt.Sprintf("<%s>; rel=preload; as=fetch; crossorigin=1", redirectUri.String())) + defer func() { + // remove old header header! + w.Header().Del("Link") + }() + w.WriteHeader(http.StatusEarlyHints) + + ctx, cancel := context.WithTimeout(r.Context(), deadline) + defer cancel() + if result := state.AwaitChallenge(key, ctx); result.Ok() { + data.Challenges[c.Id] = challenge.VerifyResultPASS + + // this should serve! + return challenge.ResultPass + } + + data.Challenges[c.Id] = challenge.VerifyResultFAIL + // we failed, continue + return challenge.ResultContinue + } case "resource-load": c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result { redirectUri := new(url.URL) @@ -467,6 +543,9 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error) } else if !ok { state.logger(r).Warn("challenge failed", "challenge", challengeName, "redirect", redirect) utils.ClearCookie(utils.CookiePrefix+challengeName, w) + + state.SolveChallenge(key, challenge.VerifyResultFAIL) + _ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName), redirect) return nil } @@ -481,6 +560,8 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error) } data.Challenges[c.Id] = challenge.VerifyResultPASS + state.SolveChallenge(key, challenge.VerifyResultPASS) + switch httpCode { case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect: http.Redirect(w, r, redirect, httpCode)