Split reCAPTCHA compatibility and our own verify endpoint

This commit is contained in:
Taras Kushnir
2025-07-14 16:38:55 +03:00
parent 9b34577ce5
commit 4d98f0dc4e
5 changed files with 136 additions and 72 deletions

View File

@@ -45,13 +45,15 @@ var (
http.CanonicalHeaderKey(common.HeaderContentType): []string{common.ContentTypePlain},
}
verifyResultErrorTest = &puzzle.VerifyResult{
Errors: []puzzle.VerifyError{puzzle.TestPropertyError},
Error: puzzle.TestPropertyError,
}
verifyResultErrorParse = &puzzle.VerifyResult{
Errors: []puzzle.VerifyError{puzzle.ParseResponseError},
Error: puzzle.ParseResponseError,
}
)
type verifyFunc func(w http.ResponseWriter, r *http.Request) (*puzzle.VerifyResult, error)
type Server struct {
Stage string
BusinessDB db.Implementor
@@ -105,12 +107,15 @@ func (a *apiKeyOwnerSource) OwnerID(ctx context.Context, tnow time.Time) (int32,
}
type VerifyResponse struct {
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes,omitempty"`
Success bool `json:"success"`
Origin string `json:"origin,omitempty"`
Timestamp common.JSONTime `json:"timestamp,omitempty"`
Error string `json:"message,omitempty"`
}
type VerifyResponseRecaptchaV2 struct {
VerifyResponse
Success bool `json:"success"`
ErrorCodes []string `json:"error-codes,omitempty"`
ChallengeTS common.JSONTime `json:"challenge_ts"`
Hostname string `json:"hostname"`
}
@@ -202,7 +207,10 @@ func (s *Server) setupWithPrefix(domain string, router *http.ServeMux, corsHandl
apiRateLimiter := s.RateLimiter.RateLimitExFunc(apiKeyLeakyBucketCap, apiKeyLeakInterval)
verifyChain := publicChain.Append(apiRateLimiter, monitoring.Traced, common.TimeoutHandler(5*time.Second), s.Auth.APIKey)
router.Handle(http.MethodPost+" "+prefix+common.VerifyEndpoint, verifyChain.Then(http.MaxBytesHandler(http.HandlerFunc(s.verifyHandler), maxSolutionsBodySize)))
// reCAPTCHA compatibility
router.Handle(http.MethodPost+" "+prefix+common.SiteVerifyEndpoint, verifyChain.Then(http.MaxBytesHandler(s.recaptchaResponseHandler(s.verifyHandler), maxSolutionsBodySize)))
// Private Captcha format
router.Handle(http.MethodPost+" "+prefix+common.VerifyEndpoint, verifyChain.Then(http.MaxBytesHandler(s.pcResponseHandler(s.verifyHandler), maxSolutionsBodySize)))
// "root" access
router.Handle(prefix+"{$}", publicChain.Then(common.HttpStatus(http.StatusForbidden)))
@@ -340,9 +348,9 @@ func (s *Server) Verify(ctx context.Context, data []byte, expectedOwner puzzle.O
return verifyResultErrorParse, nil
}
result := &puzzle.VerifyResult{Errors: make([]puzzle.VerifyError, 0, 1)}
result := &puzzle.VerifyResult{}
puzzleObject, property, perr := s.verifyPuzzleValid(ctx, verifyPayload, tnow)
result.AddError(perr)
result.SetError(perr)
if puzzleObject != nil && !puzzleObject.IsZero() {
result.CreatedAt = puzzleObject.Expiration.Add(-puzzle.DefaultValidityPeriod)
}
@@ -360,7 +368,7 @@ func (s *Server) Verify(ctx context.Context, data []byte, expectedOwner puzzle.O
if (property.OrgOwnerID.Int32 != ownerID) && (property.CreatorID.Int32 != ownerID) {
slog.WarnContext(ctx, "Org owner does not match expected owner", "expectedOwner", ownerID,
"orgOwner", property.OrgOwnerID.Int32, "propertyCreator", property.CreatorID.Int32)
result.AddError(puzzle.WrongOwnerError)
result.SetError(puzzle.WrongOwnerError)
return result, nil
}
} else {
@@ -376,7 +384,7 @@ func (s *Server) Verify(ctx context.Context, data []byte, expectedOwner puzzle.O
"propertyID", property.ID)
s.addVerifyRecord(ctx, puzzleObject, property, verr)
result.AddError(verr)
result.SetError(verr)
return result, nil
}
@@ -391,14 +399,14 @@ func (s *Server) Verify(ctx context.Context, data []byte, expectedOwner puzzle.O
return result, nil
}
func (s *Server) verifyHandler(w http.ResponseWriter, r *http.Request) {
func (s *Server) verifyHandler(w http.ResponseWriter, r *http.Request) (*puzzle.VerifyResult, error) {
ctx := r.Context()
data, err := io.ReadAll(r.Body)
if err != nil {
slog.ErrorContext(ctx, "Failed to read request body", common.ErrAttr(err))
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
return
return nil, err
}
result, err := s.Verify(ctx, data, &apiKeyOwnerSource{Store: s.BusinessDB}, time.Now().UTC())
@@ -411,7 +419,7 @@ func (s *Server) verifyHandler(w http.ResponseWriter, r *http.Request) {
default:
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}
return
return nil, err
}
if apiKey, ok := ctx.Value(common.APIKeyContextKey).(*dbgen.APIKey); ok && (apiKey != nil) {
@@ -421,25 +429,79 @@ func (s *Server) verifyHandler(w http.ResponseWriter, r *http.Request) {
s.RateLimiter.UpdateRequestLimits(r, uint32(apiKey.RequestsBurst), time.Duration(interval))
}
vr2 := &VerifyResponseRecaptchaV2{
VerifyResponse: VerifyResponse{
Success: result.Success(),
ErrorCodes: result.ErrorsToStrings(),
},
ChallengeTS: common.JSONTime(result.CreatedAt),
Hostname: result.Domain,
}
return result, nil
}
var response interface{} = vr2
if recaptchaCompatVersion := r.Header.Get(common.HeaderCaptchaCompat); recaptchaCompatVersion == "rcV3" {
response = &VerifyResponseRecaptchaV3{
VerifyResponseRecaptchaV2: *vr2,
Action: "",
Score: 0.5,
func (s *Server) recaptchaResponseHandler(vf verifyFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
result, err := vf(w, r)
if err != nil {
return
}
}
common.SendJSONResponse(ctx, w, response, common.NoCacheHeaders)
vr2 := &VerifyResponseRecaptchaV2{
Success: result.Success(),
ErrorCodes: result.ErrorsToStrings(),
ChallengeTS: common.JSONTime(result.CreatedAt),
Hostname: result.Domain,
}
var response interface{} = vr2
if recaptchaCompatVersion := r.Header.Get(common.HeaderCaptchaCompat); recaptchaCompatVersion == "rcV3" {
response = &VerifyResponseRecaptchaV3{
VerifyResponseRecaptchaV2: *vr2,
Action: "",
Score: 0.5,
}
}
common.SendJSONResponse(r.Context(), w, response, common.NoCacheHeaders)
})
}
func (s *Server) pcResponseHandler(vf verifyFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
result, err := vf(w, r)
if err != nil {
return
}
response := &VerifyResponse{
Success: result.Success(),
Origin: result.Domain,
Timestamp: common.JSONTime(result.CreatedAt),
Error: result.ErrorString(),
}
common.SendJSONResponse(r.Context(), w, response, common.NoCacheHeaders)
})
}
func (s *Server) privateCaptchaResponseHandler(vf verifyFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
result, err := vf(w, r)
if err != nil {
return
}
vr2 := &VerifyResponseRecaptchaV2{
Success: result.Success(),
ErrorCodes: result.ErrorsToStrings(),
ChallengeTS: common.JSONTime(result.CreatedAt),
Hostname: result.Domain,
}
var response interface{} = vr2
if recaptchaCompatVersion := r.Header.Get(common.HeaderCaptchaCompat); recaptchaCompatVersion == "rcV3" {
response = &VerifyResponseRecaptchaV3{
VerifyResponseRecaptchaV2: *vr2,
Action: "",
Score: 0.5,
}
}
common.SendJSONResponse(r.Context(), w, response, common.NoCacheHeaders)
})
}
func (s *Server) addVerifyRecord(ctx context.Context, p *puzzle.Puzzle, property *dbgen.Property, verr puzzle.VerifyError) {

View File

@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -23,10 +24,8 @@ import (
func TestSerializeResponse(t *testing.T) {
v := VerifyResponseRecaptchaV3{
VerifyResponseRecaptchaV2: VerifyResponseRecaptchaV2{
VerifyResponse: VerifyResponse{
Success: false,
ErrorCodes: []string{puzzle.VerifyErrorOther.String()},
},
Success: false,
ErrorCodes: []string{puzzle.VerifyErrorOther.String()},
ChallengeTS: common.JSONTimeNow(),
Hostname: "hostname.com",
},
@@ -40,13 +39,13 @@ func TestSerializeResponse(t *testing.T) {
}
}
func verifySuite(response, secret string) (*http.Response, error) {
func verifySuite(response, secret, endpoint string) (*http.Response, error) {
srv := http.NewServeMux()
s.Setup(srv, "", true /*verbose*/, common.NoopMiddleware)
//srv.HandleFunc("/", catchAll)
req, err := http.NewRequest(http.MethodPost, "/"+common.VerifyEndpoint, strings.NewReader(response))
req, err := http.NewRequest(http.MethodPost, "/"+endpoint, strings.NewReader(response))
if err != nil {
return nil, err
}
@@ -130,7 +129,7 @@ func TestVerifyPuzzle(t *testing.T) {
t.Fatal(err)
}
resp, err := verifySuite(payload, apiKey)
resp, err := verifySuite(payload, apiKey, common.VerifyEndpoint)
if err != nil {
t.Fatal(err)
}
@@ -140,13 +139,13 @@ func TestVerifyPuzzle(t *testing.T) {
}
}
func checkVerifyError(resp *http.Response, expected puzzle.VerifyError) error {
func checkSiteVerifyError(resp *http.Response, expected puzzle.VerifyError) error {
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
response := &VerifyResponse{}
response := &VerifyResponseRecaptchaV2{}
err = json.Unmarshal(body, &response)
if err != nil {
return err
@@ -154,15 +153,15 @@ func checkVerifyError(resp *http.Response, expected puzzle.VerifyError) error {
if expected == puzzle.VerifyNoError {
if !response.Success {
return fmt.Errorf("Expected successful verification")
return errors.New("expected successful verification")
}
if len(response.ErrorCodes) > 0 {
return fmt.Errorf("Error codes present in response")
return errors.New("error code present in response")
}
} else {
if len(response.ErrorCodes) == 0 {
return fmt.Errorf("No error codes in response")
return errors.New("no error code in response")
}
if response.ErrorCodes[0] != expected.String() {
@@ -183,7 +182,7 @@ func TestVerifyPuzzleReplay(t *testing.T) {
t.Fatal(err)
}
resp, err := verifySuite(payload, apiKey)
resp, err := verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
if err != nil {
t.Fatal(err)
}
@@ -193,12 +192,12 @@ func TestVerifyPuzzleReplay(t *testing.T) {
}
// now second time the same
resp, err = verifySuite(payload, apiKey)
resp, err = verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
if err != nil {
t.Fatal(err)
}
if err := checkVerifyError(resp, puzzle.VerifiedBeforeError); err != nil {
if err := checkSiteVerifyError(resp, puzzle.VerifiedBeforeError); err != nil {
t.Fatal(err)
}
}
@@ -220,7 +219,7 @@ func TestVerifyPuzzleAllowReplay(t *testing.T) {
// this should be still cached so we don't need to actually update DB
property.AllowReplay = true
resp, err := verifySuite(payload, apiKey)
resp, err := verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
if err != nil {
t.Fatal(err)
}
@@ -230,12 +229,12 @@ func TestVerifyPuzzleAllowReplay(t *testing.T) {
}
// now second time the same
resp, err = verifySuite(payload, apiKey)
resp, err = verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
if err != nil {
t.Fatal(err)
}
if err := checkVerifyError(resp, puzzle.VerifyNoError); err != nil {
if err := checkSiteVerifyError(resp, puzzle.VerifyNoError); err != nil {
t.Fatal(err)
}
}
@@ -276,7 +275,7 @@ func TestVerifyCachePriority(t *testing.T) {
cache.SetMissing(ctx, db.APIKeyCacheKey(secret))
resp, err := verifySuite(fmt.Sprintf("%s.%s", solutionsStr, puzzleStr), secret)
resp, err := verifySuite(fmt.Sprintf("%s.%s", solutionsStr, puzzleStr), secret, common.SiteVerifyEndpoint)
if err != nil {
t.Fatal(err)
}
@@ -298,7 +297,7 @@ func TestVerifyInvalidKey(t *testing.T) {
t.Fatal(err)
}
resp, err := verifySuite(payload, db.UUIDToSecret(*randomUUID()))
resp, err := verifySuite(payload, db.UUIDToSecret(*randomUUID()), common.VerifyEndpoint)
if err != nil {
t.Fatal(err)
}
@@ -332,7 +331,7 @@ func TestVerifyExpiredKey(t *testing.T) {
t.Fatal(err)
}
resp, err := verifySuite("a.b.c", db.UUIDToSecret(apikey.ExternalID))
resp, err := verifySuite("a.b.c", db.UUIDToSecret(apikey.ExternalID), common.SiteVerifyEndpoint)
if err != nil {
t.Fatal(err)
}
@@ -361,7 +360,7 @@ func TestVerifyMaintenanceMode(t *testing.T) {
store.UpdateConfig(true /*maintenance mode*/)
defer store.UpdateConfig(false /*maintenance mode*/)
resp, err := verifySuite(payload, apiKey)
resp, err := verifySuite(payload, apiKey, common.SiteVerifyEndpoint)
if err != nil {
t.Fatal(err)
}
@@ -370,7 +369,7 @@ func TestVerifyMaintenanceMode(t *testing.T) {
t.Errorf("Unexpected submit status code %d", resp.StatusCode)
}
if err := checkVerifyError(resp, puzzle.MaintenanceModeError); err != nil {
if err := checkSiteVerifyError(resp, puzzle.MaintenanceModeError); err != nil {
t.Fatal(err)
}
}
@@ -400,7 +399,7 @@ func TestVerifyTestProperty(t *testing.T) {
secret := db.UUIDToSecret(apikey.ExternalID)
resp, err := verifySuite(payload, secret)
resp, err := verifySuite(payload, secret, common.SiteVerifyEndpoint)
if err != nil {
t.Fatal(err)
}
@@ -409,7 +408,7 @@ func TestVerifyTestProperty(t *testing.T) {
t.Errorf("Unexpected verify status code %d", resp.StatusCode)
}
if err := checkVerifyError(resp, puzzle.TestPropertyError); err != nil {
if err := checkSiteVerifyError(resp, puzzle.TestPropertyError); err != nil {
t.Fatal(err)
}
}

View File

@@ -3,7 +3,8 @@ package common
const (
PuzzleEndpoint = "puzzle"
EchoPuzzleEndpoint = "echopuzzle"
VerifyEndpoint = "siteverify"
SiteVerifyEndpoint = "siteverify"
VerifyEndpoint = "verify"
LoginEndpoint = "login"
TwoFactorEndpoint = "2fa"
ResendEndpoint = "resend"

View File

@@ -63,7 +63,7 @@ func TestMain(m *testing.M) {
CookieName: "pcsid",
MaxLifetime: 1 * time.Minute,
},
PuzzleEngine: &fakePuzzleEngine{result: &puzzle.VerifyResult{Errors: []puzzle.VerifyError{puzzle.VerifyNoError}}},
PuzzleEngine: &fakePuzzleEngine{result: &puzzle.VerifyResult{Error: puzzle.VerifyNoError}},
PlanService: planService,
}
@@ -113,7 +113,7 @@ func TestMain(m *testing.M) {
},
Mailer: &email.StubMailer{},
RateLimiter: &ratelimit.StubRateLimiter{},
PuzzleEngine: &fakePuzzleEngine{result: &puzzle.VerifyResult{Errors: []puzzle.VerifyError{puzzle.VerifyNoError}}},
PuzzleEngine: &fakePuzzleEngine{result: &puzzle.VerifyResult{Error: puzzle.VerifyNoError}},
Metrics: monitoring.NewStub(),
PlanService: planService,
}

View File

@@ -7,36 +7,38 @@ import (
)
type VerifyResult struct {
Errors []VerifyError
Error VerifyError
CreatedAt time.Time
Domain string
}
func (vr *VerifyResult) Success() bool {
return (len(vr.Errors) == 0) ||
((len(vr.Errors) == 1) &&
(vr.Errors[0] == VerifyNoError) ||
(vr.Errors[0] == MaintenanceModeError) ||
(vr.Errors[0] == TestPropertyError))
return (vr.Error == VerifyNoError) ||
(vr.Error == MaintenanceModeError) ||
(vr.Error == TestPropertyError)
}
func (vr *VerifyResult) AddError(verr VerifyError) {
if verr != VerifyNoError {
vr.Errors = append(vr.Errors, verr)
func (vr *VerifyResult) SetError(verr VerifyError) {
vr.Error = verr
}
func (vr *VerifyResult) ErrorString() string {
if vr.Error == VerifyNoError {
return ""
}
return vr.Error.String()
}
func (vr *VerifyResult) ErrorsToStrings() []string {
if len(vr.Errors) == 0 {
if vr.Error == VerifyNoError {
return []string{}
}
result := make([]string, 0, len(vr.Errors))
result := make([]string, 0, 1)
for _, err := range vr.Errors {
if err != VerifyNoError {
result = append(result, err.String())
}
if vr.Error != VerifyNoError {
result = append(result, vr.Error.String())
}
return result