summaryrefslogtreecommitdiff
path: root/doc/development/spam_protection_and_captcha/web_ui.md
blob: 6aa01f401bdf4d5cd089623cb2d81b6fee6a4089 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
---
stage: Manage
group: Authentication and Authorization
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---

# 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)
    1. Using Axios (REST API via Fetch/XHR request)
1. 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.
1. 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 easily 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:

```mermaid
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 simple static
   `#new_from_request` factory method. This method takes a request, and returns a `SpamParams` instance.
1. 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.
1. 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.
    1. 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.

```ruby
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](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66427#note_636989204)
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`:

```ruby
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
```