Passed
Push — main ( 6044d0...c28500 )
by Paul
09:22
created

Router::isValidMutexRequest()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 20
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 17.8

Importance

Changes 0
Metric Value
eloc 14
c 0
b 0
f 0
dl 0
loc 20
ccs 3
cts 15
cp 0.2
rs 9.4888
cc 5
nc 5
nop 1
crap 17.8
1
<?php
2
3
namespace GeminiLabs\SiteReviews;
4
5
use GeminiLabs\SiteReviews\Helper;
6
use GeminiLabs\SiteReviews\Modules\Notice;
7
8
class Router
9
{
10
    /**
11
     * @action wp_ajax_glsr_action
12
     */
13 8
    public function routeAdminAjaxRequest(): void
14
    {
15 8
        $request = Request::inputPost();
16 8
        $this->checkAjaxRequest($request);
17 8
        $this->checkAjaxNonce($request, 'admin');
18 8
        $this->checkMutexRequest($request);
19 8
        $this->post('ajax', $request);
20
        wp_die();
21
    }
22
23
    /**
24
     * A routed admin GET request will look like this: /wp-admin/?glsr_=.
25
     * @action admin_init
26
     */
27 8
    public function routeAdminGetRequest(): void
28
    {
29 8
        $request = Request::inputGet();
30 8
        if (!empty($request->action)) {
31
            $this->get('admin', $request);
32
        }
33
    }
34
35
    /**
36
     * @action admin_init
37
     */
38 8
    public function routeAdminPostRequest(): void
39
    {
40 8
        $request = Request::inputPost();
41 8
        if (!$this->isValidRequest($request)) {
42 8
            return;
43
        }
44
        check_admin_referer($request->_action); // die() called if nonce is invalid, assumes _wpnonce
45
        if (!$this->isValidMutexRequest($request)) {
46
            return;
47
        }
48
        $this->post('admin', $request);
49
    }
50
51
    /**
52
     * @action wp_ajax_nopriv_glsr_action
53
     */
54
    public function routePublicAjaxRequest(): void
55
    {
56
        $request = Request::inputPost();
57
        $this->checkAjaxRequest($request);
58
        $this->checkAjaxNonce($request, 'public');
59
        $this->checkMutexRequest($request);
60
        $this->post('ajax', $request);
61
        wp_die();
62
    }
63
64
    /**
65
     * A routed public GET request will look like this: ?glsr_=.
66
     * @action parse_request
67
     */
68
    public function routePublicGetRequest(): void
69
    {
70
        $request = Request::inputGet();
71
        if (!empty($request->action)) {
72
            $this->get('public', $request);
73
        }
74
    }
75
76
    /**
77
     * @action init
78
     */
79
    public function routePublicPostRequest(): void
80
    {
81
        if (glsr()->isAdmin()) {
82
            return;
83
        }
84
        $request = Request::inputPost();
85
        if (!$this->isValidRequest($request)) {
86
            return;
87
        }
88
        if (!$this->isValidPublicNonce($request)) {
89
            return;
90
        }
91
        if (!$this->isValidMutexRequest($request)) {
92
            return;
93
        }
94
        $this->post('public', $request);
95
    }
96
97 8
    protected function checkAjaxNonce(Request $request, string $type): void
98
    {
99 8
        $unguardedActions = 'admin' === $type
100 8
            ? $this->unguardedAdminActions()
101 8
            : $this->unguardedPublicActions();
102 8
        if (in_array($request->_action, $unguardedActions)) {
103
            return;
104
        }
105 8
        if (empty($request->_nonce)) {
106
            $this->sendAjaxError('AJAX request is missing a nonce', $request, 400, 'Unauthorized request');
107
        }
108 8
        if (!wp_verify_nonce($request->_nonce, $request->_action)) {
109
            $this->sendAjaxError('AJAX request failed the nonce check', $request, 403, 'Unauthorized request');
110
        }
111
    }
112
113 8
    protected function checkAjaxRequest(Request $request): void
114
    {
115 8
        if (empty($request->_action)) {
116
            $this->sendAjaxError('AJAX request must include an action', $request, 400, 'Bad Request');
117
        }
118 8
        if (empty($request->_ajax_request)) {
119
            $this->sendAjaxError('AJAX request is invalid', $request, 400, 'Bad Request');
120
        }
121
    }
122
123 8
    protected function checkMutexRequest(Request $request): void
124
    {
125 8
        if (!$this->isValidMutexRequest($request)) {
126
            $this->sendAjaxError('Parallel AJAX request (possible single-packet attack)', $request, 429, 'Too Many Requests');
127
        }
128
    }
129
130
    protected function get(string $type, Request $request): void
131
    {
132
        $hook = "route/get/{$type}/{$request->action}";
133
        glsr()->action('route/request', $request, $hook);
134
        glsr()->action($hook, $request);
135
        if (0 === did_action(glsr()->id."/{$hook}")) {
136
            glsr_log()->warning("Unknown {$type} router GET request: {$request->action}");
137
        }
138
    }
139
140 8
    protected function isValidMutexRequest(Request $request): bool
141
    {
142 8
        if (defined('GLSR_UNIT_TESTS')) {
143 8
            return true;
144
        }
145
        if (!in_array($request->_action, $this->mutexActions())) {
146
            return true;
147
        }
148
        $ipAddress = Helper::getIpAddress();
149
        $hash = substr(wp_hash($ipAddress), 0, 13);
150
        $lock = glsr()->prefix.$hash;
151
        if (get_transient($lock)) {
152
            return false; // is parallel request
153
        }
154
        $expiration = glsr()->filterInt('router/mutex/expiration', 5, $ipAddress);
155
        $transient = set_transient($lock, 1, $expiration);
156
        if (!$transient) {
157
            return false; // parallel requests cannot set transient
158
        }
159
        return true;
160
    }
161
162
    protected function isValidPublicNonce(Request $request): bool
163
    {
164
        // only require a nonce for public requests if user is logged in, this avoids
165
        // potential caching issues since unauthenticated requests should nenever be destructive.
166
        if (is_user_logged_in() && !wp_verify_nonce($request->_nonce, $request->_action)) {
167
            glsr_log()->warning('nonce check failed for public request')->debug($request);
168
            return false;
169
        }
170
        return true;
171
    }
172
173 8
    protected function isValidRequest(Request $request): bool
174
    {
175 8
        return !empty($request->_action) && empty($request->_ajax_request);
176
    }
177
178
    protected function mutexActions(): array
179
    {
180
        return glsr()->filterArray('router/mutex/actions', [
181
            'submit-review',
182
        ]);
183
    }
184
185 8
    protected function post(string $type, Request $request): void
186
    {
187 8
        $hook = "route/{$type}/{$request->_action}";
188 8
        glsr()->action('route/request', $request, $hook);
189 8
        glsr()->action($hook, $request);
190
        if (0 === did_action(glsr()->id."/{$hook}")) {
191
            glsr_log()->warning("Unknown {$type} router POST request: {$request->_action}");
192
        }
193
    }
194
195
    protected function sendAjaxError(string $error, Request $request, int $errCode, string $message): void
196
    {
197
        $data = [
198
            'code' => $errCode,
199
            'error' => $error,
200
            'message' => $message ?: $error,
201
            'notices' => '',
202
        ];
203
        if ('submit-review' === $request->_action) {
204
            $data['message'] = __('The form could not be submitted. Please notify the site administrator.', 'site-reviews');
205
        }
206
        if (glsr()->isAdmin()) {
207
            glsr(Notice::class)->addError(_x('There was an error (try reloading the page).', 'admin-text', 'site-reviews')." <code>{$error}</code>");
208
            $data['notices'] = glsr(Notice::class)->get();
209
        }
210
        glsr_log($error);
211
        if (429 !== $errCode) {
212
            glsr_log()->debug($request->toArray());
213
        }
214
        wp_send_json_error($data);
215
    }
216
217 8
    protected function unguardedAdminActions(): array
218
    {
219 8
        return glsr()->filterArray('router/admin/unguarded-actions', [
220 8
            'dismiss-notice',
221 8
            'fetch-paged-reviews',
222 8
            'verified-review',
223 8
        ]);
224
    }
225
226
    protected function unguardedPublicActions(): array
227
    {
228
        return glsr()->filterArray('router/public/unguarded-actions', [
229
            'dismiss-notice',
230
            'fetch-paged-reviews',
231
            'submit-review',
232
            'verified-review',
233
        ]);
234
    }
235
}
236