diff --git a/berghain_test.go b/berghain_test.go
index 8acb742..cce1a2d 100644
--- a/berghain_test.go
+++ b/berghain_test.go
@@ -33,6 +33,7 @@ func TestBerghain(t *testing.T) {
Level: 1,
}
req.Method = "GET"
+ req.SupportID = []byte("bh@123e4567-e89b-12d3-a456-426614174000")
resp := AcquireValidatorResponse()
err := bh.LevelConfig(req.Identifier.Level).Type.RunValidator(bh, req, resp)
diff --git a/cmd/spop/frontend.go b/cmd/spop/frontend.go
index a6394aa..1ae83f7 100644
--- a/cmd/spop/frontend.go
+++ b/cmd/spop/frontend.go
@@ -40,6 +40,27 @@ func readExpectedKVEntry(ctx context.Context, m *encoding.Message, k *encoding.K
return nil
}
+func addOptionalSessionToContext(ctx context.Context, m *encoding.Message, k *encoding.KVEntry) (context.Context, []byte) {
+ if !m.KV.Next(k) {
+ if err := m.KV.Error(); err != nil {
+ slog.ErrorContext(ctx, "error while reading optional session argument", "error", err)
+ }
+ return ctx, nil
+ }
+
+ if !k.NameEquals("session") {
+ slog.WarnContext(ctx, "ignoring unexpected optional SPOP argument", "have", k.NameBytes())
+ return ctx, nil
+ }
+
+ if id := k.ValueBytes(); berghain.ValidSupportID(id) {
+ return context.WithValue(ctx, "session", string(id)), id
+ }
+
+ slog.DebugContext(ctx, "ignoring invalid session id")
+ return ctx, nil
+}
+
const hostBufferLength = 256
var hostBufPool = sync.Pool{
@@ -281,11 +302,15 @@ func (f *frontend) HandleSPOEChallenge(ctx context.Context, w *encoding.ActionWr
return
}
req.Body = k.ValueBytes()
+ ctx, req.SupportID = addOptionalSessionToContext(ctx, m, k)
resp := berghain.AcquireValidatorResponse()
defer berghain.ReleaseValidatorResponse(resp)
err := f.bh.LevelConfig(ri.Level).Type.RunValidator(f.bh, req, resp)
+ if berghain.ValidSupportID(req.SupportID) {
+ ctx = context.WithValue(ctx, "session", string(req.SupportID))
+ }
if err != nil {
slog.ErrorContext(ctx, "validator failed", "error", err)
}
diff --git a/cmd/spop/log.go b/cmd/spop/log.go
index 6de6e00..ec2ff54 100644
--- a/cmd/spop/log.go
+++ b/cmd/spop/log.go
@@ -14,6 +14,7 @@ func (lh *logHandler) Handle(ctx context.Context, r slog.Record) error {
for _, k := range []string{
"handler",
"host",
+ "session",
"src",
"validator",
} {
diff --git a/docs/index.html b/docs/index.html
index 98070b1..118a8e9 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -170,9 +170,9 @@
Report an issue on GitHub
diff --git a/examples/haproxy/berghain.cfg b/examples/haproxy/berghain.cfg
index 31973ed..9acf74a 100644
--- a/examples/haproxy/berghain.cfg
+++ b/examples/haproxy/berghain.cfg
@@ -32,7 +32,7 @@ spoe-agent berghain_challenge
spoe-message challenge
# The order is relevant, as haproxy is sending them in-order
- args frontend=fe_name level=var(req.berghain.level) src=src host=req.hdr(Host) method=method body=req.body
+ args frontend=fe_name level=var(req.berghain.level) src=src host=req.hdr(Host) method=method body=req.body session=var(txn.berghain.session)
spoe-group challenge
messages challenge
diff --git a/examples/haproxy/haproxy-ua-policy.cfg b/examples/haproxy/haproxy-ua-policy.cfg
index bf7bb93..10857be 100644
--- a/examples/haproxy/haproxy-ua-policy.cfg
+++ b/examples/haproxy/haproxy-ua-policy.cfg
@@ -39,6 +39,9 @@ frontend test
acl berghain_path path /cdn-cgi/challenge-platform/challenge
+ # HAProxy issues the initial support ID; continuation requests carry it in their body.
+ http-request set-var-fmt(txn.berghain.session) "bh@%[uuid()]" if berghain_path METH_GET
+
# These paths deliberately bypass the UA policy. Use exact paths or a
# slash-terminated prefix so an unrelated path cannot match accidentally.
acl public_endpoint path -i /feed /feed.xml /rss /rss.xml /atom.xml
diff --git a/examples/haproxy/haproxy.cfg b/examples/haproxy/haproxy.cfg
index adfcc6a..0b093b2 100644
--- a/examples/haproxy/haproxy.cfg
+++ b/examples/haproxy/haproxy.cfg
@@ -19,6 +19,11 @@ frontend test
bind *:8080
log-format "%ci:%cp\ [%t]\ %ft\ %b/%s\ %Th/%Ti/%TR/%Tq/%Tw/%Tc/%Tr/%Tt\ %ST\ %B\ %CC\ %CS\ %tsc\ %ac/%fc/%bc/%sc/%rc\ %sq/%bq\ %hr\ %hs\ %{+Q}r\ %ID spoa-error:\ %[var(txn.berghain.error)]"
+ acl berghain_path path /cdn-cgi/challenge-platform/challenge
+
+ # HAProxy issues the initial support ID; continuation requests carry it in their body.
+ http-request set-var-fmt(txn.berghain.session) "bh@%[uuid()]" if berghain_path METH_GET
+
http-request track-sc1 src table st_src
filter spoe engine berghain config examples/haproxy/berghain.cfg
@@ -29,7 +34,6 @@ frontend test
acl berghain_active var(req.berghain.level) -m found
- acl berghain_path path /cdn-cgi/challenge-platform/challenge
http-request send-spoe-group berghain validate if !berghain_path berghain_active
http-request return status 501 if { var(txn.berghain.error) -m found }
diff --git a/support_id.go b/support_id.go
new file mode 100644
index 0000000..58993cc
--- /dev/null
+++ b/support_id.go
@@ -0,0 +1,26 @@
+package berghain
+
+import "bytes"
+
+const supportIDLength = len("bh@00000000-0000-4000-8000-000000000000")
+
+func ValidSupportID(id []byte) bool {
+ if len(id) != supportIDLength || !bytes.Equal(id[:3], []byte("bh@")) {
+ return false
+ }
+
+ for i, c := range id[3:] {
+ switch i {
+ case 8, 13, 18, 23:
+ if c != '-' {
+ return false
+ }
+ default:
+ if !('0' <= c && c <= '9') && !('a' <= c && c <= 'f') && !('A' <= c && c <= 'F') {
+ return false
+ }
+ }
+ }
+
+ return true
+}
diff --git a/support_id_test.go b/support_id_test.go
new file mode 100644
index 0000000..f58f502
--- /dev/null
+++ b/support_id_test.go
@@ -0,0 +1,26 @@
+package berghain
+
+import "testing"
+
+func TestValidSupportID(t *testing.T) {
+ tests := []struct {
+ name string
+ id string
+ want bool
+ }{
+ {name: "lowercase", id: "bh@123e4567-e89b-12d3-a456-426614174000", want: true},
+ {name: "uppercase", id: "bh@123E4567-E89B-12D3-A456-426614174000", want: true},
+ {name: "wrong prefix", id: "foo@123e4567-e89b-12d3-a456-426614174000"},
+ {name: "too short", id: "bh@123e4567"},
+ {name: "invalid hex", id: "bh@123e4567-e89b-12d3-a456-42661417400z"},
+ {name: "invalid separators", id: "bh@123e4567xe89bx12d3xa456x426614174000"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := ValidSupportID([]byte(tt.id)); got != tt.want {
+ t.Fatalf("ValidSupportID(%q) = %v, want %v", tt.id, got, tt.want)
+ }
+ })
+ }
+}
diff --git a/test/e2e/haproxy.cfg b/test/e2e/haproxy.cfg
index 721369a..a20ce52 100644
--- a/test/e2e/haproxy.cfg
+++ b/test/e2e/haproxy.cfg
@@ -19,6 +19,10 @@ frontend e2e
filter spoe engine berghain config examples/haproxy/berghain.cfg
acl berghain_path path /cdn-cgi/challenge-platform/challenge
+
+ # HAProxy issues the initial support ID; continuation requests carry it in their body.
+ http-request set-var-fmt(txn.berghain.session) "bh@%[uuid()]" if berghain_path METH_GET
+
http-request send-spoe-group berghain validate if !berghain_path
http-request return status 501 if { var(txn.berghain.error) -m found }
@@ -37,6 +41,10 @@ frontend e2e_turnstile
filter spoe engine berghain config examples/haproxy/berghain.cfg
acl berghain_path path /cdn-cgi/challenge-platform/challenge
+
+ # HAProxy issues the initial support ID; continuation requests carry it in their body.
+ http-request set-var-fmt(txn.berghain.session) "bh@%[uuid()]" if berghain_path METH_GET
+
http-request send-spoe-group berghain validate if !berghain_path
http-request return status 501 if { var(txn.berghain.error) -m found }
diff --git a/validator_none.go b/validator_none.go
index 1d6fad3..ff02b65 100644
--- a/validator_none.go
+++ b/validator_none.go
@@ -10,6 +10,10 @@ var validatorNoneResponse = mustJSONEncodeString(struct {
})
func validatorNone(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse) error {
+ if !ValidSupportID(req.SupportID) {
+ return ErrInvalidLength
+ }
+
lc := b.LevelConfig(req.Identifier.Level)
copy(resp.Body.WriteBytes(), validatorNoneResponse)
@@ -17,7 +21,8 @@ func validatorNone(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse)
// the following conversion is faster than sprintf but also way uglier, I am sorry.
// 48 is the ASCII code for '0', adding lc.Countdown will give us the single correct digit.
copy(resp.Body.WriteNBytes(1), []byte{byte(48 + lc.Countdown)})
- resp.Body.AdvanceW(len(`,"t":0}`))
+ resp.Body.AdvanceW(len(`,"t":0`))
+ appendSupportID(resp.Body, req.SupportID)
return req.Identifier.ToCookie(b, resp.Token)
}
diff --git a/validator_none_test.go b/validator_none_test.go
index 06f99c3..2caed6d 100644
--- a/validator_none_test.go
+++ b/validator_none_test.go
@@ -1,7 +1,7 @@
package berghain
import (
- "bytes"
+ "encoding/json"
"net/http"
"net/netip"
"testing"
@@ -25,14 +25,23 @@ func Test_validatorNone(t *testing.T) {
Level: 1,
}
req.Method = http.MethodGet
+ req.SupportID = []byte("bh@123e4567-e89b-12d3-a456-426614174000")
err := validatorNone(bh, req, resp)
if err != nil {
t.Errorf("validator failed: %v", err)
}
- if !bytes.Equal(resp.Body.ReadBytes(), []byte(validatorNoneResponse)) {
- t.Errorf("invalid response: %s != %s", validatorNoneResponse, resp.Body.ReadBytes())
+ var challenge struct {
+ Countdown int `json:"c"`
+ Type int `json:"t"`
+ SupportID string `json:"i"`
+ }
+ if err := json.Unmarshal(resp.Body.ReadBytes(), &challenge); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if challenge.Type != 0 || challenge.SupportID != string(req.SupportID) {
+ t.Errorf("invalid response: %+v", challenge)
}
err = bh.IsValidCookie(*req.Identifier, resp.Token.ReadBytes())
diff --git a/validator_pow.go b/validator_pow.go
index 7b4cefa..1246414 100644
--- a/validator_pow.go
+++ b/validator_pow.go
@@ -31,9 +31,11 @@ type powValidator struct {
}
const (
- validatorPOWRandom = "0000000000000000"
+ validatorPOWTimestamp = "0000000000000000"
+ validatorPOWRandom = validatorPOWTimestamp + "bh@00000000-0000-4000-8000-000000000000"
validatorPOWHash = "0000000000000000000000000000000000000000000000000000000000000000"
validatorPOWMinSolutionLength = len(validatorPOWRandom + "-" + validatorPOWHash + "-0")
+ validatorPOWMaxSolutionLength = validatorPOWMinSolutionLength + 19
)
var validatorPOWChallengeTemplate = mustJSONEncodeString(struct {
@@ -45,7 +47,7 @@ var validatorPOWChallengeTemplate = mustJSONEncodeString(struct {
// Only strings have to be set, as the default is zero for ints.
// We do set the Type here because it is static anyway...
Type: 1,
- Random: "0000000000000000",
+ Random: validatorPOWRandom,
Hash: "0000000000000000000000000000000000000000000000000000000000000000",
})
@@ -55,10 +57,17 @@ var _ = func() bool {
if len(validatorPOWHash) != hex.EncodedLen(h.Size()) {
panic("invalid pow hash length")
}
+ if !ValidSupportID([]byte(validatorPOWRandom[len(validatorPOWTimestamp):])) {
+ panic("invalid pow support ID placeholder")
+ }
return true
}()
func (powValidator) onNew(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse) error {
+ if !ValidSupportID(req.SupportID) {
+ return ErrInvalidLength
+ }
+
h := b.acquireHMAC()
defer b.releaseHMAC(h)
@@ -71,10 +80,13 @@ func (powValidator) onNew(b *Berghain, req *ValidatorRequest, resp *ValidatorRes
// 48 is the ASCII code for '0', adding lc.Countdown will give us the single correct digit.
copy(resp.Body.WriteNBytes(1), []byte{byte(48 + lc.Countdown)})
resp.Body.AdvanceW(len(`,"t":1,"r":"`))
- timestampArea := resp.Body.WriteNBytes(len(validatorPOWRandom))
+ randomArea := resp.Body.WriteNBytes(len(validatorPOWRandom))
+ timestampArea := randomArea[:len(validatorPOWTimestamp)]
+ copy(randomArea[len(validatorPOWTimestamp):], req.SupportID)
resp.Body.AdvanceW(len(`","s":"`))
hexArea := resp.Body.WriteNBytes(hex.EncodedLen(h.Size()))
- resp.Body.AdvanceW(len(`"}`))
+ resp.Body.AdvanceW(len(`"`))
+ appendSupportID(resp.Body, req.SupportID)
// we use the response body temporarily as a buffer
expireAt := tc.Now().Add(lc.Duration)
@@ -86,7 +98,7 @@ func (powValidator) onNew(b *Berghain, req *ValidatorRequest, resp *ValidatorRes
// Write identifier to hash to ensure uniqueness
req.Identifier.WriteTo(h)
- h.Write(timestampArea)
+ h.Write(randomArea)
hex.Encode(hexArea, h.Sum(nil))
@@ -94,27 +106,33 @@ func (powValidator) onNew(b *Berghain, req *ValidatorRequest, resp *ValidatorRes
}
func (powValidator) isValid(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse) error {
- // req.Body should be at least validatorPOWMinSolutionLength
- if len(req.Body) <= validatorPOWMinSolutionLength {
- // invalid solution data
+ req.SupportID = nil
+ if len(req.Body) < validatorPOWMinSolutionLength || len(req.Body) > validatorPOWMaxSolutionLength {
return ErrInvalidLength
}
body := buffer.NewSliceBufferWithSlice(req.Body)
-
- timestampArea := body.ReadNBytes(len(validatorPOWRandom))
- body.AdvanceR(1) // Skip padding character
+ randomArea := body.ReadNBytes(len(validatorPOWRandom))
+ if separator := body.ReadNBytes(1); len(separator) != 1 || separator[0] != '-' {
+ return ErrInvalidLength
+ }
sumArea := body.ReadNBytes(len(validatorPOWHash))
- body.AdvanceR(1) // Skip padding character
+ if separator := body.ReadNBytes(1); len(separator) != 1 || separator[0] != '-' {
+ return ErrInvalidLength
+ }
solArea := body.ReadBytes()
+ for _, c := range solArea {
+ if c < '0' || c > '9' {
+ return ErrInvalidLength
+ }
+ }
h := b.acquireHMAC()
defer b.releaseHMAC(h)
// Write identifier to hash to ensure uniqueness
req.Identifier.WriteTo(h)
-
- h.Write(timestampArea)
+ h.Write(randomArea)
// we use the response body temporarily as a buffer
defer resp.Body.Reset()
@@ -128,7 +146,14 @@ func (powValidator) isValid(b *Berghain, req *ValidatorRequest, resp *ValidatorR
}
resp.Body.Reset()
- expirArea := resp.Body.WriteNBytes(hex.DecodedLen(len(validatorPOWRandom)))
+ supportID := randomArea[len(validatorPOWTimestamp):]
+ if !ValidSupportID(supportID) {
+ return ErrInvalidLength
+ }
+ req.SupportID = supportID
+ timestampArea := randomArea[:len(validatorPOWTimestamp)]
+
+ expirArea := resp.Body.WriteNBytes(hex.DecodedLen(len(validatorPOWTimestamp)))
if _, err := hex.Decode(expirArea, timestampArea); err != nil {
return err
}
@@ -141,7 +166,7 @@ func (powValidator) isValid(b *Berghain, req *ValidatorRequest, resp *ValidatorR
sha := acquireSHA256()
defer releaseSHA256(sha)
- sha.Write(timestampArea)
+ sha.Write(randomArea)
sha.Write(solArea)
sum := sha.Sum(nil)
diff --git a/validator_pow_test.go b/validator_pow_test.go
index e3e932c..e3670e5 100644
--- a/validator_pow_test.go
+++ b/validator_pow_test.go
@@ -18,6 +18,7 @@ func solvePOW(tb testing.TB, b []byte) ([]byte, error) {
T int `json:"t"`
R string `json:"r"`
S string `json:"s"`
+ I string `json:"i"`
}
var p powChallenge
@@ -37,8 +38,7 @@ func solvePOW(tb testing.TB, b []byte) ([]byte, error) {
h.Write([]byte(is))
if bytes.HasPrefix(h.Sum(nil), []byte{0x00, 0x00}) {
- solution := p.R + "-" + p.S + "-" + is
- return []byte(solution), nil
+ return []byte(p.R + "-" + p.S + "-" + is), nil
}
h.Reset()
@@ -46,6 +46,10 @@ func solvePOW(tb testing.TB, b []byte) ([]byte, error) {
panic("unreachable")
}
+func expectedPOWChallengeLength(supportID []byte) int {
+ return len(validatorPOWChallengeTemplate) + len(`,"i":""`) + len(supportID)
+}
+
func Test_validatorPOW(t *testing.T) {
bh := NewBerghain(generateSecret(t))
@@ -63,13 +67,23 @@ func Test_validatorPOW(t *testing.T) {
Level: 1,
}
req.Method = http.MethodGet
+ req.SupportID = []byte("bh@123e4567-e89b-12d3-a456-426614174000")
if err := validatorPOW(bh, req, resp); err != nil {
t.Errorf("validator failed: %v", err)
}
- if resp.Body.Len() != len(validatorPOWChallengeTemplate) {
- t.Errorf("invalid challenge response length: %d != %d", len(validatorPOWChallengeTemplate), resp.Body.Len())
+ if resp.Body.Len() != expectedPOWChallengeLength(req.SupportID) {
+ t.Errorf("invalid challenge response length: %d", resp.Body.Len())
+ }
+ var challenge struct {
+ SupportID string `json:"i"`
+ }
+ if err := json.Unmarshal(resp.Body.ReadBytes(), &challenge); err != nil {
+ t.Fatalf("decode challenge: %v", err)
+ }
+ if challenge.SupportID != string(req.SupportID) {
+ t.Fatalf("support ID = %q, want %q", challenge.SupportID, req.SupportID)
}
solution, err := solvePOW(t, resp.Body.ReadBytes())
@@ -79,6 +93,12 @@ func Test_validatorPOW(t *testing.T) {
// Do another request but this time as POST and with the solution.
req.Method = http.MethodPost
+ changed := bytes.Clone(solution)
+ changed[len(validatorPOWRandom)-1] = '1'
+ req.Body = changed
+ if err := validatorPOW(bh, req, resp); err != ErrInvalidHMAC {
+ t.Errorf("changed support ID error = %v, want %v", err, ErrInvalidHMAC)
+ }
req.Body = solution
if err := validatorPOW(bh, req, resp); err != nil {
t.Errorf("validator failed: %v", err)
@@ -111,13 +131,14 @@ func Test_validatorPOW_unique(t *testing.T) {
Level: 1,
}
req.Method = http.MethodGet
+ req.SupportID = []byte("bh@123e4567-e89b-12d3-a456-426614174000")
if err := validatorPOW(bh, req, resp); err != nil {
t.Errorf("validator failed: %v", err)
}
- if resp.Body.Len() != len(validatorPOWChallengeTemplate) {
- t.Errorf("invalid challenge response length: %d != %d", len(validatorPOWChallengeTemplate), resp.Body.Len())
+ if resp.Body.Len() != expectedPOWChallengeLength(req.SupportID) {
+ t.Errorf("invalid challenge response length: %d", resp.Body.Len())
}
solution, err := solvePOW(t, resp.Body.ReadBytes())
@@ -155,6 +176,7 @@ func Benchmark_validatorPOW_GET(b *testing.B) {
req.Identifier = &ri
req.Method = http.MethodGet
+ req.SupportID = []byte("bh@123e4567-e89b-12d3-a456-426614174000")
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
@@ -187,7 +209,9 @@ func Benchmark_validatorPOW_POST(b *testing.B) {
req, resp := AcquireValidatorRequest(), AcquireValidatorResponse()
defer ReleaseValidatorRequest(req)
+ req.Identifier = &ri
req.Method = http.MethodGet
+ req.SupportID = []byte("bh@123e4567-e89b-12d3-a456-426614174000")
if err := validatorPOW(bh, req, resp); err != nil {
b.Errorf("validator failed: %v", err)
@@ -199,7 +223,6 @@ func Benchmark_validatorPOW_POST(b *testing.B) {
}
ReleaseValidatorResponse(resp)
- req.Identifier = &ri
req.Method = http.MethodPost
req.Body = solution
diff --git a/validators.go b/validators.go
index 7531012..918efc6 100644
--- a/validators.go
+++ b/validators.go
@@ -49,6 +49,7 @@ type ValidatorRequest struct {
Method string
Body []byte
Identifier *RequestIdentifier
+ SupportID []byte
}
var validatorRequestPool = sync.Pool{
@@ -65,9 +66,16 @@ func ReleaseValidatorRequest(v *ValidatorRequest) {
v.Method = ""
v.Body = nil
v.Identifier = nil
+ v.SupportID = nil
validatorRequestPool.Put(v)
}
+func appendSupportID(body *buffer.SliceBuffer, id []byte) {
+ copy(body.WriteNBytes(len(`,"i":"`)), []byte(`,"i":"`))
+ copy(body.WriteNBytes(len(id)), id)
+ copy(body.WriteNBytes(len(`"}`)), []byte(`"}`))
+}
+
func (v ValidationType) RunValidator(b *Berghain, req *ValidatorRequest, resp *ValidatorResponse) error {
switch v {
case ValidationTypeNone:
diff --git a/web/index.html b/web/index.html
index 0b078b9..9485630 100644
--- a/web/index.html
+++ b/web/index.html
@@ -27,6 +27,7 @@
Request on Hold
If you suspect that you are being blocked by mistake, make sure that you have the
latest browser version installed and all active browser plugins are disabled.
If you see this and the verification repeats, please try a different browser.
+