PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / trunk
Fluent Support – Helpdesk & Customer Support Ticket System vtrunk
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 1.5.5 All 68 releases
fluent-support / app / Hooks / Handlers / ExternalPages.php

ExternalPages.php in Fluent Support – Helpdesk & Customer Support Ticket System trunk, at app/Hooks/Handlers/ExternalPages.php

408 lines 15.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentSupport\App\Hooks\Handlers;
4
5 use FluentSupport\App\Models\Attachment;
6 use FluentSupport\App\Models\Ticket;
7 use FluentSupport\App\Services\EmailClaimService;
8 use FluentSupport\App\Services\Helper;
9 use FluentSupport\Framework\Support\Arr;
10
11 /**
12 * ExternalPages - Handles public-facing ticket and attachment viewing
13 *
14 */
15 class ExternalPages
16 {
17 public function route()
18 {
19 // Verify this is a GET request for security
20 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
21 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
22 if ($requestMethod !== 'GET') {
23 wp_die('Invalid request method', 'Method Not Allowed', ['response' => 405]);
24 }
25
26 // Rate limiting check
27 $this->checkRateLimit();
28 // Validate required parameter exists and sanitize
29 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
30 if (!isset($_REQUEST['fs_view'])) {
31 wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
32 }
33
34 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
35 $route = isset($_REQUEST['fs_view']) ? sanitize_text_field(wp_unslash($_REQUEST['fs_view'])) : '';
36
37 if (empty($route)) {
38 wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
39 }
40
41 // Validate route value
42 $methodMaps = [
43 'ticket' => 'handleTicketView',
44 'email_claim' => 'handleEmailClaim'
45 ];
46
47 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 $this->verifyPublicEndpointSecurity($route);
51 $this->{$methodMaps[$route]}();
52 } else {
53 wp_die('Invalid route', 'Not Found', ['response' => 404]);
54 }
55 }
56
57 /**
58 * Request or confirm a move of the customer's support address onto the
59 * address their WordPress account now holds.
60 *
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.
67 *
68 * @return void
69 */
70 public function handleEmailClaim()
71 {
72 $baseUrl = Helper::getPortalBaseUrl();
73
74 if (!$baseUrl) {
75 wp_die('Invalid route', 'Not Found', ['response' => 404]);
76 }
77
78 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- routing only; the send branch verifies a nonce and the confirm branch verifies a signature
79 $action = isset($_GET['fs_claim_action']) ? sanitize_text_field(wp_unslash($_GET['fs_claim_action'])) : '';
80
81 if ($action === 'send') {
82 $this->handleEmailClaimRequest();
83 }
84
85 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the token is a signed secret delivered to the address being claimed
86 $token = isset($_GET['fs_claim']) ? sanitize_text_field(wp_unslash($_GET['fs_claim'])) : '';
87
88 if (!$token) {
89 $this->redirectToPortal('invalid');
90 }
91
92 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.
95 $returnUrl = add_query_arg([
96 'fs_view' => 'email_claim',
97 'fs_claim' => $token
98 ], $baseUrl);
99
100 wp_safe_redirect(wp_login_url($returnUrl));
101 exit;
102 }
103
104 $resolved = EmailClaimService::resolveClaim($token);
105
106 if ($resolved['status'] !== 'ok') {
107 $this->redirectToPortal($resolved['status']);
108 }
109
110 EmailClaimService::apply($resolved['customer'], $resolved['email']);
111
112 $this->redirectToPortal('confirmed');
113 }
114
115 /**
116 * @return void
117 */
118 protected function handleEmailClaimRequest()
119 {
120 if (!get_current_user_id()) {
121 $this->redirectToPortal('invalid');
122 }
123
124 $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : '';
125
126 if (!wp_verify_nonce($nonce, 'fs_email_claim_send')) {
127 $this->redirectToPortal('invalid');
128 }
129
130 $divergence = EmailClaimService::getDivergence();
131
132 if (!$divergence) {
133 $this->redirectToPortal('nothing_to_do');
134 }
135
136 $error = EmailClaimService::issue($divergence);
137
138 $this->redirectToPortal($error ?: 'sent');
139 }
140
141 /**
142 * @param string $status
143 * @return void
144 */
145 protected function redirectToPortal($status)
146 {
147 wp_safe_redirect(add_query_arg('fs_claim_result', $status, Helper::getPortalBaseUrl()));
148 exit;
149 }
150
151 public function handleTicketView()
152 {
153 if (!Helper::isPublicSignedTicketEnabled()) {
154 $this->handleInvalidTicket();
155 } else {
156 $this->handleValidTicket();
157 }
158 }
159
160 /**
161 * Display the attachment.
162 *
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 * @return void
168 * @since 3.2.0
169 */
170 public function view_attachment()
171 {
172 // Verify this is a GET request for security
173 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
174 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
175 if ($requestMethod !== 'GET') {
176 wp_die('Invalid request method', 'Method Not Allowed', ['response' => 405]);
177 }
178
179 // Rate limiting check
180 $this->checkRateLimit();
181
182 // Validate required parameter exists and sanitize
183 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
184 if (!isset($_REQUEST['fst_file'])) {
185 wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
186 }
187
188 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
189 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
190
191 if (empty($attachmentHash)) {
192 wp_die('Invalid Attachment Hash', 'Bad Request', ['response' => 400]);
193 }
194
195 $attachment = $this->getAttachmentByHash($attachmentHash);
196
197 if (!$attachment) {
198 wp_die('Invalid Attachment Hash', 'Not Found', ['response' => 404]);
199 }
200
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.
204 if ($attachment->status !== 'inline' && !$this->validateAttachmentSignature($attachment)) {
205 $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]);
207 }
208
209 //If external file, redirect to the secure download URL
210 if ('local' !== $attachment->driver) {
211 $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
212 if (!empty($fileUrl)) {
213 $this->redirectToExternalAttachment($fileUrl);
214 } else {
215 die('File could not be found');
216 }
217 }
218
219 //Handle Local file
220 if (!file_exists($attachment->file_path)) {
221 die('File could not be found');
222 }
223 $this->serveLocalAttachment($attachment);
224 }
225
226 private function getAttachmentByHash($attachmentHash)
227 {
228 return Attachment::where('file_hash', $attachmentHash)->first();
229 }
230
231 private function validateAttachmentSignature($attachment)
232 {
233 // Sanitize and validate secure_sign input - don't trust any input
234 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
235 if (!isset($_REQUEST['secure_sign'])) {
236 return false;
237 }
238
239 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
240 $secureSign = isset($_REQUEST['secure_sign']) ? sanitize_text_field(wp_unslash($_REQUEST['secure_sign'])) : '';
241
242 if (empty($secureSign)) {
243 return false;
244 }
245
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);
249 }
250
251 private function handleInvalidTicket()
252 {
253 // Validate required parameter exists and sanitize
254 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
255 if (!isset($_REQUEST['ticket_id'])) {
256 wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
257 }
258
259 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
260 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
261
262 // Validate ticket ID is positive integer
263 if ($ticketId <= 0) {
264 wp_die('Invalid ticket ID', 'Bad Request', ['response' => 400]);
265 }
266
267 $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
268
269 if (!$ticket) {
270 $this->showInvalidPortalMessage();
271 } else {
272 $this->redirectToTicketView($ticket);
273 }
274 }
275
276 private function handleValidTicket()
277 {
278 // Validate required parameters exist and sanitize
279 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
280 if (!isset($_REQUEST['support_hash']) || !isset($_REQUEST['ticket_id'])) {
281 wp_die('Missing required parameters', 'Bad Request', ['response' => 400]);
282 }
283
284 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
285 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
286 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
287 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
288
289 // Validate hash format (should be alphanumeric)
290 if (empty($ticketHash) || !preg_match('/^[a-zA-Z0-9]+$/', $ticketHash)) {
291 wp_die('Invalid ticket hash format', 'Bad Request', ['response' => 400]);
292 }
293
294 // Validate ticket ID is positive integer
295 if ($ticketId <= 0) {
296 wp_die('Invalid ticket ID', 'Bad Request', ['response' => 400]);
297 }
298
299 $ticket = Ticket::where('hash', $ticketHash)
300 ->wherePublicIdentifier($ticketId)
301 ->first();
302
303 if (!$ticket) {
304 $this->showInvalidPortalMessage();
305 } elseif (get_current_user_id()) {
306 // Only redirect if user is logged in (to clean up URL)
307 $this->redirectToTicketView($ticket);
308 }
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
311 }
312
313 private function showInvalidPortalMessage()
314 {
315 echo '<h3 style="text-align: center; margin: 50px 0;">' . esc_html__('Invalid Support Portal URL', 'fluent-support') . '</h3>';
316 die();
317 }
318
319 private function redirectToTicketView($ticket)
320 {
321 $redirectUrl = Helper::getTicketViewUrl($ticket);
322 $this->redirectToExternalAttachment($redirectUrl);
323 }
324
325 private function redirectToExternalAttachment($redirectUrl)
326 {
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.
329 wp_redirect($redirectUrl, 307);
330 exit();
331 }
332
333 // Helper method to serve an attachment
334 private function serveLocalAttachment($attachment)
335 {
336 $file_path = realpath($attachment->file_path);
337 $uploads = wp_upload_dir();
338 $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
339
340 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
341 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
342 return;
343 }
344
345 ob_get_clean();
346 $original_user_agent = ini_get('user_agent');
347 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Temporary change for file serving, restored immediately after
348 ini_set('user_agent', 'Fluent Support/' . FLUENT_SUPPORT_VERSION . '; ' . esc_url(get_bloginfo('url')));
349
350 header("Content-Type: " . esc_attr($attachment->file_type));
351 header("Content-Disposition: inline; filename=\"" . esc_attr($attachment->title) . "\"");
352
353 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Direct file serving required for attachment download
354 readfile($file_path);
355
356 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Restoring original value
357 ini_set('user_agent', $original_user_agent);
358 die();
359 }
360
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 private function verifyPublicEndpointSecurity($route)
367 {
368 switch ($route) {
369 case 'ticket':
370 // For ticket viewing, we need at least ticket_id
371 // support_hash is required only when public signed tickets are enabled
372 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
373 if (!isset($_REQUEST['ticket_id'])) {
374 wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
375 }
376
377 // Additional validation will be done in handleValidTicket/handleInvalidTicket
378 break;
379
380 default:
381 // For any other routes, ensure basic security
382 break;
383 }
384 }
385
386 /**
387 * Basic rate limiting for public endpoints
388 * Prevents abuse of public ticket/attachment viewing
389 */
390 private function checkRateLimit()
391 {
392 $ip = Helper::getIp();
393 $transient_key = 'fs_rate_limit_' . wp_hash($ip);
394 $requests = get_transient($transient_key);
395
396 if ($requests === false) {
397 // First request in this minute
398 set_transient($transient_key, 1, 60); // 60 seconds
399 } else {
400 $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]);
403 }
404 set_transient($transient_key, $requests, 60);
405 }
406 }
407 }
408