Web UI spam protection and CAPTCHA support

The approach for adding spam protection and CAPTCHA support to a new UI area of the GitLab application depends upon how the existing code is implemented.

Supported scenarios of request submissions

Three different scenarios are supported. Two are used with JavaScript XHR/Fetch requests for either Apollo or Axios, and one is used only with standard HTML form requests:

  1. A JavaScript-based submission (possibly via Vue)
    1. Using Apollo (GraphQL API via Fetch/XHR request)
    2. Using Axios (REST API via Fetch/XHR request)
  2. A standard HTML form submission (HTML request)

Some parts of the implementation depend upon which of these scenarios you must support.

Implementation tasks specific to JavaScript XHR/Fetch requests

Two approaches are fully supported:

  1. Apollo, using the GraphQL API.
  2. Axios, using either the GraphQL API.

The spam and CAPTCHA-related data communication between the frontend and backend requires no additional fields being added to the models. Instead, communication is handled:

  • Through custom header values in the request.
  • Through top-level JSON fields in the response.

The spam and CAPTCHA-related logic is also cleanly abstracted into reusable modules and helper methods which can wrap existing logic, and only alter the existing flow if potential spam is detected or a CAPTCHA display is needed. This approach allows the spam and CAPTCHA support to be added to new areas of the application with minimal changes to existing logic. In the case of the frontend, potentially zero changes are needed!

On the frontend, this is handled abstractly and transparently using ApolloLink for Apollo, and an Axios interceptor for Axios. The CAPTCHA display is handled by a standard GitLab UI / Pajamas modal component. You can find all the relevant frontend code under app/assets/javascripts/captcha.

However, even though the actual handling of the request interception and modal is transparent, without any mandatory changes to the involved JavaScript or Vue components for the form or page, changes in request or error handling may be required. Changes are needed because the existing behavior may not work correctly: for example, if a failed or cancelled CAPTCHA display interrupts the normal request flow or UI updates. Careful exploratory testing of all scenarios is important to uncover any potential problems.

This sequence diagram illustrates the normal CAPTCHA flow for JavaScript XHR/Fetch requests on the frontend:

sequenceDiagram
    participant U as User
    participant V as Vue/JS Application
    participant A as ApolloLink or Axios Interceptor
    participant G as GitLab API
    U->>V: Save model
    V->>A: Request
    A->>G: Request
    G--xA: Response with error and spam/CAPTCHA related fields
    A->>U: CAPTCHA presented in modal
    U->>A: CAPTCHA solved to obtain valid CAPTCHA response
    A->>G: Request with valid CAPTCHA response and SpamLog ID in headers
    G-->>A: Response with success
    A-->>V: Response with success

The backend is also cleanly abstracted via mixin modules and helper methods. The three main changes required to the relevant backend controller actions (normally just create/update) are:

  1. Create a SpamParams parameter object instance based on the request, using the static #new_from_request factory method. This method takes a request, and returns a SpamParams instance.
  2. Pass the created SpamParams instance as the spam_params named argument to the Service class constructor, which you should have already added. If the spam check indicates the changes to the model are possibly spam, then:
    • An error is added to the model.
    • The needs_recaptcha property on the model is set to true.
  3. Wrap the existing controller action return value (rendering or redirecting) in a block passed to a #with_captcha_check_json_format helper method, which transparently handles:
    1. Check if CAPTCHA is enabled, and if so, proceeding with the next step.
    2. Checking if there the model contains an error, and the needs_recaptcha flag is true.
      • If yes: Add the appropriate spam or CAPTCHA fields to the JSON response, and return a 409 - Conflict HTTP status code.
      • If no (if CAPTCHA is disabled or if no spam was detected): The normal request return logic passed in the block is run.

Thanks to the abstractions, it's more straightforward to implement than it is to explain it. You don't have to worry much about the hidden details!

Make these changes:

Add support to the controller actions

If the feature's frontend submits directly to controller actions, and does not only use the GraphQL API, then you must add support to the appropriate controllers.

The action methods may be directly in the controller class, or they may be abstracted to a module included in the controller class. Our example uses a module. The only difference when directly modifying the controller: extend ActiveSupport::Concern is not required.

module WidgetsActions
  # NOTE: This `extend` probably already exists, but it MUST be moved to occur BEFORE all
  # `include` statements. Otherwise, confusing bugs may occur in which the methods
  # in the included modules cannot be found.
  extend ActiveSupport::Concern

  include SpammableActions::CaptchaCheck::JsonFormatActionsSupport

  def create
    spam_params = ::Spam::SpamParams.new_from_request(request: request)
    widget = ::Widgets::CreateService.new(
      project: project,
      current_user: current_user,
      params: params,
      spam_params: spam_params
    ).execute

    respond_to do |format|
      format.json do
        with_captcha_check_json_format do
          # The action's existing `render json: ...` (or wrapper method) and related logic. Possibly
          # including different rendering cases if the model is valid or not. It's all wrapped here
          # within the `with_captcha_check_json_format` block. For example:
          if widget.valid?
            render json: serializer.represent(widget)
          else
            render json: { errors: widget.errors.full_messages }, status: :unprocessable_entity
          end
        end
      end
    end
  end
end

Implementation tasks specific to HTML form requests

Some areas of the application have not been converted to use the GraphQL API via a JavaScript client, but instead rely on standard Rails HAML form submissions via an HTML MIME type request. In these areas, the action returns a pre-rendered HTML (HAML) page as the response body. Unfortunately, in this case it is not possible to use any of the JavaScript-based frontend support as described above. Instead we must use an alternate approach which handles the rendering of the CAPTCHA form via a HAML template.

Everything is still cleanly abstracted, and the implementation in the backend controllers is virtually identical to the JavaScript/JSON based approach. Replace the word JSON with HTML (using the appropriate case) in the module names and helper methods.

The action methods might be directly in the controller, or they might be in a module. In this example, they are directly in the controller, and we also do an update method instead of create:

class WidgetsController < ApplicationController
  include SpammableActions::CaptchaCheck::HtmlFormatActionsSupport

  def update
    # Existing logic to find the `widget` model instance...

    spam_params = ::Spam::SpamParams.new_from_request(request: request)
    ::Widgets::UpdateService.new(
      project: project,
      current_user: current_user,
      params: params,
      spam_params: spam_params
    ).execute(widget)

    respond_to do |format|
      format.html do
        if widget.valid?
          # NOTE: `spammable_path` is required by the `SpammableActions::AkismetMarkAsSpamAction`
          # module, and it should have already been implemented on this controller according to
          # the instructions above. It is reused here to avoid duplicating the route helper call.
          redirect_to spammable_path
        else
          # If we got here, there were errors on the model instance - from a failed spam check
          # and/or other validation errors on the model. Either way, we'll re-render the form,
          # and if a CAPTCHA render is necessary, it will be automatically handled by
          # `with_captcha_check_html_format`
          with_captcha_check_html_format { render :edit }
        end
      end
    end
  end
end