PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.4.5
Fluent Support – Helpdesk & Customer Support Ticket System v2.4.5
2.4.5 2.4.0 2.3.2 2.3.1 2.3.0 2.2.1 2.2.0 trunk 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.10.5 1.4.0 1.4.1 1.4.2 1.4.5 1.4.6 1.4.7 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 All 69 releases
← All changes | app/Hooks/Handlers/ExternalPages.php +311 -65 1.4.0 → 2.4.5 View file →
@@ -1,126 +1,372 @@
1 1 <?php
2 2
3 3 namespace FluentSupport\App\Hooks\Handlers;
4 4
5 -
6 5 use FluentSupport\App\Models\Attachment;
7 6 use FluentSupport\App\Models\Ticket;
7 +use FluentSupport\App\Services\EmailClaimService;
8 8 use FluentSupport\App\Services\Helper;
9 9 use FluentSupport\Framework\Support\Arr;
10 10
11 +/**
12 + * Public ticket, attachment and email-claim pages.
13 + */
11 14 class ExternalPages
12 15 {
13 -
14 16 public function route()
15 17 {
16 - $route = sanitize_text_field($_REQUEST['fs_view']);
18 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
19 + $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
20 + if ($requestMethod !== 'GET') {
21 + wp_die(esc_html__('Invalid request method', 'fluent-support'), esc_html__('Method Not Allowed', 'fluent-support'), ['response' => 405]);
22 + }
17 23
24 + $this->checkRateLimit();
25 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
26 + if (!isset($_REQUEST['fs_view'])) {
27 + wp_die(esc_html__('Missing required parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
28 + }
29 +
30 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
31 + $route = isset($_REQUEST['fs_view']) ? sanitize_text_field(wp_unslash($_REQUEST['fs_view'])) : '';
32 +
33 + if (empty($route)) {
34 + wp_die(esc_html__('Missing required parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
35 + }
36 +
18 37 $methodMaps = [
19 - 'ticket' => 'handleTicketView'
38 + 'ticket' => 'handleTicketView',
39 + 'email_claim' => 'handleEmailClaim'
20 40 ];
21 41
22 - if(isset($methodMaps[$route])) {
42 + if (isset($methodMaps[$route])) {
43 + $this->verifyPublicEndpointSecurity($route);
23 44 $this->{$methodMaps[$route]}();
45 + } else {
46 + wp_die(esc_html__('Invalid route', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
24 47 }
25 -
26 48 }
27 49
28 - public function handleTicketView()
50 + /**
51 + * Request or confirm moving the support address to the WP account's email.
52 + *
53 + * `fs_claim_action=send` sends mail, so it needs a nonce. `fs_claim=<token>` is
54 + * the signed link from that mail and still requires the requesting account.
55 + *
56 + * @return void
57 + */
58 + public function handleEmailClaim()
29 59 {
60 + $baseUrl = Helper::getPortalBaseUrl();
30 61
62 + if (!$baseUrl) {
63 + wp_die(esc_html__('Invalid route', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
64 + }
31 65
32 - if(!Helper::isPublicSignedTicketEnabled()) {
33 - $ticketId = absint(Arr::get($_REQUEST, 'ticket_id'));
34 - $ticket = Ticket::where('id', $ticketId)->first();
66 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only; the send branch verifies a nonce and the confirm branch verifies a signature
67 + $action = isset($_GET['fs_claim_action']) ? sanitize_text_field(wp_unslash($_GET['fs_claim_action'])) : '';
35 68
36 - if(!$ticket) {
37 - // Sorry no ticket found;
38 - echo '<h3 style="text-align: center; margin: 50px 0;">'.__('Invalid Support Portal URL', 'fluent-support').'</h3>';
39 - die();
40 - }
69 + if ($action === 'send') {
70 + $this->handleEmailClaimRequest();
71 + }
41 72
42 - $redirectUrl = Helper::getTicketViewUrl($ticket);
43 - wp_redirect($redirectUrl, 307);
44 - exit();
73 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the token is a signed secret delivered to the address being claimed
74 + $token = isset($_GET['fs_claim']) ? sanitize_text_field(wp_unslash($_GET['fs_claim'])) : '';
75 +
76 + if (!$token) {
77 + $this->redirectToPortal('invalid');
45 78 }
46 79
47 - $ticketHash = sanitize_text_field(Arr::get($_REQUEST, 'support_hash'));
48 - $ticketId = absint(Arr::get($_REQUEST, 'ticket_id'));
49 - $ticket = Ticket::where('hash', $ticketHash)->where('id', $ticketId)->first();
80 + if (!get_current_user_id()) {
81 + // The mailbox alone isn't enough proof, so require login too.
82 + $returnUrl = add_query_arg([
83 + 'fs_view' => 'email_claim',
84 + 'fs_claim' => $token
85 + ], $baseUrl);
50 86
51 - if(!$ticket) {
52 - // Sorry no ticket found;
53 - echo '<h3 style="text-align: center; margin: 50px 0;">'.__('Invalid Support Portal URL', 'fluent-support').'</h3>';
54 - die();
87 + wp_safe_redirect(wp_login_url($returnUrl));
88 + exit;
55 89 }
56 90
57 - if(get_current_user_id()) {
58 - // We have to re-route the URL
59 - $redirectUrl = Helper::getTicketViewUrl($ticket);
60 - wp_redirect($redirectUrl, 307);
61 - exit();
91 + $resolved = EmailClaimService::resolveClaim($token);
92 +
93 + if ($resolved['status'] !== 'ok') {
94 + $this->redirectToPortal($resolved['status']);
62 95 }
63 96
97 + EmailClaimService::apply($resolved['customer'], $resolved['email']);
98 +
99 + $this->redirectToPortal('confirmed');
64 100 }
65 101
66 102 /**
67 - * Display the attachment.
103 + * @return void
104 + */
105 + protected function handleEmailClaimRequest()
106 + {
107 + if (!get_current_user_id()) {
108 + $this->redirectToPortal('invalid');
109 + }
110 +
111 + $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';
112 +
113 + if (!wp_verify_nonce($nonce, 'fs_email_claim_send')) {
114 + $this->redirectToPortal('invalid');
115 + }
116 +
117 + $divergence = EmailClaimService::getDivergence();
118 +
119 + if (!$divergence) {
120 + $this->redirectToPortal('nothing_to_do');
121 + }
122 +
123 + $error = EmailClaimService::issue($divergence);
124 +
125 + $this->redirectToPortal($error ?: 'sent');
126 + }
127 +
128 + /**
129 + * @param string $status
130 + * @return void
131 + */
132 + protected function redirectToPortal($status)
133 + {
134 + wp_safe_redirect(add_query_arg('fs_claim_result', $status, Helper::getPortalBaseUrl()));
135 + exit;
136 + }
137 +
138 + public function handleTicketView()
139 + {
140 + if (!Helper::isPublicSignedTicketEnabled()) {
141 + $this->handleInvalidTicket();
142 + } else {
143 + $this->handleValidTicket();
144 + }
145 + }
146 +
147 + /**
148 + * Serve an attachment by its `fst_file` hash and signature.
68 149 *
69 - * Uses the new rewrite endpoint to get an attachment ID
70 - * and display the attachment if the currently logged in user
71 - * has the authorization to.
72 - *
73 150 * @return void
74 151 * @since 3.2.0
75 152 */
76 153 public function view_attachment()
77 154 {
155 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
156 + $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
157 + if ($requestMethod !== 'GET') {
158 + wp_die(esc_html__('Invalid request method', 'fluent-support'), esc_html__('Method Not Allowed', 'fluent-support'), ['response' => 405]);
159 + }
78 160
79 - $attachmentHash = sanitize_text_field($_REQUEST['fst_file']);
161 + $this->checkRateLimit();
80 162
163 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
164 + if (!isset($_REQUEST['fst_file'])) {
165 + wp_die(esc_html__('Missing required parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
166 + }
81 167
82 - if (!empty($attachmentHash)) {
168 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
169 + $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
83 170
84 - $attachment = Attachment::where('file_hash', $attachmentHash)->first();
171 + if (empty($attachmentHash)) {
172 + wp_die(esc_html__('Invalid attachment hash.', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
173 + }
85 174
86 - /**
87 - * Return a 404 page if the attachment ID
88 - * does not match any attachment in the database.
89 - */
90 - if (empty($attachment)) {
175 + $attachment = $this->getAttachmentByHash($attachmentHash);
91 176
92 - /**
93 - * @var WP_Query $wp_query WordPress main query
94 - */
95 - global $wp_query;
177 + if (!$attachment) {
178 + wp_die(esc_html__('Invalid attachment hash.', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
179 + }
96 180
97 - $wp_query->set_404();
181 + // Inline (pasted) images skip the signature because they are already embedded in emails.
182 + if ($attachment->status !== 'inline' && !$this->validateAttachmentSignature($attachment)) {
183 + $dieMessage = esc_html__('Sorry, Your secure sign is invalid, Please reload the previous page and get new signed url', 'fluent-support');
184 + wp_die(esc_html($dieMessage), esc_html__('Forbidden', 'fluent-support'), ['response' => 403]);
185 + }
98 186
99 - status_header(404);
100 - include(get_query_template('404'));
187 + if ('local' !== $attachment->driver) {
188 + $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
189 + if (!empty($fileUrl)) {
190 + $this->redirectToExternalAttachment($fileUrl);
191 + } else {
192 + die('File could not be found');
193 + }
194 + }
101 195
102 - die();
103 - }
196 + if (!file_exists($attachment->file_path)) {
197 + die('File could not be found');
198 + }
199 + $this->serveLocalAttachment($attachment);
200 + }
104 201
105 - // check signature hash
106 - $sign = md5($attachment->id . date('YmdH'));
107 - if($sign != $_REQUEST['secure_sign']) {
108 - $dieMessage = __('Sorry, Your secure sign is invalid, Please reload the previous page and get new signed url', 'fluent-support');
109 - die($dieMessage);
110 - }
202 + private function getAttachmentByHash($attachmentHash)
203 + {
204 + return Attachment::where('file_hash', $attachmentHash)->first();
205 + }
111 206
112 - ob_clean();
113 - ob_end_flush();
207 + private function validateAttachmentSignature($attachment)
208 + {
209 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
210 + if (!isset($_REQUEST['secure_sign'])) {
211 + return false;
212 + }
114 213
115 - ini_set('user_agent', 'Fluent Support/' . FLUENT_SUPPORT_VERSION . '; ' . get_bloginfo('url'));
116 - header("Content-Type: $attachment->file_type");
117 - header("Content-Disposition: inline; filename=\"$attachment->title\"");
214 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
215 + $secureSign = isset($_REQUEST['secure_sign']) ? sanitize_text_field(wp_unslash($_REQUEST['secure_sign'])) : '';
118 216
119 - echo readfile( $attachment->file_path );
120 - die();
217 + if (empty($secureSign)) {
218 + return false;
121 219 }
122 220
221 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
222 + $expires = isset($_REQUEST['expires']) ? sanitize_text_field(wp_unslash($_REQUEST['expires'])) : '';
223 +
224 + if ($expires === '') {
225 + // Legacy URL signed for the current UTC hour only.
226 + return hash_equals(Attachment::legacySignature($attachment->id, gmdate('YmdH')), $secureSign);
227 + }
228 +
229 + if (!ctype_digit($expires) || (int) $expires < time()) {
230 + return false;
231 + }
232 +
233 + return hash_equals(Attachment::signature($attachment->id, (int) $expires), $secureSign);
123 234 }
124 235
236 + private function handleInvalidTicket()
237 + {
238 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
239 + if (!isset($_REQUEST['ticket_id'])) {
240 + wp_die(esc_html__('Missing ticket ID parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
241 + }
125 242
243 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
244 + $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
245 +
246 + if ($ticketId <= 0) {
247 + wp_die(esc_html__('Invalid ticket ID', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
248 + }
249 +
250 + $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
251 +
252 + if (!$ticket) {
253 + $this->showInvalidPortalMessage();
254 + } else {
255 + $this->redirectToTicketView($ticket);
256 + }
257 + }
258 +
259 + private function handleValidTicket()
260 + {
261 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
262 + if (!isset($_REQUEST['support_hash']) || !isset($_REQUEST['ticket_id'])) {
263 + wp_die(esc_html__('Missing required parameters', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
264 + }
265 +
266 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
267 + $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
268 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
269 + $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
270 +
271 + if (empty($ticketHash) || !preg_match('/^[a-zA-Z0-9]+$/', $ticketHash)) {
272 + wp_die(esc_html__('Invalid ticket hash format', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
273 + }
274 +
275 + if ($ticketId <= 0) {
276 + wp_die(esc_html__('Invalid ticket ID', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
277 + }
278 +
279 + $ticket = Ticket::where('hash', $ticketHash)
280 + ->wherePublicIdentifier($ticketId)
281 + ->first();
282 +
283 + if (!$ticket) {
284 + $this->showInvalidPortalMessage();
285 + } elseif (get_current_user_id()) {
286 + $this->redirectToTicketView($ticket);
287 + }
288 + // Logged-out visitors stay on the signed URL; the portal app reads the hash from it.
289 + }
290 +
291 + private function showInvalidPortalMessage()
292 + {
293 + echo '<h3 style="text-align: center; margin: 50px 0;">' . esc_html__('Invalid Support Portal URL', 'fluent-support') . '</h3>';
294 + die();
295 + }
296 +
297 + private function redirectToTicketView($ticket)
298 + {
299 + $redirectUrl = Helper::getTicketViewUrl($ticket);
300 + $this->redirectToExternalAttachment($redirectUrl);
301 + }
302 +
303 + private function redirectToExternalAttachment($redirectUrl)
304 + {
305 + // Not wp_safe_redirect: remote attachments (Drive, Dropbox) live on other hosts.
306 + wp_redirect($redirectUrl, 307);
307 + exit();
308 + }
309 +
310 + private function serveLocalAttachment($attachment)
311 + {
312 + $file_path = realpath($attachment->file_path);
313 + $uploads = wp_upload_dir();
314 + $uploads_dir = realpath($uploads['basedir']);
315 +
316 + if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
317 + wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
318 + return;
319 + }
320 +
321 + ob_get_clean();
322 + $original_user_agent = ini_get('user_agent');
323 + // phpcs:ignore WordPress.PHP.IniSet.Risky -- Temporary change for file serving, restored immediately after
324 + ini_set('user_agent', 'Fluent Support/' . FLUENT_SUPPORT_VERSION . '; ' . esc_url(get_bloginfo('url')));
325 +
326 + header("Content-Type: " . esc_attr($attachment->file_type));
327 + header("Content-Disposition: inline; filename=\"" . esc_attr($attachment->title) . "\"");
328 +
329 + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Direct file serving required for attachment download
330 + readfile($file_path);
331 +
332 + // phpcs:ignore WordPress.PHP.IniSet.Risky -- Restoring original value
333 + ini_set('user_agent', $original_user_agent);
334 + die();
335 + }
336 +
337 + private function verifyPublicEndpointSecurity($route)
338 + {
339 + switch ($route) {
340 + case 'ticket':
341 + // support_hash is only required when signed ticket links are enabled.
342 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
343 + if (!isset($_REQUEST['ticket_id'])) {
344 + wp_die(esc_html__('Missing ticket ID parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
345 + }
346 + break;
347 +
348 + default:
349 + break;
350 + }
351 + }
352 +
353 + /**
354 + * 30 requests per minute per IP on the public endpoints.
355 + */
356 + private function checkRateLimit()
357 + {
358 + $ip = Helper::getIp();
359 + $transient_key = 'fs_rate_limit_' . wp_hash($ip);
360 + $requests = get_transient($transient_key);
361 +
362 + if ($requests === false) {
363 + set_transient($transient_key, 1, 60);
364 + } else {
365 + $requests++;
366 + if ($requests > 30) {
367 + wp_die(esc_html__('Rate limit exceeded. Please try again later.', 'fluent-support'), esc_html__('Too Many Requests', 'fluent-support'), ['response' => 429]);
368 + }
369 + set_transient($transient_key, $requests, 60);
370 + }
371 + }
126 372 }