Sunday, July 10, 2011

How ReCaptcha should not work with in-body script

Big Question: Why doesn't my ReCaptchaField show up while AMO's ReCaptcha show up if our ReCaptchaField stuff matches exactly, including the parts that displays the in-body javascript? Issue on github

Skip to solution

How an In-Body Javascript is ultimately introduced in Google Recaptcha:

1. Your Django Form has ReCaptchaField. e.g. File: apps/users/forms.py
import captcha.fields

class UserRegisterForm(happyforms.ModelForm, PasswordMixin):
    passwords ...  
               
    recaptcha = captcha.fields.ReCaptchaField()

    ... irrelevent stuff ...

2. captcha.fields.ReCaptchaField() in zamboni/vendors (not shown on zamboni github), but it's on Mozilla's django-recaptcha
from django.conf import settings
from django import forms
from django.utils.encoding import smart_unicode
from django.utils.translation import ugettext_lazy as _

from recaptcha.client import captcha

from captcha.widgets import ReCaptcha

class ReCaptchaField(forms.CharField):

    default_error_messages = {
        'captcha_invalid': _(u'Invalid captcha')
    }

    def __init__(self, *args, **kwargs):
        self.widget = ReCaptcha
        self.required = True
        super(ReCaptchaField, self).__init__(*args, **kwargs)

    def clean(self, values):
        super(ReCaptchaField, self).clean(values[1])
        recaptcha_challenge_value = smart_unicode(values[0])
        recaptcha_response_value = smart_unicode(values[1])
        check_captcha = captcha.submit(recaptcha_challenge_value,
            recaptcha_response_value, settings.RECAPTCHA_PRIVATE_KEY, {})
        if not check_captcha.is_valid:
            raise forms.util.ValidationError(
                    self.error_messages['captcha_invalid'])
        return values[0]

3. from captcha.widgets import ReCaptcha
from django import forms
from django.utils.safestring import mark_safe
from django.conf import settings
from recaptcha.client import captcha

class ReCaptcha(forms.widgets.Widget):
    recaptcha_challenge_name = 'recaptcha_challenge_field'
    recaptcha_response_name = 'recaptcha_response_field'

    def render(self, name, value, attrs=None):
        use_ssl = False
        if 'RECAPTCHA_USE_SSL' in settings.__members__:
            use_ssl = settings.RECAPTCHA_USE_SSL
        return mark_safe(u'%s' %
                         captcha.displayhtml(settings.RECAPTCHA_PUBLIC_KEY,
                                             use_ssl=use_ssl))
     ...

4. from recaptcha.client (which is from Python's recaptcha client) import captcha
API_SSL_SERVER="https://api-secure.recaptcha.net"
API_SERVER="http://api.recaptcha.net"

def displayhtml (public_key,
                 use_ssl = False,
                 error = None):
    """Gets the HTML to display for reCAPTCHA

    public_key -- The public api key
    use_ssl -- Should the request be sent over ssl?
    error -- An error message to display (from RecaptchaResponse.error_code)"""

    error_param = ''
    if error:
        error_param = '&error=%s' % error

    if use_ssl:
        server = API_SSL_SERVER
    else:
        server = API_SERVER

    return """<script type="text/javascript" src="%(ApiServer)s/challenge?k=%(PublicKey)s%(ErrorParam)s"></script> # this src contains in-body script!!

<noscript>
  <iframe src="%(ApiServer)s/noscript?k=%(PublicKey)s%(ErrorParam)s" height="300" width="500" frameborder="0"></iframe><br />
  <textarea name="recaptcha_challenge_field" rows="3" cols="40"></textarea>
  <input type='hidden' name='recaptcha_response_field' value='manual_challenge' />
</noscript>
""" % { 
        'ApiServer' : server,
        'PublicKey' : public_key,
        'ErrorParam' : error_param,
        }   

5. The src directs to (key varies for host, content is the same) https://www.google.com/recaptcha/api/challenge?k=6LcCCsYSAAAAACm9eF4n2ttYMU4TFbDMXMO-Bw2q


6. Which then directs to https://www.google.com/recaptcha/api/js/recaptcha.js that contains an in-body script:
document.write('<script>Recaptcha.widget = Recaptcha.$("recaptcha_widget_div"); Recaptcha.challenge_callback();<\/script>');

SOLVED!!!: click to see commit 2 break throughs, 1 question:

BT1: change into amo register's custom RecaptchaOptions to avoid in-body script.
BT2: have to allow setInterval like 'CSP_OPTIONS = ("eval-script",)'.
Q1: How come amo register does not have "setInterval blocked by CSP" problem even without CSP_OPTIONS?

BT1: In-body script is skipped with a custom RecaptchaOptions
as seen in Google recaptcha's js, note the javascript comma:
if (RecaptchaOptions.theme == "custom") {
    if (RecaptchaOptions.custom_theme_widget) Recaptcha.widget = Recaptcha.$(RecaptchaOptions.custom_theme_widget);
    Recaptcha.challenge_callback()
} else 
    document.write('<div id="recaptcha_widget_div" style="display:none"></div>'),
    document.write('<script>Recaptcha.widget = Recaptcha.$("recaptcha_widget_div"); Recaptcha.challenge_callback();<\/script>');
So the entire "else", which contains the in-body javascript, is skipped!

BT2: Make CSP policy allow setInterval
add CSP_OPTIONS = ("eval-script",) into settings.py
solves the "call to setInterval blocked by CSP" issue (seen in Firebug).

Q1: Why doesn't amo have this issue?

Solved: because amo has CSP_REPORT_ONLY, meaning that CSP is not actually enforced, but only reported!

---------

Have to get around setInterval(). CSP only blocks setInterval if it's called with a string argument.

So let's call it with a function!

Continued on this post.

1 comment:

  1. Nice job... i have been looking for information about Captcha Code i think i found it.....

    ReplyDelete