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 +245 -30 1.10.1 → 2.4.5 View file →
@@ -1,30 +1,141 @@
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 16 public function route()
14 17 {
15 - $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 + }
16 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 +
17 37 $methodMaps = [
18 - 'ticket' => 'handleTicketView'
38 + 'ticket' => 'handleTicketView',
39 + 'email_claim' => 'handleEmailClaim'
19 40 ];
20 41
21 42 if (isset($methodMaps[$route])) {
43 + $this->verifyPublicEndpointSecurity($route);
22 44 $this->{$methodMaps[$route]}();
45 + } else {
46 + wp_die(esc_html__('Invalid route', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
23 47 }
48 + }
24 49
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()
59 + {
60 + $baseUrl = Helper::getPortalBaseUrl();
61 +
62 + if (!$baseUrl) {
63 + wp_die(esc_html__('Invalid route', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
64 + }
65 +
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'])) : '';
68 +
69 + if ($action === 'send') {
70 + $this->handleEmailClaimRequest();
71 + }
72 +
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');
78 + }
79 +
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);
86 +
87 + wp_safe_redirect(wp_login_url($returnUrl));
88 + exit;
89 + }
90 +
91 + $resolved = EmailClaimService::resolveClaim($token);
92 +
93 + if ($resolved['status'] !== 'ok') {
94 + $this->redirectToPortal($resolved['status']);
95 + }
96 +
97 + EmailClaimService::apply($resolved['customer'], $resolved['email']);
98 +
99 + $this->redirectToPortal('confirmed');
25 100 }
26 101
102 + /**
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 +
27 138 public function handleTicketView()
28 139 {
29 140 if (!Helper::isPublicSignedTicketEnabled()) {
30 141 $this->handleInvalidTicket();
@@ -33,47 +144,56 @@
33 144 }
34 145 }
35 146
36 147 /**
37 - * Display the attachment.
148 + * Serve an attachment by its `fst_file` hash and signature.
38 149 *
39 - * Uses the new rewrite endpoint to get an attachment ID
40 - * and display the attachment if the currently logged in user
41 - * has the authorization to.
42 - *
43 150 * @return void
44 151 * @since 3.2.0
45 152 */
46 153 public function view_attachment()
47 154 {
48 - $attachmentHash = sanitize_text_field($_REQUEST['fst_file']);
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 + }
49 160
161 + $this->checkRateLimit();
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 + }
167 +
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'])) : '';
170 +
50 171 if (empty($attachmentHash)) {
51 - die('Invalid Attachment Hash');
172 + wp_die(esc_html__('Invalid attachment hash.', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
52 173 }
53 174
54 175 $attachment = $this->getAttachmentByHash($attachmentHash);
55 176
56 177 if (!$attachment) {
57 - die('Invalid Attachment Hash');
178 + wp_die(esc_html__('Invalid attachment hash.', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
58 179 }
59 180
60 - // check signature hash
61 - if (!$this->validateAttachmentSignature($attachment)) {
181 + // Inline (pasted) images skip the signature because they are already embedded in emails.
182 + if ($attachment->status !== 'inline' && !$this->validateAttachmentSignature($attachment)) {
62 183 $dieMessage = esc_html__('Sorry, Your secure sign is invalid, Please reload the previous page and get new signed url', 'fluent-support');
63 - die(esc_html($dieMessage)); // Escaping the die message again for safety
184 + wp_die(esc_html($dieMessage), esc_html__('Forbidden', 'fluent-support'), ['response' => 403]);
64 185 }
65 186
66 - //If external file
67 187 if ('local' !== $attachment->driver) {
68 - if(!empty($attachment->full_url)){
69 - $this->redirectToExternalAttachment($attachment->full_url);
70 - }else{
188 + $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
189 + if (!empty($fileUrl)) {
190 + $this->redirectToExternalAttachment($fileUrl);
191 + } else {
71 192 die('File could not be found');
72 193 }
73 194 }
74 195
75 - //Handle Local file
76 196 if (!file_exists($attachment->file_path)) {
77 197 die('File could not be found');
78 198 }
79 199 $this->serveLocalAttachment($attachment);
@@ -85,17 +205,51 @@
85 205 }
86 206
87 207 private function validateAttachmentSignature($attachment)
88 208 {
89 - $sign = md5($attachment->id . date('YmdH'));
90 - return $sign === $_REQUEST['secure_sign'];
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 + }
213 +
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'])) : '';
216 +
217 + if (empty($secureSign)) {
218 + return false;
219 + }
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);
91 234 }
92 235
93 236 private function handleInvalidTicket()
94 237 {
95 - $ticketId = absint(Arr::get($_REQUEST, 'ticket_id'));
96 - $ticket = Ticket::where('id', $ticketId)->first();
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 + }
97 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 +
98 252 if (!$ticket) {
99 253 $this->showInvalidPortalMessage();
100 254 } else {
101 255 $this->redirectToTicketView($ticket);
@@ -103,17 +257,36 @@
103 257 }
104 258
105 259 private function handleValidTicket()
106 260 {
107 - $ticketHash = sanitize_text_field(Arr::get($_REQUEST, 'support_hash'));
108 - $ticketId = absint(Arr::get($_REQUEST, 'ticket_id'));
109 - $ticket = Ticket::where('hash', $ticketHash)->where('id', $ticketId)->first();
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 + }
110 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 +
111 283 if (!$ticket) {
112 284 $this->showInvalidPortalMessage();
113 285 } elseif (get_current_user_id()) {
114 286 $this->redirectToTicketView($ticket);
115 287 }
288 + // Logged-out visitors stay on the signed URL; the portal app reads the hash from it.
116 289 }
117 290
118 291 private function showInvalidPortalMessage()
119 292 {
@@ -128,30 +301,72 @@
128 301 }
129 302
130 303 private function redirectToExternalAttachment($redirectUrl)
131 304 {
305 + // Not wp_safe_redirect: remote attachments (Drive, Dropbox) live on other hosts.
132 306 wp_redirect($redirectUrl, 307);
133 307 exit();
134 308 }
135 309
136 - // Helper method to serve an attachment
137 310 private function serveLocalAttachment($attachment)
138 311 {
139 312 $file_path = realpath($attachment->file_path);
140 313 $uploads = wp_upload_dir();
141 - $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
314 + $uploads_dir = realpath($uploads['basedir']);
142 315
143 316 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
144 317 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
145 318 return;
146 319 }
147 -
320 +
148 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
149 324 ini_set('user_agent', 'Fluent Support/' . FLUENT_SUPPORT_VERSION . '; ' . esc_url(get_bloginfo('url')));
150 -
325 +
151 326 header("Content-Type: " . esc_attr($attachment->file_type));
152 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
153 330 readfile($file_path);
331 +
332 + // phpcs:ignore WordPress.PHP.IniSet.Risky -- Restoring original value
333 + ini_set('user_agent', $original_user_agent);
154 334 die();
155 335 }
156 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 + }
157 372 }