| @@ -66,6 +66,12 @@ Class OrderApiController_Class | |||
| Exit Sub | |||
| End If | |||
| Dim turnstileToken : turnstileToken = Request.ServerVariables("HTTP_X_TURNSTILE_TOKEN") | |||
| If Not VerifyTurnstileToken(turnstileToken, Request.ServerVariables("REMOTE_ADDR")) Then | |||
| WriteJsonError "403 Forbidden", "Verification challenge failed. Please refresh the page and try again." | |||
| Exit Sub | |||
| End If | |||
| Dim answers | |||
| On Error Resume Next | |||
| json().loadJSON GetRawJsonFromRequest() | |||
| @@ -29,11 +29,12 @@ Class OrderController_Class | |||
| ' The order form itself is a placeholder for now - this only confirms the token is valid. | |||
| '------------------------------------------------------------------------------------------------------------------- | |||
| Public Sub Continue() | |||
| Dim token, order, isValid, csrfToken, municipalityName | |||
| Dim token, order, isValid, csrfToken, municipalityName, turnstileSiteKey | |||
| token = Trim(Request.QueryString("token")) | |||
| isValid = False | |||
| csrfToken = "" | |||
| municipalityName = "" | |||
| turnstileSiteKey = GetAppSetting("TurnstileSiteKey") | |||
| Set order = Nothing | |||
| If Len(token) > 0 Then | |||
| @@ -28,7 +28,7 @@ Class RequestOrderController_Class | |||
| ' GET: show the "request an order" form (email + jurisdiction number) | |||
| '------------------------------------------------------------------------------------------------------------------- | |||
| Public Sub Index() | |||
| Dim formValues, emailValue, jurisdictionValue, csrfToken | |||
| Dim formValues, emailValue, jurisdictionValue, csrfToken, turnstileSiteKey | |||
| Set formValues = FormCache().DeserializeForm("RequestOrder") | |||
| FormCache().ClearForm "RequestOrder" | |||
| @@ -41,6 +41,7 @@ Class RequestOrderController_Class | |||
| End If | |||
| csrfToken = HTMLSecurity().GetAntiCSRFToken("RequestOrder") | |||
| turnstileSiteKey = GetAppSetting("TurnstileSiteKey") | |||
| %> | |||
| <!--#include file="../views/RequestOrder/index.asp" --> | |||
| <% | |||
| @@ -73,6 +74,11 @@ Class RequestOrderController_Class | |||
| AddValidationError errorList, "That jurisdiction number could not be verified." | |||
| End If | |||
| Dim turnstileToken : turnstileToken = Request.Form("cf-turnstile-response") | |||
| If Not VerifyTurnstileToken(turnstileToken, Request.ServerVariables("REMOTE_ADDR")) Then | |||
| AddValidationError errorList, "Please complete the verification challenge." | |||
| End If | |||
| If UBound(errorList) >= 0 Then | |||
| Dim i | |||
| For i = 0 To UBound(errorList) | |||
| @@ -1,5 +1,6 @@ | |||
| <!--#include file="../models/OrdersRepository.asp" --> | |||
| <!--#include file="../models/JurisdictionValidator.asp" --> | |||
| <!--#include file="../models/TurnstileValidator.asp" --> | |||
| <!--#include file="../models/POBO_OrderDetails.asp" --> | |||
| <!--#include file="../models/OrderDetailsRepository.asp" --> | |||
| <!--#include file="../models/OrderDetailsLabels.asp" --> | |||
| @@ -0,0 +1,48 @@ | |||
| <% | |||
| '======================================================================================================================= | |||
| ' Cloudflare Turnstile (CAPTCHA) server-side verification | |||
| '======================================================================================================================= | |||
| ' Verifies a Turnstile response token against Cloudflare's siteverify endpoint. Unlike | |||
| ' JurisdictionValidator's fail-open behavior (missing reference data shouldn't block orders), | |||
| ' this fails CLOSED: a missing token or a failed/unreachable verification call is treated as | |||
| ' "not verified" rather than being let through, since the whole point is to block automated | |||
| ' submissions. | |||
| '======================================================================================================================= | |||
| Const TURNSTILE_VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify" | |||
| ' Verifies a Turnstile response token (the value of the widget's "cf-turnstile-response" | |||
| ' field / the token returned to the data-callback). remoteIp is optional - pass "" to omit it. | |||
| Function VerifyTurnstileToken(responseToken, remoteIp) | |||
| VerifyTurnstileToken = False | |||
| responseToken = Trim(responseToken) | |||
| If Len(responseToken) = 0 Then Exit Function | |||
| Dim secretKey : secretKey = GetAppSetting("TurnstileSecretKey") | |||
| If Len(secretKey) = 0 Then Exit Function | |||
| Dim body | |||
| body = "secret=" & Server.URLEncode(secretKey) & "&response=" & Server.URLEncode(responseToken) | |||
| If Len(remoteIp) > 0 Then body = body & "&remoteip=" & Server.URLEncode(remoteIp) | |||
| On Error Resume Next | |||
| Dim http : Set http = Server.CreateObject("Msxml2.ServerXMLHTTP") | |||
| http.setTimeouts 5000, 5000, 5000, 5000 | |||
| http.Open "POST", TURNSTILE_VERIFY_URL, False | |||
| http.setRequestHeader "Content-Type", "application/x-www-form-urlencoded" | |||
| http.Send body | |||
| If Err.Number = 0 And http.Status = 200 Then | |||
| Dim re | |||
| Set re = New RegExp | |||
| re.Pattern = """success""\s*:\s*true" | |||
| re.IgnoreCase = True | |||
| VerifyTurnstileToken = re.Test(http.responseText) | |||
| End If | |||
| Err.Clear | |||
| On Error GoTo 0 | |||
| End Function | |||
| %> | |||
| @@ -25,6 +25,12 @@ | |||
| %> | |||
| <link href="https://cdn.jsdelivr.net/npm/survey-core@2.5.36/survey-core.min.css" rel="stylesheet"> | |||
| <div style="max-width: 760px; margin: 0 auto; padding: 0 16px;"> | |||
| <div class="cf-turnstile" style="margin-bottom: 24px;" | |||
| data-sitekey="<%= H(turnstileSiteKey) %>" | |||
| data-callback="onTurnstileVerified"></div> | |||
| </div> | |||
| <div id="surveyContainer" style="max-width: 760px; margin: 0 auto 64px; padding: 0 16px;"></div> | |||
| <div id="colorPickerOverlay" style="display:none; position:fixed; inset:0; background:rgba(36,16,58,0.55); z-index:1000; align-items:center; justify-content:center; padding:16px;"> | |||
| @@ -43,12 +49,17 @@ | |||
| <script src="https://cdn.jsdelivr.net/npm/survey-core@2.5.36/survey.core.min.js"></script> | |||
| <script src="https://cdn.jsdelivr.net/npm/survey-js-ui@2.5.36/survey-js-ui.min.js"></script> | |||
| <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> | |||
| <script> | |||
| (function () { | |||
| "use strict"; | |||
| var submitUrl = "<%= H(Routes().AppURL & "order/submit?token=" & Server.URLEncode(token)) %>"; | |||
| var csrfToken = "<%= H(csrfToken) %>"; | |||
| var turnstileToken = ""; | |||
| window.onTurnstileVerified = function (token) { | |||
| turnstileToken = token; | |||
| }; | |||
| // Election-materials order form. Field (name) values match the OrderDetails table | |||
| // columns 1:1 - see db/migrations/20260728145000_create_order_details_table.asp and | |||
| @@ -408,12 +419,20 @@ | |||
| }); | |||
| })(); | |||
| survey.onCompleting.add(function (sender, options) { | |||
| if (!turnstileToken) { | |||
| options.allowComplete = false; | |||
| alert("Please complete the verification challenge above before submitting."); | |||
| } | |||
| }); | |||
| survey.onComplete.add(function (sender) { | |||
| fetch(submitUrl, { | |||
| method: "POST", | |||
| headers: { | |||
| "Content-Type": "application/json", | |||
| "X-CSRF-Token": csrfToken | |||
| "X-CSRF-Token": csrfToken, | |||
| "X-Turnstile-Token": turnstileToken | |||
| }, | |||
| body: JSON.stringify(sender.data) | |||
| }) | |||
| @@ -23,6 +23,10 @@ | |||
| value="<%= H(jurisdictionValue) %>" /> | |||
| </div> | |||
| <div class="cf-turnstile" style="margin-bottom: 24px;" data-sitekey="<%= H(turnstileSiteKey) %>"></div> | |||
| <button type="submit" class="button button-primary">Request order</button> | |||
| </form> | |||
| </section> | |||
| <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script> | |||
| @@ -61,6 +61,13 @@ | |||
| --> | |||
| <add key="JurisdictionApiUrl" value="http://192.168.1.40:8081/api/jurisdictions" /> | |||
| <add key="JurisdictionCacheMinutes" value="60" /> | |||
| <!-- | |||
| Cloudflare Turnstile (CAPTCHA) keys. These are DEV keys - replace with production | |||
| site/secret keys before going live. | |||
| --> | |||
| <add key="TurnstileSiteKey" value="0x4AAAAAAEUPEPHEnA9hPgbw" /> | |||
| <add key="TurnstileSecretKey" value="0x4AAAAAAEUPEFnbhCe6ttr_Mq6yCozxRkg" /> | |||
| </appSettings> | |||
| <system.webServer> | |||
| @@ -0,0 +1,2 @@ | |||
| site key dev 0x4AAAAAAEUPEPHEnA9hPgbw | |||
| secret key dev 0x4AAAAAAEUPEFnbhCe6ttr_Mq6yCozxRkg | |||
Powered by TurnKey Linux.