| @@ -66,6 +66,12 @@ Class OrderApiController_Class | |||||
| Exit Sub | Exit Sub | ||||
| End If | 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 | Dim answers | ||||
| On Error Resume Next | On Error Resume Next | ||||
| json().loadJSON GetRawJsonFromRequest() | 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. | ' The order form itself is a placeholder for now - this only confirms the token is valid. | ||||
| '------------------------------------------------------------------------------------------------------------------- | '------------------------------------------------------------------------------------------------------------------- | ||||
| Public Sub Continue() | Public Sub Continue() | ||||
| Dim token, order, isValid, csrfToken, municipalityName | |||||
| Dim token, order, isValid, csrfToken, municipalityName, turnstileSiteKey | |||||
| token = Trim(Request.QueryString("token")) | token = Trim(Request.QueryString("token")) | ||||
| isValid = False | isValid = False | ||||
| csrfToken = "" | csrfToken = "" | ||||
| municipalityName = "" | municipalityName = "" | ||||
| turnstileSiteKey = GetAppSetting("TurnstileSiteKey") | |||||
| Set order = Nothing | Set order = Nothing | ||||
| If Len(token) > 0 Then | If Len(token) > 0 Then | ||||
| @@ -28,7 +28,7 @@ Class RequestOrderController_Class | |||||
| ' GET: show the "request an order" form (email + jurisdiction number) | ' GET: show the "request an order" form (email + jurisdiction number) | ||||
| '------------------------------------------------------------------------------------------------------------------- | '------------------------------------------------------------------------------------------------------------------- | ||||
| Public Sub Index() | Public Sub Index() | ||||
| Dim formValues, emailValue, jurisdictionValue, csrfToken | |||||
| Dim formValues, emailValue, jurisdictionValue, csrfToken, turnstileSiteKey | |||||
| Set formValues = FormCache().DeserializeForm("RequestOrder") | Set formValues = FormCache().DeserializeForm("RequestOrder") | ||||
| FormCache().ClearForm "RequestOrder" | FormCache().ClearForm "RequestOrder" | ||||
| @@ -41,6 +41,7 @@ Class RequestOrderController_Class | |||||
| End If | End If | ||||
| csrfToken = HTMLSecurity().GetAntiCSRFToken("RequestOrder") | csrfToken = HTMLSecurity().GetAntiCSRFToken("RequestOrder") | ||||
| turnstileSiteKey = GetAppSetting("TurnstileSiteKey") | |||||
| %> | %> | ||||
| <!--#include file="../views/RequestOrder/index.asp" --> | <!--#include file="../views/RequestOrder/index.asp" --> | ||||
| <% | <% | ||||
| @@ -73,6 +74,11 @@ Class RequestOrderController_Class | |||||
| AddValidationError errorList, "That jurisdiction number could not be verified." | AddValidationError errorList, "That jurisdiction number could not be verified." | ||||
| End If | 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 | If UBound(errorList) >= 0 Then | ||||
| Dim i | Dim i | ||||
| For i = 0 To UBound(errorList) | For i = 0 To UBound(errorList) | ||||
| @@ -1,5 +1,6 @@ | |||||
| <!--#include file="../models/OrdersRepository.asp" --> | <!--#include file="../models/OrdersRepository.asp" --> | ||||
| <!--#include file="../models/JurisdictionValidator.asp" --> | <!--#include file="../models/JurisdictionValidator.asp" --> | ||||
| <!--#include file="../models/TurnstileValidator.asp" --> | |||||
| <!--#include file="../models/POBO_OrderDetails.asp" --> | <!--#include file="../models/POBO_OrderDetails.asp" --> | ||||
| <!--#include file="../models/OrderDetailsRepository.asp" --> | <!--#include file="../models/OrderDetailsRepository.asp" --> | ||||
| <!--#include file="../models/OrderDetailsLabels.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"> | <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="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;"> | <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-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://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> | <script> | ||||
| (function () { | (function () { | ||||
| "use strict"; | "use strict"; | ||||
| var submitUrl = "<%= H(Routes().AppURL & "order/submit?token=" & Server.URLEncode(token)) %>"; | var submitUrl = "<%= H(Routes().AppURL & "order/submit?token=" & Server.URLEncode(token)) %>"; | ||||
| var csrfToken = "<%= H(csrfToken) %>"; | var csrfToken = "<%= H(csrfToken) %>"; | ||||
| var turnstileToken = ""; | |||||
| window.onTurnstileVerified = function (token) { | |||||
| turnstileToken = token; | |||||
| }; | |||||
| // Election-materials order form. Field (name) values match the OrderDetails table | // Election-materials order form. Field (name) values match the OrderDetails table | ||||
| // columns 1:1 - see db/migrations/20260728145000_create_order_details_table.asp and | // 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) { | survey.onComplete.add(function (sender) { | ||||
| fetch(submitUrl, { | fetch(submitUrl, { | ||||
| method: "POST", | method: "POST", | ||||
| headers: { | headers: { | ||||
| "Content-Type": "application/json", | "Content-Type": "application/json", | ||||
| "X-CSRF-Token": csrfToken | |||||
| "X-CSRF-Token": csrfToken, | |||||
| "X-Turnstile-Token": turnstileToken | |||||
| }, | }, | ||||
| body: JSON.stringify(sender.data) | body: JSON.stringify(sender.data) | ||||
| }) | }) | ||||
| @@ -23,6 +23,10 @@ | |||||
| value="<%= H(jurisdictionValue) %>" /> | value="<%= H(jurisdictionValue) %>" /> | ||||
| </div> | </div> | ||||
| <div class="cf-turnstile" style="margin-bottom: 24px;" data-sitekey="<%= H(turnstileSiteKey) %>"></div> | |||||
| <button type="submit" class="button button-primary">Request order</button> | <button type="submit" class="button button-primary">Request order</button> | ||||
| </form> | </form> | ||||
| </section> | </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="JurisdictionApiUrl" value="http://192.168.1.40:8081/api/jurisdictions" /> | ||||
| <add key="JurisdictionCacheMinutes" value="60" /> | <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> | </appSettings> | ||||
| <system.webServer> | <system.webServer> | ||||
| @@ -0,0 +1,2 @@ | |||||
| site key dev 0x4AAAAAAEUPEPHEnA9hPgbw | |||||
| secret key dev 0x4AAAAAAEUPEFnbhCe6ttr_Mq6yCozxRkg | |||||
Powered by TurnKey Linux.