Skip to content

Commit

Permalink
fix(recaptcha): verify v2 on all modal checkout requests (#3644)
Browse files Browse the repository at this point in the history
This PR makes sure we are also verifying v2 recaptcha response on all modal checkout requests, including those done remotely.
  • Loading branch information
chickenn00dle authored Jan 7, 2025
1 parent 75baf11 commit 88277e9
Show file tree
Hide file tree
Showing 3 changed files with 246 additions and 182 deletions.
16 changes: 15 additions & 1 deletion includes/class-recaptcha.php
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,21 @@ function refreshToken() {
*/
public static function verify_recaptcha_on_checkout() {
$url = \home_url( \add_query_arg( null, null ) );
$should_verify_captcha = apply_filters( 'newspack_recaptcha_verify_captcha', self::can_use_captcha( 'v3' ), $url );
$should_verify_captcha = apply_filters( 'newspack_recaptcha_verify_captcha', self::can_use_captcha(), $url );
$version = self::get_setting( 'version' );

// Only use v2 if we are in modal checkout context.
// TODO: Remove this check once we have a way to enable v2 for non-modal checkouts.
if (
( 'v2' === $version || 'v2_invisible' === $version ) &&
(
! method_exists( 'Newspack_Blocks\Modal_Checkout', 'is_modal_checkout' ) ||
! \Newspack_Blocks\Modal_Checkout::is_modal_checkout()
)
) {
$should_verify_captcha = false;
}

if ( ! $should_verify_captcha ) {
return;
}
Expand Down
237 changes: 56 additions & 181 deletions src/other-scripts/recaptcha/index.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
/* globals jQuery, grecaptcha, newspack_recaptcha_data */

import {
addErrorMessage,
addHiddenV3Field,
destroyV3Field,
domReady,
getIntersectionObserver,
refreshV2Widget,
removeErrorMessages
} from './utils';
import './style.scss';

/**
* Specify a function to execute when the DOM is fully loaded.
*
* @see https://github.com/WordPress/gutenberg/blob/trunk/packages/dom-ready/
*
* @param {Function} callback A function to execute after the DOM is ready.
* @return {void}
*/
function domReady( callback ) {
if ( typeof document === 'undefined' ) {
return;
}
if (
document.readyState === 'complete' || // DOMContentLoaded + Images/Styles/etc loaded, so we call directly.
document.readyState === 'interactive' // DOMContentLoaded fires at this point, so we call directly.
) {
return void callback();
}
// DOMContentLoaded has not fired yet, delay callback until then.
document.addEventListener( 'DOMContentLoaded', callback );
}

window.newspack_grecaptcha = window.newspack_grecaptcha || {
destroy: destroyV3Field,
render,
Expand All @@ -35,87 +22,6 @@ const isV3 = 'v3' === newspack_recaptcha_data.version;
const siteKey = newspack_recaptcha_data.site_key;
const isInvisible = 'v2_invisible' === newspack_recaptcha_data.version;

/**
* Destroy hidden reCAPTCHA v3 token fields to avoid unnecessary reCAPTCHA checks.
*/
function destroyV3Field( forms = [] ) {
if ( isV3 ) {
const formsToHandle = forms.length
? forms
: [ ...document.querySelectorAll( 'form[data-newspack-recaptcha]' ) ];

formsToHandle.forEach( form => {
removeHiddenV3Field( form );
} );
}
}

/**
* Refresh the reCAPTCHA v3 token for the given form and action.
*
* @param {HTMLElement} field The hidden input field storing the token for a form.
* @param {string} action The action name to pass to reCAPTCHA.
*
* @return {Promise<void>|void} A promise that resolves when the token is refreshed.
*/
function refreshV3Token( field, action = 'submit' ) {
if ( field ) {
// Get a token to pass to the server. See https://developers.google.com/recaptcha/docs/v3 for API reference.
return grecaptcha.execute( siteKey, { action } ).then( token => {
field.value = token;
} );
}
}

/**
* Append a hidden reCAPTCHA v3 token field to the given form.
*
* @param {HTMLElement} form The form element.
*/
function addHiddenV3Field( form ) {
let field = form.querySelector( 'input[name="g-recaptcha-response"]' );
if ( ! field ) {
field = document.createElement( 'input' );
field.type = 'hidden';
field.name = 'g-recaptcha-response';
form.appendChild( field );

const action = form.getAttribute( 'data-newspack-recaptcha' ) || 'submit';
refreshV3Token( field, action );
setInterval( () => refreshV3Token( field, action ), 30000 ); // Refresh token every 30 seconds.

// Refresh reCAPTCHAs on Woo checkout update and error.
if ( jQuery ) {
jQuery( document ).on( 'updated_checkout', () => refreshV3Token( field, action ) );
jQuery( document.body ).on( 'checkout_error', () => refreshV3Token( field, action ) );
}
}
}

/**
* Remove the hidden reCAPTCHA v3 token field from the given form.
*
* @param {HTMLElement} form The form element.
*/
function removeHiddenV3Field( form ) {
const field = form.querySelector( 'input[name="g-recaptcha-response"]' );
if ( field ) {
field.parentElement.removeChild( field );
}
}

/**
* Refresh the reCAPTCHA v2 widget attached to the given element.
*
* @param {HTMLElement} el Element with the reCAPTCHA widget to refresh.
*/
function refreshV2Widget( el ) {
const widgetId = parseInt( el.getAttribute( 'data-recaptcha-widget-id' ) );
if ( ! isNaN( widgetId ) ) {
grecaptcha.reset( widgetId );
}
}

/**
* Render reCAPTCHA v2 widget on the given form.
*
Expand All @@ -139,14 +45,18 @@ function renderV2Widget( form, onSuccess = null, onError = null ) {
if ( button.hasAttribute( 'data-skip-recaptcha' ) ) {
return;
}
// Refresh widget if it already exists.
if ( button.hasAttribute( 'data-recaptcha-widget-id' ) ) {
refreshV2Widget( button );
return;
}
// Callback when reCAPTCHA passes validation or skip flag is present.
const successCallback = () => {
onSuccess?.()
const successCallback = token => {
onSuccess?.();
// Ensure the token gets submitted with the form submission.
let hiddenField = form.querySelector( '[name="g-recaptcha-response"]' );
if ( ! hiddenField ) {
hiddenField = document.createElement( 'input' );
hiddenField.type = 'hidden';
hiddenField.name = 'g-recaptcha-response';
form.appendChild( hiddenField );
}
hiddenField.value = token;
form.requestSubmit( button );
refreshV2Widget( button );
};
Expand All @@ -166,76 +76,51 @@ function renderV2Widget( form, onSuccess = null, onError = null ) {
}
}
}
// Attach widget to form events.
const attachListeners = () => {
getIntersectionObserver( () => renderV2Widget( form, onSuccess, onError ) ).observe( form, { attributes: true } );
button.addEventListener( 'click', e => {
e.preventDefault();
e.stopImmediatePropagation();
// Empty error messages if present.
removeErrorMessages( form );
// Skip reCAPTCHA verification if the button has a data-skip-recaptcha attribute.
if ( button.hasAttribute( 'data-skip-recaptcha' ) ) {
successCallback();
} else {
grecaptcha.execute( widgetId ).then( () => {
// If we are in an iframe scroll to top.
if ( window?.location !== window?.parent?.location ) {
document.body.scrollIntoView( { behavior: 'smooth' } );
}
} );
}
} );
}
// Refresh reCAPTCHA widgets on Woo checkout update and error.
if ( jQuery ) {
jQuery( document ).on( 'updated_checkout', () => attachListeners );
jQuery( document.body ).on( 'checkout_error', () => attachListeners );
}
// Refresh widget if it already exists.
if ( button.hasAttribute( 'data-recaptcha-widget-id' ) ) {
refreshV2Widget( button );
return;
}
const container = document.createElement( 'div' );
container.classList.add( 'grecaptcha-container' );
button.parentElement.append( container );
document.body.append( container );
const widgetId = grecaptcha.render( container, {
...options,
callback: successCallback,
'error-callback': errorCallback,
'expired-callback': errorCallback,
} );
button.setAttribute( 'data-recaptcha-widget-id', widgetId );

// Refresh reCAPTCHA widgets on Woo checkout update and error.
if ( jQuery ) {
jQuery( document ).on( 'updated_checkout', () => renderV2Widget( form, onSuccess, onError ) );
jQuery( document.body ).on( 'checkout_error', () => renderV2Widget( form, onSuccess, onError ) );
}

button.addEventListener( 'click', e => {
e.preventDefault();
e.stopImmediatePropagation();
// Empty error messages if present.
removeErrorMessages( form );
// Skip reCAPTCHA verification if the button has a data-skip-recaptcha attribute.
if ( button.hasAttribute( 'data-skip-recaptcha' ) ) {
successCallback();
} else {
grecaptcha.execute( widgetId ).then( () => {
// If we are in an iframe scroll to top.
if ( window?.location !== window?.parent?.location ) {
document.body.scrollIntoView( { behavior: 'smooth' } );
}
} );
}
} );
attachListeners();
} );
}

/**
* Append a generic error message above the given form.
*
* @param {HTMLElement} form The form element.
* @param {string} message The error message to display.
*/
function addErrorMessage( form, message ) {
const errorText = document.createElement( 'p' );
errorText.textContent = message;
const container = document.createElement( 'div' );
container.classList.add( 'newspack-recaptcha-error' );
container.appendChild( errorText );
// Newsletters block errors render below the form.
if ( form.parentElement.classList.contains( 'newspack-newsletters-subscribe' ) ) {
form.append( container );
} else {
container.classList.add( 'newspack-ui__notice', 'newspack-ui__notice--error' );
form.insertBefore( container, form.firstChild );
}
}

/**
* Remove generic error messages from form if present.
*
* @param {HTMLElement} form The form element.
*/
function removeErrorMessages( form ) {
const errors = form.querySelectorAll( '.newspack-recaptcha-error' );
for ( const error of errors ) {
error.parentElement.removeChild( error );
}
}

/**
* Render reCAPTCHA elements.
*
Expand All @@ -254,25 +139,15 @@ function render( forms = [], onSuccess = null, onError = null ) {
: [ ...document.querySelectorAll( 'form[data-newspack-recaptcha]' ) ];

formsToHandle.forEach( form => {
if ( ! form.hasAttribute( 'data-recaptcha-rendered' ) ) {
form.addEventListener( 'focusin', () => {
if ( isV2 ) {
renderV2Widget( form, onSuccess, onError );
}
if ( isV3 ) {
addHiddenV3Field( form );
}
} );
form.setAttribute( 'data-recaptcha-rendered', 'true' );
} else {
// Call render methods to trigger refresh.
const renderForm = () => {
if ( isV2 ) {
renderV2Widget( form, onSuccess, onError );
}
if ( isV3 ) {
addHiddenV3Field( form );
}
}
};
getIntersectionObserver( renderForm ).observe( form, { attributes: true } );
} );
}

Expand Down
Loading

0 comments on commit 88277e9

Please sign in to comment.