From 19587ab633fb084bd018ee8b0e9801b9f798f18c Mon Sep 17 00:00:00 2001 From: fionera Date: Fri, 10 Jul 2026 13:17:10 +0000 Subject: [PATCH] feat: add challenge support IDs --- berghain_test.go | 1 + cmd/spop/frontend.go | 25 +++++++++++ cmd/spop/log.go | 1 + docs/index.html | 13 +++--- examples/haproxy/berghain.cfg | 2 +- examples/haproxy/haproxy-ua-policy.cfg | 3 ++ examples/haproxy/haproxy.cfg | 6 ++- support_id.go | 26 ++++++++++++ support_id_test.go | 26 ++++++++++++ test/e2e/haproxy.cfg | 8 ++++ validator_none.go | 7 +++- validator_none_test.go | 15 +++++-- validator_pow.go | 57 ++++++++++++++++++-------- validator_pow_test.go | 37 +++++++++++++---- validators.go | 8 ++++ web/index.html | 1 + web/src/challange/challanger.js | 9 ++-- web/src/challange/challanges.js | 4 +- web/src/main.js | 6 ++- web/src/style.scss | 8 ++++ 20 files changed, 221 insertions(+), 42 deletions(-) create mode 100644 support_id.go create mode 100644 support_id_test.go 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 @@

5. Try another browser

6. Still stuck?

-

If the verification still repeats on an up-to-date, mainstream browser, - report an issue with your browser and operating system so an - operator can investigate.

+

Note the support ID shown on the verification screen (it looks like + bh@<uuid>) and report an issue so an operator can + correlate your report with its challenge logs.

@@ -406,9 +406,10 @@

Report an issue

operator can help.

What to include

-

Tell us your browser and version, your operating system, the address you were trying to - reach and what happened. A screenshot of the verification screen helps too. The more detail - you provide, the faster an operator can reproduce and fix the problem.

+

Include the per-page support ID shown on the verification screen, your browser and version, + your operating system, the address you were trying to reach and what happened. The ID is + randomly generated by your browser and is used only to correlate the report with challenge + logs; it is not derived from your IP address.

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.

+