New challenge for HTTP/2 clients, preload-link
This commit is contained in:
@@ -84,6 +84,8 @@ func (v *MultiVar) Set(value string) error {
|
|||||||
func newServer(handler http.Handler) *http.Server {
|
func newServer(handler http.Handler) *http.Server {
|
||||||
h2s := &http2.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{
|
h1s := &http.Server{
|
||||||
Handler: h2c.NewHandler(handler, h2s),
|
Handler: h2c.NewHandler(handler, h2s),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,7 +112,17 @@ challenges:
|
|||||||
self-cookie:
|
self-cookie:
|
||||||
mode: "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:
|
self-header-refresh:
|
||||||
mode: "header-refresh"
|
mode: "header-refresh"
|
||||||
runtime:
|
runtime:
|
||||||
@@ -120,7 +130,7 @@ challenges:
|
|||||||
mode: "key"
|
mode: "key"
|
||||||
probability: 0.1
|
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:
|
self-meta-refresh:
|
||||||
mode: "meta-refresh"
|
mode: "meta-refresh"
|
||||||
runtime:
|
runtime:
|
||||||
@@ -186,6 +196,7 @@ conditions:
|
|||||||
# Golang proxy and initial fetch
|
# Golang proxy and initial fetch
|
||||||
- 'userAgent.startsWith("GoModuleMirror/")'
|
- 'userAgent.startsWith("GoModuleMirror/")'
|
||||||
- 'userAgent.startsWith("Go-http-client/") && "go-get" in query && query["go-get"] == "1"'
|
- '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:
|
is-git-path:
|
||||||
- 'path.matches("^/[^/]+/[^/]+/(git-upload-pack|git-receive-pack|HEAD|info/refs|info/lfs|objects)")'
|
- 'path.matches("^/[^/]+/[^/]+/(git-upload-pack|git-receive-pack|HEAD|info/refs|info/lfs|objects)")'
|
||||||
|
|
||||||
@@ -299,8 +310,12 @@ rules:
|
|||||||
- name: suspicious-crawlers/1
|
- name: suspicious-crawlers/1
|
||||||
conditions: ['($is-suspicious-crawler)']
|
conditions: ['($is-suspicious-crawler)']
|
||||||
action: check
|
action: check
|
||||||
challenges: [self-header-refresh]
|
challenges: [self-preload-link]
|
||||||
- name: suspicious-crawlers/2
|
- name: suspicious-crawlers/2
|
||||||
|
conditions: ['($is-suspicious-crawler)']
|
||||||
|
action: check
|
||||||
|
challenges: [self-header-refresh]
|
||||||
|
- name: suspicious-crawlers/3
|
||||||
conditions: ['($is-suspicious-crawler)']
|
conditions: ['($is-suspicious-crawler)']
|
||||||
action: check
|
action: check
|
||||||
challenges: [self-resource-load]
|
challenges: [self-resource-load]
|
||||||
@@ -396,7 +411,7 @@ rules:
|
|||||||
# check a sequence of challenges
|
# check a sequence of challenges
|
||||||
- name: heavy-operations/0
|
- name: heavy-operations/0
|
||||||
action: check
|
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)']
|
conditions: ['($is-heavy-resource)']
|
||||||
- name: heavy-operations/1
|
- name: heavy-operations/1
|
||||||
action: check
|
action: check
|
||||||
@@ -430,6 +445,6 @@ rules:
|
|||||||
|
|
||||||
- name: standard-browser
|
- name: standard-browser
|
||||||
action: challenge
|
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:
|
conditions:
|
||||||
- '($is-generic-browser)'
|
- '($is-generic-browser)'
|
||||||
|
|||||||
6
go.mod
6
go.mod
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/klauspost/compress v1.18.0
|
github.com/klauspost/compress v1.18.0
|
||||||
github.com/tetratelabs/wazero v1.9.0
|
github.com/tetratelabs/wazero v1.9.0
|
||||||
github.com/yl2chen/cidranger v1.0.2
|
github.com/yl2chen/cidranger v1.0.2
|
||||||
|
golang.org/x/net v0.26.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ require (
|
|||||||
github.com/stoewer/go-strcase v1.3.0 // indirect
|
github.com/stoewer/go-strcase v1.3.0 // indirect
|
||||||
golang.org/x/crypto v0.33.0 // indirect
|
golang.org/x/crypto v0.33.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac // 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/api v0.0.0-20240826202546-f6391c0de4c7 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc 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
|
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
|
// 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
|
// TODO: remove this when Go 1.22+ is supported by other higher users
|
||||||
replace (
|
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/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
|
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
|
)
|
||||||
)
|
|
||||||
|
|||||||
2
go.sum
2
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/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 h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs=
|
||||||
golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo=
|
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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
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=
|
google.golang.org/genproto/googleapis/api v0.0.0-20240826202546-f6391c0de4c7 h1:YcyjlL1PRr2Q17/I0dPk2JmYS5CDXfcdb2Z3YRioEbw=
|
||||||
|
|||||||
83
lib/state.go
83
lib/state.go
@@ -36,6 +36,8 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -59,8 +61,36 @@ type State struct {
|
|||||||
privateKey ed25519.PrivateKey
|
privateKey ed25519.PrivateKey
|
||||||
|
|
||||||
Poison map[string][]byte
|
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 {
|
type RuleState struct {
|
||||||
Name string
|
Name string
|
||||||
Hash string
|
Hash string
|
||||||
@@ -273,6 +303,9 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
|||||||
if response.StatusCode != httpCode {
|
if response.StatusCode != httpCode {
|
||||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
||||||
// continue other challenges!
|
// continue other challenges!
|
||||||
|
|
||||||
|
//TODO: negatively cache failure
|
||||||
|
|
||||||
return challenge.ResultContinue
|
return challenge.ResultContinue
|
||||||
} else {
|
} else {
|
||||||
// bind hash of cookie contents
|
// 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(key)
|
||||||
sum.Write([]byte{0})
|
sum.Write([]byte{0})
|
||||||
sum.Write(state.publicKey)
|
sum.Write(state.publicKey)
|
||||||
|
|
||||||
token, err := c.IssueChallengeToken(state.privateKey, key, sum.Sum(nil), expiry)
|
token, err := c.IssueChallengeToken(state.privateKey, key, sum.Sum(nil), expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
utils.ClearCookie(utils.CookiePrefix+c.Name, w)
|
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
|
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":
|
case "resource-load":
|
||||||
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
c.ServeChallenge = func(w http.ResponseWriter, r *http.Request, key []byte, expiry time.Time) challenge.Result {
|
||||||
redirectUri := new(url.URL)
|
redirectUri := new(url.URL)
|
||||||
@@ -467,6 +543,9 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
|||||||
} else if !ok {
|
} else if !ok {
|
||||||
state.logger(r).Warn("challenge failed", "challenge", challengeName, "redirect", redirect)
|
state.logger(r).Warn("challenge failed", "challenge", challengeName, "redirect", redirect)
|
||||||
utils.ClearCookie(utils.CookiePrefix+challengeName, w)
|
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)
|
_ = state.errorPage(w, r.Header.Get("X-Away-Id"), http.StatusForbidden, fmt.Errorf("access denied: failed challenge %s", challengeName), redirect)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -481,6 +560,8 @@ func NewState(p policy.Policy, settings StateSettings) (state *State, err error)
|
|||||||
}
|
}
|
||||||
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
data.Challenges[c.Id] = challenge.VerifyResultPASS
|
||||||
|
|
||||||
|
state.SolveChallenge(key, challenge.VerifyResultPASS)
|
||||||
|
|
||||||
switch httpCode {
|
switch httpCode {
|
||||||
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther, http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
||||||
http.Redirect(w, r, redirect, httpCode)
|
http.Redirect(w, r, redirect, httpCode)
|
||||||
|
|||||||
Reference in New Issue
Block a user