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 +130 -69 2.3.1 → 2.4.5 View file →
@@ -3,32 +3,29 @@
3 3 namespace FluentSupport\App\Hooks\Handlers;
4 4
5 5 use FluentSupport\App\Models\Attachment;
6 6 use FluentSupport\App\Models\Ticket;
7 +use FluentSupport\App\Services\EmailClaimService;
7 8 use FluentSupport\App\Services\Helper;
8 9 use FluentSupport\Framework\Support\Arr;
9 10
10 11 /**
11 - * ExternalPages - Handles public-facing ticket and attachment viewing
12 - *
12 + * Public ticket, attachment and email-claim pages.
13 13 */
14 14 class ExternalPages
15 15 {
16 16 public function route()
17 17 {
18 - // Verify this is a GET request for security
19 18 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
20 19 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
21 20 if ($requestMethod !== 'GET') {
22 - 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]);
23 22 }
24 23
25 - // Rate limiting check
26 24 $this->checkRateLimit();
27 - // Validate required parameter exists and sanitize
28 25 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
29 26 if (!isset($_REQUEST['fs_view'])) {
30 - 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]);
31 28 }
32 29
33 30 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
34 31 $route = isset($_REQUEST['fs_view']) ? sanitize_text_field(wp_unslash($_REQUEST['fs_view'])) : '';
@@ -33,26 +30,112 @@
33 30 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
34 31 $route = isset($_REQUEST['fs_view']) ? sanitize_text_field(wp_unslash($_REQUEST['fs_view'])) : '';
35 32
36 33 if (empty($route)) {
37 - 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]);
38 35 }
39 36
40 - // Validate route value
41 37 $methodMaps = [
42 - 'ticket' => 'handleTicketView'
38 + 'ticket' => 'handleTicketView',
39 + 'email_claim' => 'handleEmailClaim'
43 40 ];
44 41
45 42 if (isset($methodMaps[$route])) {
46 - // For public endpoints, verify security using ticket hash validation instead of nonces
47 - // This is appropriate for public endpoints that must work without user authentication
48 43 $this->verifyPublicEndpointSecurity($route);
49 44 $this->{$methodMaps[$route]}();
50 45 } else {
51 - 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]);
52 47 }
53 48 }
54 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');
100 + }
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 +
55 138 public function handleTicketView()
56 139 {
57 140 if (!Helper::isPublicSignedTicketEnabled()) {
58 141 $this->handleInvalidTicket();
@@ -61,33 +144,26 @@
61 144 }
62 145 }
63 146
64 147 /**
65 - * Display the attachment.
148 + * Serve an attachment by its `fst_file` hash and signature.
66 149 *
67 - * Uses the new rewrite endpoint to get an attachment ID
68 - * and display the attachment if the currently logged in user
69 - * has the authorization to.
70 - *
71 150 * @return void
72 151 * @since 3.2.0
73 152 */
74 153 public function view_attachment()
75 154 {
76 - // Verify this is a GET request for security
77 155 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
78 156 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
79 157 if ($requestMethod !== 'GET') {
80 - 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]);
81 159 }
82 160
83 - // Rate limiting check
84 161 $this->checkRateLimit();
85 162
86 - // Validate required parameter exists and sanitize
87 163 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
88 164 if (!isset($_REQUEST['fst_file'])) {
89 - 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]);
90 166 }
91 167
92 168 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
93 169 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
@@ -92,26 +168,23 @@
92 168 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
93 169 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
94 170
95 171 if (empty($attachmentHash)) {
96 - 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]);
97 173 }
98 174
99 175 $attachment = $this->getAttachmentByHash($attachmentHash);
100 176
101 177 if (!$attachment) {
102 - 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]);
103 179 }
104 180
105 - // Inline attachments (paste images embedded in ticket/email content) are publicly accessible
106 - // without a signature because they are already shared with customers via email.
107 - // Other attachments require HMAC signature validation.
181 + // Inline (pasted) images skip the signature because they are already embedded in emails.
108 182 if ($attachment->status !== 'inline' && !$this->validateAttachmentSignature($attachment)) {
109 183 $dieMessage = esc_html__('Sorry, Your secure sign is invalid, Please reload the previous page and get new signed url', 'fluent-support');
110 - wp_die(esc_html($dieMessage), 'Forbidden', ['response' => 403]);
184 + wp_die(esc_html($dieMessage), esc_html__('Forbidden', 'fluent-support'), ['response' => 403]);
111 185 }
112 186
113 - //If external file, redirect to the secure download URL
114 187 if ('local' !== $attachment->driver) {
115 188 $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
116 189 if (!empty($fileUrl)) {
117 190 $this->redirectToExternalAttachment($fileUrl);
@@ -119,9 +192,8 @@
119 192 die('File could not be found');
120 193 }
121 194 }
122 195
123 - //Handle Local file
124 196 if (!file_exists($attachment->file_path)) {
125 197 die('File could not be found');
126 198 }
127 199 $this->serveLocalAttachment($attachment);
@@ -133,9 +205,8 @@
133 205 }
134 206
135 207 private function validateAttachmentSignature($attachment)
136 208 {
137 - // Sanitize and validate secure_sign input - don't trust any input
138 209 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
139 210 if (!isset($_REQUEST['secure_sign'])) {
140 211 return false;
141 212 }
@@ -146,27 +217,35 @@
146 217 if (empty($secureSign)) {
147 218 return false;
148 219 }
149 220
150 - // Use HMAC-SHA256 for secure signature verification
151 - $sign = hash_hmac('sha256', $attachment->id . '|' . gmdate('YmdH'), wp_salt('secure_auth'));
152 - 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);
153 234 }
154 235
155 236 private function handleInvalidTicket()
156 237 {
157 - // Validate required parameter exists and sanitize
158 238 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
159 239 if (!isset($_REQUEST['ticket_id'])) {
160 - 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]);
161 241 }
162 242
163 243 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
164 244 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
165 245
166 - // Validate ticket ID is positive integer
167 246 if ($ticketId <= 0) {
168 - 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]);
169 248 }
170 249
171 250 $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
172 251
@@ -178,12 +257,11 @@
178 257 }
179 258
180 259 private function handleValidTicket()
181 260 {
182 - // Validate required parameters exist and sanitize
183 261 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
184 262 if (!isset($_REQUEST['support_hash']) || !isset($_REQUEST['ticket_id'])) {
185 - 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]);
186 264 }
187 265
188 266 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
189 267 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
@@ -189,16 +267,14 @@
189 267 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
190 268 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
191 269 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
192 270
193 - // Validate hash format (should be alphanumeric)
194 271 if (empty($ticketHash) || !preg_match('/^[a-zA-Z0-9]+$/', $ticketHash)) {
195 - 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]);
196 273 }
197 274
198 - // Validate ticket ID is positive integer
199 275 if ($ticketId <= 0) {
200 - 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]);
201 277 }
202 278
203 279 $ticket = Ticket::where('hash', $ticketHash)
204 280 ->wherePublicIdentifier($ticketId)
@@ -206,13 +282,11 @@
206 282
207 283 if (!$ticket) {
208 284 $this->showInvalidPortalMessage();
209 285 } elseif (get_current_user_id()) {
210 - // Only redirect if user is logged in (to clean up URL)
211 286 $this->redirectToTicketView($ticket);
212 287 }
213 - // If not logged in, let the page load normally with the hash parameters
214 - // 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.
215 289 }
216 290
217 291 private function showInvalidPortalMessage()
218 292 {
@@ -227,20 +301,18 @@
227 301 }
228 302
229 303 private function redirectToExternalAttachment($redirectUrl)
230 304 {
231 - // This redirect is required to serve attachments stored on third-party services (Google Drive, Dropbox).
232 - // Safe and intentional: not a malicious or undesired redirect.
305 + // Not wp_safe_redirect: remote attachments (Drive, Dropbox) live on other hosts.
233 306 wp_redirect($redirectUrl, 307);
234 307 exit();
235 308 }
236 309
237 - // Helper method to serve an attachment
238 310 private function serveLocalAttachment($attachment)
239 311 {
240 312 $file_path = realpath($attachment->file_path);
241 313 $uploads = wp_upload_dir();
242 - $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
314 + $uploads_dir = realpath($uploads['basedir']);
243 315
244 316 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
245 317 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
246 318 return;
@@ -261,36 +333,26 @@
261 333 ini_set('user_agent', $original_user_agent);
262 334 die();
263 335 }
264 336
265 - /**
266 - * Verify security for public endpoints
267 - * This implements a custom security mechanism appropriate for public endpoints
268 - * that need to work without user authentication while maintaining security
269 - */
270 337 private function verifyPublicEndpointSecurity($route)
271 338 {
272 339 switch ($route) {
273 340 case 'ticket':
274 - // For ticket viewing, we need at least ticket_id
275 - // support_hash is required only when public signed tickets are enabled
341 + // support_hash is only required when signed ticket links are enabled.
276 342 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
277 343 if (!isset($_REQUEST['ticket_id'])) {
278 - 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]);
279 345 }
280 -
281 - // Additional validation will be done in handleValidTicket/handleInvalidTicket
282 346 break;
283 347
284 348 default:
285 - // For any other routes, ensure basic security
286 349 break;
287 350 }
288 351 }
289 352
290 353 /**
291 - * Basic rate limiting for public endpoints
292 - * Prevents abuse of public ticket/attachment viewing
354 + * 30 requests per minute per IP on the public endpoints.
293 355 */
294 356 private function checkRateLimit()
295 357 {
296 358 $ip = Helper::getIp();
@@ -297,14 +359,13 @@
297 359 $transient_key = 'fs_rate_limit_' . wp_hash($ip);
298 360 $requests = get_transient($transient_key);
299 361
300 362 if ($requests === false) {
301 - // First request in this minute
302 - set_transient($transient_key, 1, 60); // 60 seconds
363 + set_transient($transient_key, 1, 60);
303 364 } else {
304 365 $requests++;
305 - if ($requests > 30) { // Max 30 requests per minute per IP
306 - 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]);
307 368 }
308 369 set_transient($transient_key, $requests, 60);
309 370 }
310 371 }