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 +44 -79 2.4.0 → 2.4.5 View file →
@@ -8,28 +8,24 @@
8 8 use FluentSupport\App\Services\Helper;
9 9 use FluentSupport\Framework\Support\Arr;
10 10
11 11 /**
12 - * ExternalPages - Handles public-facing ticket and attachment viewing
13 - *
12 + * Public ticket, attachment and email-claim pages.
14 13 */
15 14 class ExternalPages
16 15 {
17 16 public function route()
18 17 {
19 - // Verify this is a GET request for security
20 18 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
21 19 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
22 20 if ($requestMethod !== 'GET') {
23 - wp_die('Invalid request method', 'Method Not Allowed', ['response' => 405]);
21 + wp_die(esc_html__('Invalid request method', 'fluent-support'), esc_html__('Method Not Allowed', 'fluent-support'), ['response' => 405]);
24 22 }
25 23
26 - // Rate limiting check
27 24 $this->checkRateLimit();
28 - // Validate required parameter exists and sanitize
29 25 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
30 26 if (!isset($_REQUEST['fs_view'])) {
31 - wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
27 + wp_die(esc_html__('Missing required parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
32 28 }
33 29
34 30 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
35 31 $route = isset($_REQUEST['fs_view']) ? sanitize_text_field(wp_unslash($_REQUEST['fs_view'])) : '';
@@ -34,12 +30,11 @@
34 30 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
35 31 $route = isset($_REQUEST['fs_view']) ? sanitize_text_field(wp_unslash($_REQUEST['fs_view'])) : '';
36 32
37 33 if (empty($route)) {
38 - wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
34 + wp_die(esc_html__('Missing required parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
39 35 }
40 36
41 - // Validate route value
42 37 $methodMaps = [
43 38 'ticket' => 'handleTicketView',
44 39 'email_claim' => 'handleEmailClaim'
45 40 ];
@@ -44,27 +39,20 @@
44 39 'email_claim' => 'handleEmailClaim'
45 40 ];
46 41
47 42 if (isset($methodMaps[$route])) {
48 - // For public endpoints, verify security using ticket hash validation instead of nonces
49 - // This is appropriate for public endpoints that must work without user authentication
50 43 $this->verifyPublicEndpointSecurity($route);
51 44 $this->{$methodMaps[$route]}();
52 45 } else {
53 - wp_die('Invalid route', 'Not Found', ['response' => 404]);
46 + wp_die(esc_html__('Invalid route', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
54 47 }
55 48 }
56 49
57 50 /**
58 - * Request or confirm a move of the customer's support address onto the
59 - * address their WordPress account now holds.
51 + * Request or confirm moving the support address to the WP account's email.
60 52 *
61 - * Two steps arrive here. `fs_claim_action=send` asks for the confirmation
62 - * mail and is nonced, because following it sends email and a bare GET with
63 - * an effect is a link somebody can put in front of a signed-in customer.
64 - * `fs_claim=<token>` is the link out of that mail; it carries its own
65 - * signature, so no nonce applies, but it does require being signed in as
66 - * the account that asked for it.
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.
67 55 *
68 56 * @return void
69 57 */
70 58 public function handleEmailClaim()
@@ -71,9 +59,9 @@
71 59 {
72 60 $baseUrl = Helper::getPortalBaseUrl();
73 61
74 62 if (!$baseUrl) {
75 - wp_die('Invalid route', 'Not Found', ['response' => 404]);
63 + wp_die(esc_html__('Invalid route', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
76 64 }
77 65
78 66 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only; the send branch verifies a nonce and the confirm branch verifies a signature
79 67 $action = isset($_GET['fs_claim_action']) ? sanitize_text_field(wp_unslash($_GET['fs_claim_action'])) : '';
@@ -89,10 +77,9 @@
89 77 $this->redirectToPortal('invalid');
90 78 }
91 79
92 80 if (!get_current_user_id()) {
93 - // Reading the mail is only half the proof. Send them through login
94 - // and back to the same link, so confirming still needs the account.
81 + // The mailbox alone isn't enough proof, so require login too.
95 82 $returnUrl = add_query_arg([
96 83 'fs_view' => 'email_claim',
97 84 'fs_claim' => $token
98 85 ], $baseUrl);
@@ -157,33 +144,26 @@
157 144 }
158 145 }
159 146
160 147 /**
161 - * Display the attachment.
148 + * Serve an attachment by its `fst_file` hash and signature.
162 149 *
163 - * Uses the new rewrite endpoint to get an attachment ID
164 - * and display the attachment if the currently logged in user
165 - * has the authorization to.
166 - *
167 150 * @return void
168 151 * @since 3.2.0
169 152 */
170 153 public function view_attachment()
171 154 {
172 - // Verify this is a GET request for security
173 155 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
174 156 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
175 157 if ($requestMethod !== 'GET') {
176 - wp_die('Invalid request method', 'Method Not Allowed', ['response' => 405]);
158 + wp_die(esc_html__('Invalid request method', 'fluent-support'), esc_html__('Method Not Allowed', 'fluent-support'), ['response' => 405]);
177 159 }
178 160
179 - // Rate limiting check
180 161 $this->checkRateLimit();
181 162
182 - // Validate required parameter exists and sanitize
183 163 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
184 164 if (!isset($_REQUEST['fst_file'])) {
185 - wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
165 + wp_die(esc_html__('Missing required parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
186 166 }
187 167
188 168 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
189 169 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
@@ -188,26 +168,23 @@
188 168 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
189 169 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
190 170
191 171 if (empty($attachmentHash)) {
192 - wp_die('Invalid Attachment Hash', 'Bad Request', ['response' => 400]);
172 + wp_die(esc_html__('Invalid attachment hash.', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
193 173 }
194 174
195 175 $attachment = $this->getAttachmentByHash($attachmentHash);
196 176
197 177 if (!$attachment) {
198 - wp_die('Invalid Attachment Hash', 'Not Found', ['response' => 404]);
178 + wp_die(esc_html__('Invalid attachment hash.', 'fluent-support'), esc_html__('Not Found', 'fluent-support'), ['response' => 404]);
199 179 }
200 180
201 - // Inline attachments (paste images embedded in ticket/email content) are publicly accessible
202 - // without a signature because they are already shared with customers via email.
203 - // Other attachments require HMAC signature validation.
181 + // Inline (pasted) images skip the signature because they are already embedded in emails.
204 182 if ($attachment->status !== 'inline' && !$this->validateAttachmentSignature($attachment)) {
205 183 $dieMessage = esc_html__('Sorry, Your secure sign is invalid, Please reload the previous page and get new signed url', 'fluent-support');
206 - wp_die(esc_html($dieMessage), 'Forbidden', ['response' => 403]);
184 + wp_die(esc_html($dieMessage), esc_html__('Forbidden', 'fluent-support'), ['response' => 403]);
207 185 }
208 186
209 - //If external file, redirect to the secure download URL
210 187 if ('local' !== $attachment->driver) {
211 188 $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
212 189 if (!empty($fileUrl)) {
213 190 $this->redirectToExternalAttachment($fileUrl);
@@ -215,9 +192,8 @@
215 192 die('File could not be found');
216 193 }
217 194 }
218 195
219 - //Handle Local file
220 196 if (!file_exists($attachment->file_path)) {
221 197 die('File could not be found');
222 198 }
223 199 $this->serveLocalAttachment($attachment);
@@ -229,9 +205,8 @@
229 205 }
230 206
231 207 private function validateAttachmentSignature($attachment)
232 208 {
233 - // Sanitize and validate secure_sign input - don't trust any input
234 209 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
235 210 if (!isset($_REQUEST['secure_sign'])) {
236 211 return false;
237 212 }
@@ -242,27 +217,35 @@
242 217 if (empty($secureSign)) {
243 218 return false;
244 219 }
245 220
246 - // Use HMAC-SHA256 for secure signature verification
247 - $sign = hash_hmac('sha256', $attachment->id . '|' . gmdate('YmdH'), wp_salt('secure_auth'));
248 - return hash_equals($sign, $secureSign);
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);
249 234 }
250 235
251 236 private function handleInvalidTicket()
252 237 {
253 - // Validate required parameter exists and sanitize
254 238 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
255 239 if (!isset($_REQUEST['ticket_id'])) {
256 - wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
240 + wp_die(esc_html__('Missing ticket ID parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
257 241 }
258 242
259 243 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
260 244 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
261 245
262 - // Validate ticket ID is positive integer
263 246 if ($ticketId <= 0) {
264 - wp_die('Invalid ticket ID', 'Bad Request', ['response' => 400]);
247 + wp_die(esc_html__('Invalid ticket ID', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
265 248 }
266 249
267 250 $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
268 251
@@ -274,12 +257,11 @@
274 257 }
275 258
276 259 private function handleValidTicket()
277 260 {
278 - // Validate required parameters exist and sanitize
279 261 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
280 262 if (!isset($_REQUEST['support_hash']) || !isset($_REQUEST['ticket_id'])) {
281 - wp_die('Missing required parameters', 'Bad Request', ['response' => 400]);
263 + wp_die(esc_html__('Missing required parameters', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
282 264 }
283 265
284 266 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
285 267 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
@@ -285,16 +267,14 @@
285 267 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
286 268 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
287 269 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
288 270
289 - // Validate hash format (should be alphanumeric)
290 271 if (empty($ticketHash) || !preg_match('/^[a-zA-Z0-9]+$/', $ticketHash)) {
291 - wp_die('Invalid ticket hash format', 'Bad Request', ['response' => 400]);
272 + wp_die(esc_html__('Invalid ticket hash format', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
292 273 }
293 274
294 - // Validate ticket ID is positive integer
295 275 if ($ticketId <= 0) {
296 - wp_die('Invalid ticket ID', 'Bad Request', ['response' => 400]);
276 + wp_die(esc_html__('Invalid ticket ID', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
297 277 }
298 278
299 279 $ticket = Ticket::where('hash', $ticketHash)
300 280 ->wherePublicIdentifier($ticketId)
@@ -302,13 +282,11 @@
302 282
303 283 if (!$ticket) {
304 284 $this->showInvalidPortalMessage();
305 285 } elseif (get_current_user_id()) {
306 - // Only redirect if user is logged in (to clean up URL)
307 286 $this->redirectToTicketView($ticket);
308 287 }
309 - // If not logged in, let the page load normally with the hash parameters
310 - // The frontend will handle displaying the ticket based on the URL
288 + // Logged-out visitors stay on the signed URL; the portal app reads the hash from it.
311 289 }
312 290
313 291 private function showInvalidPortalMessage()
314 292 {
@@ -323,20 +301,18 @@
323 301 }
324 302
325 303 private function redirectToExternalAttachment($redirectUrl)
326 304 {
327 - // This redirect is required to serve attachments stored on third-party services (Google Drive, Dropbox).
328 - // Safe and intentional: not a malicious or undesired redirect.
305 + // Not wp_safe_redirect: remote attachments (Drive, Dropbox) live on other hosts.
329 306 wp_redirect($redirectUrl, 307);
330 307 exit();
331 308 }
332 309
333 - // Helper method to serve an attachment
334 310 private function serveLocalAttachment($attachment)
335 311 {
336 312 $file_path = realpath($attachment->file_path);
337 313 $uploads = wp_upload_dir();
338 - $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
314 + $uploads_dir = realpath($uploads['basedir']);
339 315
340 316 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
341 317 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
342 318 return;
@@ -357,36 +333,26 @@
357 333 ini_set('user_agent', $original_user_agent);
358 334 die();
359 335 }
360 336
361 - /**
362 - * Verify security for public endpoints
363 - * This implements a custom security mechanism appropriate for public endpoints
364 - * that need to work without user authentication while maintaining security
365 - */
366 337 private function verifyPublicEndpointSecurity($route)
367 338 {
368 339 switch ($route) {
369 340 case 'ticket':
370 - // For ticket viewing, we need at least ticket_id
371 - // support_hash is required only when public signed tickets are enabled
341 + // support_hash is only required when signed ticket links are enabled.
372 342 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
373 343 if (!isset($_REQUEST['ticket_id'])) {
374 - wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
344 + wp_die(esc_html__('Missing ticket ID parameter', 'fluent-support'), esc_html__('Bad Request', 'fluent-support'), ['response' => 400]);
375 345 }
376 -
377 - // Additional validation will be done in handleValidTicket/handleInvalidTicket
378 346 break;
379 347
380 348 default:
381 - // For any other routes, ensure basic security
382 349 break;
383 350 }
384 351 }
385 352
386 353 /**
387 - * Basic rate limiting for public endpoints
388 - * Prevents abuse of public ticket/attachment viewing
354 + * 30 requests per minute per IP on the public endpoints.
389 355 */
390 356 private function checkRateLimit()
391 357 {
392 358 $ip = Helper::getIp();
@@ -393,14 +359,13 @@
393 359 $transient_key = 'fs_rate_limit_' . wp_hash($ip);
394 360 $requests = get_transient($transient_key);
395 361
396 362 if ($requests === false) {
397 - // First request in this minute
398 - set_transient($transient_key, 1, 60); // 60 seconds
363 + set_transient($transient_key, 1, 60);
399 364 } else {
400 365 $requests++;
401 - if ($requests > 30) { // Max 30 requests per minute per IP
402 - wp_die('Rate limit exceeded. Please try again later.', 'Too Many Requests', ['response' => 429]);
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]);
403 368 }
404 369 set_transient($transient_key, $requests, 60);
405 370 }
406 371 }