Skip to content

Commit b31d260

Browse files
committed
Fix the cloudflare turnstile token
1 parent 92b6822 commit b31d260

File tree

11 files changed

+203
-38
lines changed

11 files changed

+203
-38
lines changed

api/pkg/di/container.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,18 @@ func (container *Container) MessageHandlerValidator() (validator *validators.Mes
487487
container.Logger(),
488488
container.Tracer(),
489489
container.PhoneService(),
490+
container.TurnstileTokenValidator(),
491+
)
492+
}
493+
494+
// TurnstileTokenValidator creates a new instance of validators.TurnstileTokenValidator
495+
func (container *Container) TurnstileTokenValidator() (validator *validators.TurnstileTokenValidator) {
496+
container.logger.Debug(fmt.Sprintf("creating %T", validator))
497+
return validators.NewTurnstileTokenValidator(
498+
container.Logger(),
499+
container.Tracer(),
500+
os.Getenv("CLOUDFLARE_TURNSTILE_SECRET_KEY"),
501+
container.HTTPClient("turnstile"),
490502
)
491503
}
492504

api/pkg/handlers/message_handler.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -471,6 +471,7 @@ func (h *MessageHandler) PostCallMissed(c *fiber.Ctx) error {
471471
// @Tags Messages
472472
// @Accept json
473473
// @Produce json
474+
// @Param token header string true "Cloudflare turnstile token https://www.cloudflare.com/en-gb/application-services/products/turnstile/"
474475
// @Param owners query string true "the owner's phone numbers" default(+18005550199,+18005550100)
475476
// @Param skip query int false "number of messages to skip" minimum(0)
476477
// @Param query query string false "filter messages containing query"
@@ -492,6 +493,9 @@ func (h *MessageHandler) Search(c *fiber.Ctx) error {
492493
return h.responseBadRequest(c, err)
493494
}
494495

496+
request.IPAddress = c.IP()
497+
request.Token = c.Get("token")
498+
495499
if errors := h.validator.ValidateMessageSearch(ctx, request.Sanitize()); len(errors) != 0 {
496500
msg := fmt.Sprintf("validation errors [%s], while searching messages [%+#v]", spew.Sdump(errors), request)
497501
ctxLogger.Warn(stacktrace.NewError(msg))

api/pkg/requests/message_search_request.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ type MessageSearch struct {
2121
SortBy string `json:"sort_by" query:"sort_by"`
2222
SortDescending bool `json:"sort_descending" query:"sort_descending"`
2323
Limit string `json:"limit" query:"limit"`
24+
25+
IPAddress string `json:"ip_address" swaggerignore:"true"`
26+
Token string `json:"token" swaggerignore:"true"`
2427
}
2528

2629
// Sanitize sets defaults to MessageSearch

api/pkg/validators/message_handler_validator.go

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,24 @@ import (
2020
// MessageHandlerValidator validates models used in handlers.MessageHandler
2121
type MessageHandlerValidator struct {
2222
validator
23-
logger telemetry.Logger
24-
tracer telemetry.Tracer
25-
phoneService *services.PhoneService
23+
logger telemetry.Logger
24+
tracer telemetry.Tracer
25+
phoneService *services.PhoneService
26+
tokenValidator *TurnstileTokenValidator
2627
}
2728

2829
// NewMessageHandlerValidator creates a new handlers.MessageHandler validator
2930
func NewMessageHandlerValidator(
3031
logger telemetry.Logger,
3132
tracer telemetry.Tracer,
3233
phoneService *services.PhoneService,
34+
tokenValidator *TurnstileTokenValidator,
3335
) (v *MessageHandlerValidator) {
3436
return &MessageHandlerValidator{
35-
logger: logger.WithService(fmt.Sprintf("%T", v)),
36-
tracer: tracer,
37-
phoneService: phoneService,
37+
logger: logger.WithService(fmt.Sprintf("%T", v)),
38+
tracer: tracer,
39+
phoneService: phoneService,
40+
tokenValidator: tokenValidator,
3841
}
3942
}
4043

@@ -208,7 +211,7 @@ func (validator MessageHandlerValidator) ValidateMessageIndex(_ context.Context,
208211
}
209212

210213
// ValidateMessageSearch validates the requests.MessageSearch request
211-
func (validator MessageHandlerValidator) ValidateMessageSearch(_ context.Context, request requests.MessageSearch) url.Values {
214+
func (validator MessageHandlerValidator) ValidateMessageSearch(ctx context.Context, request requests.MessageSearch) url.Values {
212215
v := govalidator.New(govalidator.Options{
213216
Data: &request,
214217
Rules: govalidator.MapData{
@@ -257,7 +260,17 @@ func (validator MessageHandlerValidator) ValidateMessageSearch(_ context.Context
257260
},
258261
},
259262
})
260-
return v.ValidateStruct()
263+
264+
errors := v.ValidateStruct()
265+
if len(errors) > 0 {
266+
return errors
267+
}
268+
269+
if !validator.tokenValidator.ValidateToken(ctx, request.IPAddress, request.Token) {
270+
errors.Add("token", "The captcha token from turnstile is invalid")
271+
}
272+
273+
return errors
261274
}
262275

263276
// ValidateMessageEvent validates the requests.MessageEvent request
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package validators
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"time"
11+
12+
"github.com/NdoleStudio/httpsms/pkg/telemetry"
13+
"github.com/palantir/stacktrace"
14+
)
15+
16+
// TurnstileTokenValidator validates the token used to validate captchas from cloudflare
17+
type TurnstileTokenValidator struct {
18+
logger telemetry.Logger
19+
tracer telemetry.Tracer
20+
secretKey string
21+
httpClient *http.Client
22+
}
23+
24+
type turnstileVerifyResponse struct {
25+
Success bool `json:"success"`
26+
ChallengeTs time.Time `json:"challenge_ts"`
27+
Hostname string `json:"hostname"`
28+
ErrorCodes []any `json:"error-codes"`
29+
Action string `json:"action"`
30+
Cdata string `json:"cdata"`
31+
Metadata struct {
32+
EphemeralID string `json:"ephemeral_id"`
33+
} `json:"metadata"`
34+
}
35+
36+
// NewTurnstileTokenValidator creates a new TurnstileTokenValidator
37+
func NewTurnstileTokenValidator(logger telemetry.Logger, tracer telemetry.Tracer, secretKey string, httpClient *http.Client) *TurnstileTokenValidator {
38+
return &TurnstileTokenValidator{
39+
logger.WithService(fmt.Sprintf("%T", &TurnstileTokenValidator{})),
40+
tracer,
41+
secretKey,
42+
httpClient,
43+
}
44+
}
45+
46+
// ValidateToken validates the cloudflare turnstile token
47+
// https://developers.cloudflare.com/turnstile/get-started/server-side-validation/
48+
func (v *TurnstileTokenValidator) ValidateToken(ctx context.Context, ipAddress, token string) bool {
49+
ctx, span, ctxLogger := v.tracer.StartWithLogger(ctx, v.logger)
50+
defer span.End()
51+
52+
payload, err := json.Marshal(map[string]string{
53+
"secret": v.secretKey,
54+
"response": token,
55+
"remoteip": ipAddress,
56+
})
57+
if err != nil {
58+
ctxLogger.Error(stacktrace.Propagate(err, "failed to marshal payload"))
59+
return false
60+
}
61+
62+
request, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://challenges.cloudflare.com/turnstile/v0/siteverify", bytes.NewBuffer(payload))
63+
if err != nil {
64+
ctxLogger.Error(stacktrace.Propagate(err, "failed to create http request request"))
65+
return false
66+
}
67+
68+
request.Header.Set("Content-Type", "application/json")
69+
response, err := v.httpClient.Do(request)
70+
if err != nil {
71+
ctxLogger.Error(stacktrace.Propagate(err, fmt.Sprintf("failed to send http request to [%s]", request.URL.String())))
72+
return false
73+
}
74+
defer response.Body.Close()
75+
76+
body, err := io.ReadAll(response.Body)
77+
if err != nil {
78+
ctxLogger.Error(stacktrace.Propagate(err, "failed to read response body from cloudflare turnstile"))
79+
return false
80+
}
81+
82+
ctxLogger.Info(fmt.Sprintf("successfully validated token with cloudflare with response [%s]", body))
83+
84+
data := new(turnstileVerifyResponse)
85+
if err = json.Unmarshal(body, data); err != nil {
86+
ctxLogger.Error(stacktrace.Propagate(err, "failed to unmarshal response from cloudflare turnstile"))
87+
return false
88+
}
89+
90+
return data.Success
91+
}

web/.env.production

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ FIREBASE_STORAGE_BUCKET=httpsms-86c51.appspot.com
1717
FIREBASE_MESSAGING_SENDER_ID=877524083399
1818
FIREBASE_APP_ID=1:877524083399:web:430d6a29a0d808946514e2
1919
FIREBASE_MEASUREMENT_ID=G-EZ5W9DVK8T
20+
21+
CLOUDFLARE_TURNSTILE_SITE_KEY=0x4AAAAAAA6Hpp8SDyMMPhWg

web/layouts/default.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
class="feedback-btn"
66
href="https://httpsms.featurebase.app"
77
color="#82a865"
8-
flat
98
large
109
>
1110
<v-icon left>{{ mdiBullhorn }}</v-icon>

web/models/message.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface SearchMessagesRequest {
2222
statuses: string[]
2323
query: string
2424
sort_by: string
25+
token?: string
2526
sort_descending: boolean
2627
skip: number
2728
limit: number

web/nuxt.config.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ export default {
2222
async: true,
2323
defer: true,
2424
},
25+
{
26+
hid: 'cloudflare',
27+
src: 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit',
28+
},
2529
],
2630
meta: [
2731
{ charset: 'utf-8' },
@@ -150,6 +154,7 @@ export default {
150154
publicRuntimeConfig: {
151155
checkoutURL: process.env.CHECKOUT_URL,
152156
enterpriseCheckoutURL: process.env.ENTERPRISE_CHECKOUT_URL,
157+
cloudflareTurnstileSiteKey: process.env.CLOUDFLARE_TURNSTILE_SITE_KEY,
153158
},
154159

155160
// Build Configuration: https://go.nuxtjs.dev/config-build

web/pages/search-messages/index.vue

Lines changed: 61 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
></v-text-field>
8888
</v-col>
8989
<v-col cols="4">
90+
<div id="cloudflare-turnstile" class="d-none"></div>
9091
<v-btn
9192
:loading="loading"
9293
:disabled="loading"
@@ -294,6 +295,18 @@ import {
294295
import { formatPhoneNumber } from '~/plugins/filters'
295296
import { SearchMessagesRequest } from '~/models/message'
296297
298+
interface Turnstile {
299+
ready(callback: () => void): void
300+
render(
301+
container: string | HTMLElement,
302+
params?: {
303+
sitekey: string
304+
callback?: (token: string) => void
305+
'error-callback'?: ((error: string) => void) | undefined
306+
},
307+
): string | null | undefined
308+
}
309+
297310
export default Vue.extend({
298311
name: 'SearchMessagesIndex',
299312
middleware: ['auth'],
@@ -386,6 +399,22 @@ export default Vue.extend({
386399
},
387400
388401
methods: {
402+
getCaptcha(): Promise<string> {
403+
return new Promise<string>((resolve, reject) => {
404+
const turnstile = (window as any).turnstile as Turnstile
405+
turnstile.ready(() => {
406+
turnstile.render('#cloudflare-turnstile', {
407+
sitekey: this.$config.cloudflareTurnstileSiteKey,
408+
callback: (token) => {
409+
resolve(token)
410+
},
411+
'error-callback': (error: string) => {
412+
reject(error)
413+
},
414+
})
415+
})
416+
})
417+
},
389418
exportMessages() {
390419
let csvContent = 'data:text/csv;charset=utf-8,'
391420
csvContent +=
@@ -456,35 +485,38 @@ export default Vue.extend({
456485
this.options.page = 1
457486
}
458487
459-
this.$store
460-
.dispatch('searchMessages', {
461-
owners: this.formOwners,
462-
types: this.formTypes,
463-
statuses: this.formStatuses,
464-
query: this.formQuery,
465-
sort_by: this.options.sortBy[0],
466-
sort_descending: this.options.sortDesc[0],
467-
skip: (this.options.page - 1) * this.options.itemsPerPage,
468-
limit: this.options.itemsPerPage,
469-
} as SearchMessagesRequest)
470-
.then((messages: EntitiesMessage[]) => {
471-
this.messages = messages
472-
this.totalMessages =
473-
(this.options.page - 1) * this.options.itemsPerPage +
474-
messages.length
475-
if (messages.length === this.options.itemsPerPage) {
476-
this.totalMessages = this.totalMessages + 1
477-
}
478-
})
479-
.catch((error: AxiosError<ResponsesUnprocessableEntity>) => {
480-
this.errorTitle = capitalize(
481-
error.response?.data?.message ?? 'Error while searching messages',
482-
)
483-
this.errorMessages = getErrorMessages(error)
484-
})
485-
.finally(() => {
486-
this.loading = false
487-
})
488+
this.getCaptcha().then((token: string) => {
489+
this.$store
490+
.dispatch('searchMessages', {
491+
token,
492+
owners: this.formOwners,
493+
types: this.formTypes,
494+
statuses: this.formStatuses,
495+
query: this.formQuery,
496+
sort_by: this.options.sortBy[0],
497+
sort_descending: this.options.sortDesc[0],
498+
skip: (this.options.page - 1) * this.options.itemsPerPage,
499+
limit: this.options.itemsPerPage,
500+
} as SearchMessagesRequest)
501+
.then((messages: EntitiesMessage[]) => {
502+
this.messages = messages
503+
this.totalMessages =
504+
(this.options.page - 1) * this.options.itemsPerPage +
505+
messages.length
506+
if (messages.length === this.options.itemsPerPage) {
507+
this.totalMessages = this.totalMessages + 1
508+
}
509+
})
510+
.catch((error: AxiosError<ResponsesUnprocessableEntity>) => {
511+
this.errorTitle = capitalize(
512+
error.response?.data?.message ?? 'Error while searching messages',
513+
)
514+
this.errorMessages = getErrorMessages(error)
515+
})
516+
.finally(() => {
517+
this.loading = false
518+
})
519+
})
488520
},
489521
},
490522
})

0 commit comments

Comments
 (0)