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 +131 -69 2.2.0 → 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,25 +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 - // For public endpoints, verify security using signature validation instead of nonces
106 - // This is appropriate for public endpoints that must work without user authentication
107 - 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)) {
108 183 $dieMessage = esc_html__('Sorry, Your secure sign is invalid, Please reload the previous page and get new signed url', 'fluent-support');
109 - wp_die(esc_html($dieMessage), 'Forbidden', ['response' => 403]);
184 + wp_die(esc_html($dieMessage), esc_html__('Forbidden', 'fluent-support'), ['response' => 403]);
110 185 }
111 186
112 - //If external file, redirect to the secure download URL
113 187 if ('local' !== $attachment->driver) {
114 188 $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
115 189 if (!empty($fileUrl)) {
116 190 $this->redirectToExternalAttachment($fileUrl);
@@ -118,9 +192,8 @@
118 192 die('File could not be found');
119 193 }
120 194 }
121 195
122 - //Handle Local file
123 196 if (!file_exists($attachment->file_path)) {
124 197 die('File could not be found');
125 198 }
126 199 $this->serveLocalAttachment($attachment);
@@ -132,9 +205,8 @@
132 205 }
133 206
134 207 private function validateAttachmentSignature($attachment)
135 208 {
136 - // Sanitize and validate secure_sign input - don't trust any input
137 209 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
138 210 if (!isset($_REQUEST['secure_sign'])) {
139 211 return false;
140 212 }
@@ -145,27 +217,35 @@
145 217 if (empty($secureSign)) {
146 218 return false;
147 219 }
148 220
149 - // Use HMAC-SHA256 for secure signature verification
150 - $sign = hash_hmac('sha256', $attachment->id . '|' . gmdate('YmdH'), wp_salt('secure_auth'));
151 - 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);
152 234 }
153 235
154 236 private function handleInvalidTicket()
155 237 {
156 - // Validate required parameter exists and sanitize
157 238 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
158 239 if (!isset($_REQUEST['ticket_id'])) {
159 - 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]);
160 241 }
161 242
162 243 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
163 244 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
164 245
165 - // Validate ticket ID is positive integer
166 246 if ($ticketId <= 0) {
167 - 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]);
168 248 }
169 249
170 250 $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
171 251
@@ -177,12 +257,11 @@
177 257 }
178 258
179 259 private function handleValidTicket()
180 260 {
181 - // Validate required parameters exist and sanitize
182 261 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
183 262 if (!isset($_REQUEST['support_hash']) || !isset($_REQUEST['ticket_id'])) {
184 - 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]);
185 264 }
186 265
187 266 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
188 267 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
@@ -188,16 +267,14 @@
188 267 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
189 268 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
190 269 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
191 270
192 - // Validate hash format (should be alphanumeric)
193 271 if (empty($ticketHash) || !preg_match('/^[a-zA-Z0-9]+$/', $ticketHash)) {
194 - 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]);
195 273 }
196 274
197 - // Validate ticket ID is positive integer
198 275 if ($ticketId <= 0) {
199 - 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]);
200 277 }
201 278
202 279 $ticket = Ticket::where('hash', $ticketHash)
203 280 ->wherePublicIdentifier($ticketId)
@@ -205,13 +282,11 @@
205 282
206 283 if (!$ticket) {
207 284 $this->showInvalidPortalMessage();
208 285 } elseif (get_current_user_id()) {
209 - // Only redirect if user is logged in (to clean up URL)
210 286 $this->redirectToTicketView($ticket);
211 287 }
212 - // If not logged in, let the page load normally with the hash parameters
213 - // 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.
214 289 }
215 290
216 291 private function showInvalidPortalMessage()
217 292 {
@@ -226,20 +301,18 @@
226 301 }
227 302
228 303 private function redirectToExternalAttachment($redirectUrl)
229 304 {
230 - // This redirect is required to serve attachments stored on third-party services (Google Drive, Dropbox).
231 - // Safe and intentional: not a malicious or undesired redirect.
305 + // Not wp_safe_redirect: remote attachments (Drive, Dropbox) live on other hosts.
232 306 wp_redirect($redirectUrl, 307);
233 307 exit();
234 308 }
235 309
236 - // Helper method to serve an attachment
237 310 private function serveLocalAttachment($attachment)
238 311 {
239 312 $file_path = realpath($attachment->file_path);
240 313 $uploads = wp_upload_dir();
241 - $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
314 + $uploads_dir = realpath($uploads['basedir']);
242 315
243 316 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
244 317 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
245 318 return;
@@ -260,36 +333,26 @@
260 333 ini_set('user_agent', $original_user_agent);
261 334 die();
262 335 }
263 336
264 - /**
265 - * Verify security for public endpoints
266 - * This implements a custom security mechanism appropriate for public endpoints
267 - * that need to work without user authentication while maintaining security
268 - */
269 337 private function verifyPublicEndpointSecurity($route)
270 338 {
271 339 switch ($route) {
272 340 case 'ticket':
273 - // For ticket viewing, we need at least ticket_id
274 - // support_hash is required only when public signed tickets are enabled
341 + // support_hash is only required when signed ticket links are enabled.
275 342 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
276 343 if (!isset($_REQUEST['ticket_id'])) {
277 - 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]);
278 345 }
279 -
280 - // Additional validation will be done in handleValidTicket/handleInvalidTicket
281 346 break;
282 347
283 348 default:
284 - // For any other routes, ensure basic security
285 349 break;
286 350 }
287 351 }
288 352
289 353 /**
290 - * Basic rate limiting for public endpoints
291 - * Prevents abuse of public ticket/attachment viewing
354 + * 30 requests per minute per IP on the public endpoints.
292 355 */
293 356 private function checkRateLimit()
294 357 {
295 358 $ip = Helper::getIp();
@@ -296,14 +359,13 @@
296 359 $transient_key = 'fs_rate_limit_' . wp_hash($ip);
297 360 $requests = get_transient($transient_key);
298 361
299 362 if ($requests === false) {
300 - // First request in this minute
301 - set_transient($transient_key, 1, 60); // 60 seconds
363 + set_transient($transient_key, 1, 60);
302 364 } else {
303 365 $requests++;
304 - if ($requests > 30) { // Max 30 requests per minute per IP
305 - 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]);
306 368 }
307 369 set_transient($transient_key, $requests, 60);
308 370 }
309 371 }