41

I have a website with a simple contact form. The validation is somewhat minimal because it doesn't go into a database; just an email. The form works as such:

There are 5 fields - 4 of which are required. The submit is disabled until the 4 fields are valid, and then you can submit it. Then everything is validated on the server again, including the recaptcha (which is not validated by me client side). The whole process is done with ajax, and there are multiple tests that must pass on the server side or 4** headers are returned, and the fail callback handler is called.

Everything works like gangbusters on Chrome on the desktop (I haven't tried other browsers, but I can't imagine why they'd be different), but on the iPhone the reCaptcha always validates even if I don't check the box for the test.

In other words: I still have to fill out the four values correctly in order to submit, but if I don't check the box for the reCaptcha, the request still succeeds.

I can post some code if anyone thinks that would be helpful, but it appears that the problem is with the device and not with the code. Does anyone have any insight into this?


Note: The server side is PHP/Apache if this is helpful.


Update: 5/28/2015:

I'm still debugging this, but it seems like Mobile Safari is ignoring my response headers on my iPhone. When I output the response to the page what I get on Desktop for (data,status,xhr) is:

  1. data: my response which at this point just says error or success -> error

  2. status: error

  3. xhr: {'error',400,'error'}

On Mobile safari:

  1. data: error

  2. status: success

  3. xhr: {'error',200,'success'}

So - it seems like it's just ignoring my response headers. I tried explicitly setting {"headers":{"cache-control":"no-cache"}} but to no avail.


Update: 6/3/2015

Per Request, here is the code. This is almost certainly more than you need. It has also become more obtuse because of the changes I've made to try and fix it. Also note that, while it may appear that there are variables that haven't been defined, they (should) have been defined in other files.

The client side

 $('#submit').on('click', function(e) {

    $(this).parents('form').find('input').each(function() {
        $(this).trigger('blur');
    })
    var $btn = $(this);
    $btn = $btn.button('loading');
    var dfr = $.Deferred();

    if ($(this).attr('disabled') || $(this).hasClass('disabled')) {

        e.preventDefault();
        e.stopImmediatePropagation();
        dfr.reject();
        return false;

    } else {

        var input = $('form').serializeArray();
        var obj = {},
            j;

        $.each(input, function(i, a) {

            if (a.name === 'person-name') {

                obj.name = a.value;

            } else if (a.name === 'company-name') {

                obj.company_name = a.value;

            } else {

                j = a.name.replace(/(g-)(.*)(-response)/g, '$2');
                obj[j] = a.value;

            }

        });

        obj.action = 'recaptcha-js';
        obj.remoteIp = rc.remoteiP;
        rc.data = obj;

        var request = $.ajax({

            url: rc.ajaxurl,
            type: 'post',
            data: obj,

            headers: {
                'cache-control': 'no-cache'
            }

        });

        var success = function(data) {

            $btn.data('loadingText', 'Success');
            $btn.button('reset');
            $('#submit').addClass('btn-success').removeClass('btn-default');
            $btn.button('loading');
            dfr.resolve(data);


        };
        var fail = function(data) {

            var reason = JSON.parse(data.responseText).reason;
            $btn.delay(1000).button('reset');
            switch (reason) {

                case 'Recaptcha Failed':
                case 'Recaptcha Not Checked':
                case 'One Or more validator fields not valid or not filled out':
                case 'One Or more validator fields is invalid':

                    // reset recaptcha

                    if ($('#submit').data('tries')) {

                        $('#submit').remove();
                        $('.g-recaptcha').parent().addBack().remove();

                        myPopover('Your request is invalid.  Please reload the page to try again.');

                    } else {

                        $('#submit').data('tries', 1);
                        grecaptcha.reset();

                        myPopover('One or more of your entries are invalid.  Please make corrections and try again.');
                    }


                    break;

                default:

                    // reset page
                    $('#submit').remove();
                    $('.g-recaptcha').remove();


                    myPopover('There was a problem with your request.  Please reload the page and try again.');

                    break;
            }
            dfr.reject(data);

        };

        request.done(success);
        request.fail(fail);



    }

The Server:

function _send_email(){

$recaptcha=false;
/* * */
if(isset($_POST['recaptcha'])):

    $gRecaptchaResponse=$_POST['recaptcha'];
    $remoteIp=isset($_POST['remoteIp']) ? $_POST['remoteIp'] : false;

    /* ** */
    if(!$remoteIp):

        $response=array('status_code'=>'409','reason'=>'remoteIP not set');
        echo json_encode($response);
        http_response_code(409);

        exit();

    endif;
    /* ** */

    /* ** */
    if($gRecaptchaResponse==''):

        $response=array('status_code'=>'400','reason'=>'Recaptcha Failed');
        echo json_encode($response);
        http_response_code(400);
        exit();

    endif;
    /* ** */

    if($recaptcha=recaptcha_test($gRecaptchaResponse,$remoteIp)):

        $recaptcha=true;

    /* ** */
    else:

        $response=array('status_code'=>'400','reason'=>'Recaptcha Failed');
        echo json_encode($response);
        http_response_code(400);
        exit();

    endif;
    /* ** */

/* * */
else:

    $response=array('status_code'=>'400','reason'=>'Recaptcha Not Checked');
    echo json_encode($response);
    http_response_code(400);
    exit();

endif;
/* * */

/* * */
if($recaptcha==1):

    $name=isset($_POST['name']) ? $_POST['name'] : false;
    $company_name=isset($_POST['company_name']) ? $_POST['company_name'] : false;
    $phone=isset($_POST['phone']) ? $_POST['phone'] : false;
    $email=isset($_POST['email']) ? $_POST['email'] : false;

    /* ** */
    if(isset($_POST['questions'])):

        $questions=$_POST['questions']=='' ? 1 : $_POST['questions'];

        /* *** */

    if(!$questions=filter_var($questions,FILTER_SANITIZE_SPECIAL_CHARS)):

         $response=array('status_code'=>'400','reason'=>'$questions could not be sanitized');
         echo json_encode($response);
         http_response_code(400);
         exit();

        endif;
       /* *** */

    /* ** */
    else:

      $questions=true;

    endif;
    /* ** */

    /* ** */
    if( count( array_filter( array( $name,$company_name,$phone,$email ),"filter_false" ) ) !=4 ):

        $response=array('status_code'=>'400','reason'=>'One Or more validator fields not valid or not filled out');
        echo json_encode($response);
        http_response_code(400);
        exit();

    endif;
    /* ** */

    $company_name=filter_var($company_name,FILTER_SANITIZE_SPECIAL_CHARS);
    $name=filter_var($name,FILTER_SANITIZE_SPECIAL_CHARS);
    $phone=preg_replace('/[^0-9+-]/', '', $phone);
    $email=filter_var($email,FILTER_VALIDATE_EMAIL);

    /* ** */
    if($company_name && $recaptcha && $name && $phone && $email && $questions):

        $phone_str='Phone:  ' . $phone;
        $company_str='Company:   ' . $company_name;
        $email_str='Email String:  ' . $email;
        $name_str='Name:  '.$name;
        $questions=$questions==1 ? '' : $questions;
        $body="$name_str\r\n\r\n$company_str\r\n\r\n$email_str\r\n\r\n$phone_str\r\n\r\n____________________\r\n\r\n$questions";


        $mymail='fake@fake.com';
        $headers   = array();
        $headers[] = "MIME-Version: 1.0";
        $headers[] = "Content-type: text/plain; charset=\"utf-8\"";
        $headers[] = "From: $email";
        $headers[] = "X-Mailer: PHP/" . phpversion();

        /* *** */
        if(mail('$mymail', 'Information Request from: ' . $name,$body,implode("\r\n",$headers))):

            $response=array('status_code'=>'200','reason'=>'Sent !');
            echo json_encode($response);
            http_response_code(200);
            exit();

        /* *** */
        else:

            $response=array('status_code'=>'400','reason'=>'One Or more validator fields is invalid');
            echo json_encode($response);
            http_response_code(400);
            exit();

        endif;
        /* *** */

     endif;
    /* ** */

   endif;
  /* * */

     $response=array('status_code'=>'412','reason'=>'There was an unknown error');
     echo json_encode($response);
     http_response_code(412);
     exit();
 }


function recaptcha_test($gRecaptchaResponse,$remoteIp){

    $secret=$itsasecret; //removed for security;

    require TEMPLATE_DIR . '/includes/lib/recaptcha/src/autoload.php';
    $recaptcha = new \ReCaptcha\ReCaptcha($secret);
    $resp = $recaptcha->verify($gRecaptchaResponse, $remoteIp);

    if ($resp->isSuccess()) {
        return true;
            // verified!
    } else {
        $errors = $resp->getErrorCodes();
        return false;
    }
 }
miken32
  • 42,008
  • 16
  • 111
  • 154
dgo
  • 3,877
  • 5
  • 34
  • 47
  • Need the Javascript used for submitting the AJAX request to help you debug. Are you POSTing (please say "yes") and not GETting? If you are GETting and can't post, make sure you add cache busting to the request (don't rely on headers alone as there are many things in the way where headers can get ignored). But post the JS and we can help from there. – Robbie Jun 04 '15 at 03:00
  • 1
    @user1167442 yes, please provide some code :) would be helpful here. – sitilge Jun 04 '15 at 06:26
  • Yes - POST method. Code has been added. Thanks. – dgo Jun 04 '15 at 17:39
  • A shot in the dark - maybe your iPhone's date is not set correct. – Octav Jun 05 '15 at 16:07
  • Possible? I didn't know I could even change this. How can I do that? – dgo Jun 05 '15 at 17:28
  • 1
    Super long shot: try substituting e.stopImmediatePropagation() with e.stopPropagation(). "In addition to keeping any additional handlers on an element from being executed, this method also stops the bubbling by implicitly calling event.stopPropagation(). To simply prevent the event from bubbling to ancestor elements but allow other event handlers to execute on the same element, we can use event.stopPropagation() instead." src: https://api.jquery.com/event.stopimmediatepropagation/ – Nitin Jul 26 '16 at 11:26
  • @Nitin. I've long since come up with a workaround, and done something else, but that's the most interesting suggesting I've heard thus far. When I get a chance, I will see if I can go back and try it. (Though it still doesn't explain device disparity) – dgo Jul 27 '16 at 16:05
  • 1
    @user1167442 should not make a difference but to make sure that the problem is not related, you could check if something else is interfering. "Use event.isImmediatePropagationStopped() to know whether this method was ever called (on that event object)." You could check with two different devices to see if it is device related. – Nitin Jul 27 '16 at 16:20
  • 1
    Could you please provide some server-side code (PHP as you mentioned)? I am 100% sure there is a server side problem. If you attempt to fix it only from the client side, malicious hackers would still be able to ignore the captcha. – vincent163 Jul 27 '16 at 16:23
  • @HeWenYang - all server code is posted - you want something further? – dgo Jul 27 '16 at 21:03
  • @user1167442 Thanks, I didn't see it. I'm a bit doubtful about the line `$remoteIp=isset($_POST['remoteIp']) ? $_POST['remoteIp'] : false;` where `$remoteIp` is passed to `recaptcha_test`. `$_POST['remoteIp']` is a user input. Try using `$remoteIp=$_SERVER['REMOTE_ADDR'];` as I told you in my answer. – vincent163 Jul 28 '16 at 17:16
  • @HeWenYang - I got it. Good catch; but it is actually not user input. It is a value set from the server and passed to a JavaScript variable. This is one way to do it with Wordpress - though it looks like the variable is client side; and could technically be altered, the value is set on the server, and altering it on the client wouldn't really do anything. – dgo Jul 29 '16 at 21:15
  • Not sure why 27 people voted this up, it's not a great question. If it's still a problem, you should post the HTML code required to reproduce this in a test environment, and reduce your PHP down to the stuff required to reproduce. It seems unlikely that this method doesn't work on the most popular phone on the market. – miken32 Jan 17 '17 at 23:16
  • Also if something is set on the server and "passed to a JavaScript variable" then it becomes something that is set on the client, and is not to be trusted. – miken32 Jan 17 '17 at 23:18
  • @miken32 - `Also if something is set on the server and "passed to a JavaScript variable" then it becomes something that is set on the client, and is not to be trusted` - that is true, but this is one variable and has nothing to do with the bad result here. Manipulating this variable would do nothing except probably cause the capture to fail, which is in no way a security risk. Also If you think this is a bad question, provide some support for that, or downvote it. Your unsubstantiated rejection adds nothing to a conversation that is nearly two years old. – dgo Jan 17 '17 at 23:51
  • I gave two ways you could improve the question. Provide enough HTML/JS that the problem can be reproduced, and pare down the PHP to the minimum required to reproduce. Is this still a problem, or did you find other ways to work around it? – miken32 Jan 18 '17 at 00:15
  • @miken32 - I found a way to work around it. I haven't looked at in a year. – dgo Jan 18 '17 at 02:40
  • You may want to consider deleting the question if an answer is no longer needed. (Because it's more than 60 days old [you'd still get to keep the rep you got from upvotes](http://meta.stackexchange.com/questions/7237/how-does-reputation-work).) – miken32 Jan 18 '17 at 02:57
  • cant you hide the checkbox and check it after vaild? – yardpenalty.com May 04 '17 at 12:48

4 Answers4

1

Like that question iOS: Authentication using XMLHttpRequest - Handling 401 reponse the easiest way to solve that is disregard natural headers validation and, on the callback sucess, validate with some flag.

I've saw some cases like that and never smell good.

Community
  • 1
  • 1
capcj
  • 1,535
  • 1
  • 16
  • 23
0

Is your "remoteIP" variable correctly set in the client side?

Even if your Ajax request sends an empty or false value, the isset() function in your php script will return true, and thus populates the $remoteIp wrongly.

Try doing:

$remoteIp = $_SERVER['REMOTE_ADDR'];

Ajax just makes the browser do the request, thus PHP can perfectly grab the ip of our user.

I'm sure that if you're passing the wrong value, ReCaptcha will mess up in one way or another.

It's also safer to never trust any Javascript variables over Ajax, as those should be treated as user input as well.

Yani
  • 424
  • 6
  • 13
  • You can't see it based upon my code, but the `remoteIP` var is set on the server. But, in either case; why would that be different based upon the client browser? – dgo Jun 21 '16 at 01:14
  • If it was calculated client side, chances are another browser might calculate it differently, sending a wrong IP back to google. – Yani Jun 21 '16 at 01:16
  • I didn't think javascript could even access the remoteIP. Either way, it's moot. In this case, it's calculated on the server prior to the page being loaded, and passed to a javascript variable. – dgo Jun 21 '16 at 01:20
0

The captcha is designed to prevent malicious clients (robots), so theoretically if a client bypasses the captcha, it is a server side problem. (However, if a client fails to complete the captcha, it may be a server side problem or a client side problem.)

So the problem must be at the server. Even from security considerations, you should be using $_SERVER['REMOTE_ADDR'] rather than $_POST['remoteIp'] because $_POST['remoteIp'] may be faked (by a malicious client). In fact, $_SERVER['REMOTE_ADDR'] is much more reliable than the client-side $_POST['remoteIp'].

vincent163
  • 384
  • 2
  • 13
  • Seems like your saying two things here. Are you saying that Safari on IOS doesn't support captcha? Also, I can't quite see how the second part would apply to my situation. – dgo Jul 27 '16 at 16:07
  • @user1167442 No. The question says that Safari bypasses the captcha. Theoretically if the captcha is bypassed, it must be a server side problem. The second part suggests a possible solution. Thanks for notification, I reworded my answer. – vincent163 Jul 27 '16 at 16:12
0

I made a script 2 or 3 months ago that still works perfectly, try this:

<?php
$siteKey = ''; // Public Key
$secret = ''; // Private Key
/**
 * This is a PHP library that handles calling reCAPTCHA.
 *    - Documentation and latest version
 *          https://developers.google.com/recaptcha/docs/php
 *    - Get a reCAPTCHA API Key
 *          https://www.google.com/recaptcha/admin/create
 *    - Discussion group
 *          http://groups.google.com/group/recaptcha
 *
 * @copyright Copyright (c) 2014, Google Inc.
 * @link      http://www.google.com/recaptcha
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */
/**
 * A ReCaptchaResponse is returned from checkAnswer().
 */
class ReCaptchaResponse
{
    public $success;
    public $errorCodes;
}
class ReCaptcha
{
    private static $_signupUrl = "https://www.google.com/recaptcha/admin";
    private static $_siteVerifyUrl =
        "https://www.google.com/recaptcha/api/siteverify?";
    private $_secret;
    private static $_version = "php_1.0";
    /**
     * Constructor.
     *
     * @param string $secret shared secret between site and ReCAPTCHA server.
     */
    function ReCaptcha($secret)
    {
        if ($secret == null || $secret == "") {
            die("To use reCAPTCHA you must get an API key from <a href='"
                . self::$_signupUrl . "'>" . self::$_signupUrl . "</a>");
        }
        $this->_secret=$secret;
    }
    /**
     * Encodes the given data into a query string format.
     *
     * @param array $data array of string elements to be encoded.
     *
     * @return string - encoded request.
     */
    private function _encodeQS($data)
    {
        $req = "";
        foreach ($data as $key => $value) {
            $req .= $key . '=' . urlencode(stripslashes($value)) . '&';
        }
        // Cut the last '&'
        $req=substr($req, 0, strlen($req)-1);
        return $req;
    }
    /**
     * Submits an HTTP GET to a reCAPTCHA server.
     *
     * @param string $path url path to recaptcha server.
     * @param array  $data array of parameters to be sent.
     *
     * @return array response
     */
    private function _submitHTTPGet($path, $data)
    {
        $req = $this->_encodeQS($data);
        $response = file_get_contents($path . $req);
        return $response;
    }
    /**
     * Calls the reCAPTCHA siteverify API to verify whether the user passes
     * CAPTCHA test.
     *
     * @param string $remoteIp   IP address of end user.
     * @param string $response   response string from recaptcha verification.
     *
     * @return ReCaptchaResponse
     */
    public function verifyResponse($remoteIp, $response)
    {
        // Discard empty solution submissions
        if ($response == null || strlen($response) == 0) {
            $recaptchaResponse = new ReCaptchaResponse();
            $recaptchaResponse->success = false;
            $recaptchaResponse->errorCodes = 'missing-input';
            return $recaptchaResponse;
        }
        $getResponse = $this->_submitHttpGet(
            self::$_siteVerifyUrl,
            array (
                'secret' => $this->_secret,
                'remoteip' => $remoteIp,
                'v' => self::$_version,
                'response' => $response
            )
        );
        $answers = json_decode($getResponse, true);
        $recaptchaResponse = new ReCaptchaResponse();
        if (trim($answers ['success']) == true) {
            $recaptchaResponse->success = true;
        } else {
            $recaptchaResponse->success = false;
            $recaptchaResponse->errorCodes = $answers [error-codes];
        }
        return $recaptchaResponse;
    }
}

$reCaptcha = new ReCaptcha($secret);

if(isset($_POST["g-recaptcha-response"])) {
    $resp = $reCaptcha->verifyResponse(
        $_SERVER["REMOTE_ADDR"],
        $_POST["g-recaptcha-response"]
        );
    if ($resp != null && $resp->success) {echo "OK";}
    else {echo "CAPTCHA incorrect";}
    }
?>

<html>

<head>
<title>Google reCAPTCHA</title>
<script src="https://www.google.com/recaptcha/api.js"></script>
</head>

<body>
<form action="reCAPTCHA.php" method="POST">
<input type="submit" value="Submit">
<div class="g-recaptcha" data-sitekey="<?php echo $siteKey; ?>"></div>
</form>
</body>

</html>

Normally, it should work (just add your private key and your public key), I tested on my iPhone SE, 2 seconds ago, and it worked perfectly.

Arthur Guiot
  • 713
  • 10
  • 25