Search code examples
angulargoogle-cloud-platformgoogle-app-enginerecaptcharecaptcha-enterprise

How to handle reCAPTCHA Enterprise with Cloud Armour redirect


I have implemented reCAPTCHA Enterprise with Cloud Armour integration in an Angular v16 app and it is working as expected when blocking or allowing traffic. However I am unsure how to implement a redirect \ challenge.

What I am trying to do is

  • block low scores (working)
  • accept high scores (working)
  • challenge medium scores (how to display the challenge page?) enter image description here

From the logs I can see the cloud armour session tokens are valid

securityPolicyRequestData: {
   recaptchaSessionToken: {
   score: 0.89999998
   }}
   statusDetails: "response_sent_by_backend"
}

On the front end the HTTP GET requests return either the expected result from the backend or HTML which seems to be a reCAPTCHA challenge page which needs to be displayed somehow

const response$ = this.httpClient.post(url,  body, options );

response$ is sometimes the challenge page HTML e.g.

<!doctype html><html lang="en-US" dir="ltr"><head><base href="https://www.google.com/recaptcha/challengep........

So if response$ is HTML - how to display it?

I've tried just setting the HTML

reCaptchaRender(body: string) {
    let recaptchaDOMElem = document.getElementById('recaptcha')
    recaptchaDOMElem!.innerHTML = body
}

and also a component with DOMSanitizer but the result is the same

@Component({
  selector: 'my-recaptcha',
  templateUrl: './recaptcha.component.html',
  styleUrls: ['./recaptcha.component.scss']
})
export class MyRecaptcha {

  @ViewChild('recaptcha') recaptcha: ElementRef | undefined;

  private _html: string = ''

  constructor(private sanitizer: DomSanitizer) {}

  @Input() 
    public set recaptchaHTML(val: string) {
      this._html = val;
      if (this.recaptcha) {
        this.recaptcha.nativeElement.innerHTML = (
          this.sanitizer.sanitize(SecurityContext.HTML,  this.sanitizer.bypassSecurityTrustHtml(this._html))
        )}
  }
}

However that just results in

enter image description here

And the overlay which should be the pictures with the challenge is never displayed.

There are 404 errors in the logs which may or may not be relevant

httpRequest: {
latency: "0.223792s"
remoteIp: "xxx.16.32.252"
requestMethod: "GET"
requestSize: "18"
requestUrl: "https://app.xxxxxxxx.com/_/mss/boq-recaptcha/_/ss/k=boq-recaptcha.RecaptchaChallengePageUi.rMQB95nZlqc.L.B1.O/am=BMO0/d=1/ed=1/rs=AP105ZiCwi8N5mUj4UtZch0cdCHAP5fEfQ/chrome.css.map"
responseSize: "168"
status: 404
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36"
}
insertId: "6nvowkfrbxo3o"

So how do you handle the challenge page?

UPDATE I have stripped it back to just a static page calling a static page.
As soon as document.close(); is called, reCAPTCHA tries to establish a connection to its service and is rejected by CORS

The error is

start:1 Access to XMLHttpRequest at 'https://www.google.com/recaptcha/challengepage/_/RecaptchaChallengePageUi/browserinfo?f.sid=-6482627183065648936&bl=boq_recaptcha-boq-challengepage_20231012.04_p0&hl=en-US&_reqid=70468&rt=j' from origin 
'https://my-domain.com' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.


       POST https://www.google.com/recaptcha/challengepage/_/RecaptchaChallengePageUi/browserinfo?f.sid=-6482627183065648936&bl=boq_recaptcha-boq-
challengepage_20231012.04_p0&hl=en-US&_reqid=70468&rt=j net::ERR_FAILED

My App is
Create a Session token and associate it with the security policy

gcloud recaptcha keys create \
        --web \
        --display-name=$RE_CAPTCHA_KEY_NAME  \
        --integration-type=score \
        --domains=$APP_ENGINE_URL \
        --waf-feature=session-token \
        --waf-service=CA
gcloud beta compute security-policies update $CLOUD_ARMOUR_SEC_NAME \
        --recaptcha-redirect-site-key $RE_CAPTCHA_SITE_KEY \
        --enable-layer7-ddos-defense \
        --log-level=VERBOSE

Create the Cloud Armour Rule

gcloud beta compute security-policies rules create 1009 \
        --security-policy $CLOUD_ARMOUR_SEC_NAME  \
        --action redirect \
        --redirect-type=google-recaptcha \
        --expression=request.path.contains\(\'/login\'\)\ \&\&\ \(token.recaptcha_session.score\ \<\ 0.7\)

app.yaml

runtime: python311
env: standard

instance_class: F1

handlers:
- url: /login
  script: auto

- url: /start
  script: auto

- url: /
  script: auto

Flask back end

from flask import Flask, make_response, request, send_from_directory
from flask_cors import CORS, cross_origin

app = Flask(__name__)
CORS(app)  # Enable CORS for all routes

@app.route('/start', methods=['GET'])
def starter():
    return send_from_directory("./static", "starter.html")

@app.route('/login')
def login():

    return send_from_directory("./static", "mainapp.html")

@app.route('/')
@cross_origin(send_wildcard=True)
def index():
    return "Hello World"

if __name__ == '__main__':
    app.run(debug=True)

starter.html page


<html>
<head>
  <meta>
  <base href="/">
  <script
    src="https://www.google.com/recaptcha/enterprise.js?render=<SESSION_TOKEN_KEY>&waf=session"></script>
  <script>
    function goToApp() {
      const xhr = new XMLHttpRequest();
      xhr.open('GET', 'https://my-domain/login', false);
      xhr.onreadystatechange = function () {
        if (this.readyState == 4 && this.status == 200) {
          document.open();
          document.write(xhr.responseText);
          document.close(); //reCAPTURE fails on document.close()

        }
      };
      xhr.send(null);
    }

    onError = (error) => {
      console.log(error);
    }

    grecaptcha.enterprise.ready(function () {
      document.getElementById("launch-link").onclick = () => {
        grecaptcha.enterprise.execute('<SESSION_TOKEN_KEY>', {
        }).then(goToApp, onError);
      };
    });

  </script>
</head>

<body>
  <div>
    <a href="#" id="launch-link">Launch The App</a>
  </div>
</body>

What am I missing? How to overcome the CORS issue blocking reCAPTCHA from rendering the challenge page?


Solution

  • The Cloud Armor challenge page redirect here is designed to protect a site on the HTTP routing level, not out of an API call made from a web app. A lower-score HTTP request is being 302 redirected to the Google challenge page, so even if you can figure out how to display that HTML on your own site, it won't properly generate reCAPTCHA tokens because they wouldn't come from the Google-hosted page.

    If you're intending to just protect your own backend endpoint that you reach with a POST request, it'd be simpler to implement reCAPTCHA using the more traditional means of creating a token on your frontend & then calling createAssessment() with that token on your backend to see the score. See https://cloud.google.com/recaptcha-enterprise/docs/setup-overview-web.

    If you're intending to use a challenge page to generally guard your site and contents from bots, it's enough to put the Cloud Armor rules in place and let those redirect visitors to the challenge page on subsequent page loads after a low score is seen. You could exempt POST requests to your backend with additional firewall rules if it's intercepting those and shouldn't be.

    Another consideration to make when using challenge pages -- if what you're protecting is valuable enough for an attacker to pay for a captcha-farm, challenge pages work against your by offering a way for attackers to end-run around your protection by intentionally generating a low score and then "redeeming" their sessions with a farmed challenge solve. In those cases you're better off using other means to redeem real users (redirect to login, 2FA, flag transaction for manual review, etc).