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 +140 -76 1.10.4 → 2.4.5 View file →
@@ -3,33 +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 -
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,26 +30,112 @@
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 - 'ticket' => 'handleTicketView'
38 + 'ticket' => 'handleTicketView',
39 + 'email_claim' => 'handleEmailClaim'
44 40 ];
45 41
46 42 if (isset($methodMaps[$route])) {
47 - // For public endpoints, verify security using ticket hash validation instead of nonces
48 - // This is appropriate for public endpoints that must work without user authentication
49 43 $this->verifyPublicEndpointSecurity($route);
50 44 $this->{$methodMaps[$route]}();
51 45 } else {
52 - 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]);
53 47 }
54 48 }
55 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 +
56 138 public function handleTicketView()
57 139 {
58 140 if (!Helper::isPublicSignedTicketEnabled()) {
59 141 $this->handleInvalidTicket();
@@ -62,33 +144,26 @@
62 144 }
63 145 }
64 146
65 147 /**
66 - * Display the attachment.
148 + * Serve an attachment by its `fst_file` hash and signature.
67 149 *
68 - * Uses the new rewrite endpoint to get an attachment ID
69 - * and display the attachment if the currently logged in user
70 - * has the authorization to.
71 - *
72 150 * @return void
73 151 * @since 3.2.0
74 152 */
75 153 public function view_attachment()
76 154 {
77 - // Verify this is a GET request for security
78 155 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
79 156 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
80 157 if ($requestMethod !== 'GET') {
81 - 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]);
82 159 }
83 160
84 - // Rate limiting check
85 161 $this->checkRateLimit();
86 162
87 - // Validate required parameter exists and sanitize
88 163 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
89 164 if (!isset($_REQUEST['fst_file'])) {
90 - 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]);
91 166 }
92 167
93 168 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
94 169 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
@@ -93,34 +168,32 @@
93 168 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
94 169 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
95 170
96 171 if (empty($attachmentHash)) {
97 - 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]);
98 173 }
99 174
100 175 $attachment = $this->getAttachmentByHash($attachmentHash);
101 176
102 177 if (!$attachment) {
103 - 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]);
104 179 }
105 180
106 - // For public endpoints, verify security using signature validation instead of nonces
107 - // This is appropriate for public endpoints that must work without user authentication
108 - 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)) {
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
114 187 if ('local' !== $attachment->driver) {
115 - if(!empty($attachment->full_url)){
116 - $this->redirectToExternalAttachment($attachment->full_url);
117 - }else{
188 + $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
189 + if (!empty($fileUrl)) {
190 + $this->redirectToExternalAttachment($fileUrl);
191 + } else {
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,30 +217,38 @@
145 217 if (empty($secureSign)) {
146 218 return false;
147 219 }
148 220
149 - // Use gmdate() instead of date() to avoid timezone issues
150 - $sign = md5($attachment->id . gmdate('YmdH'));
151 - return $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 - $ticket = Ticket::where('id', $ticketId)->first();
250 + $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
171 251
172 252 if (!$ticket) {
173 253 $this->showInvalidPortalMessage();
174 254 } else {
@@ -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,28 +267,26 @@
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 - $ticket = Ticket::where('hash', $ticketHash)->where('id', $ticketId)->first();
279 + $ticket = Ticket::where('hash', $ticketHash)
280 + ->wherePublicIdentifier($ticketId)
281 + ->first();
203 282
204 283 if (!$ticket) {
205 284 $this->showInvalidPortalMessage();
206 285 } elseif (get_current_user_id()) {
207 - // Only redirect if user is logged in (to clean up URL)
208 286 $this->redirectToTicketView($ticket);
209 287 }
210 - // If not logged in, let the page load normally with the hash parameters
211 - // 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.
212 289 }
213 290
214 291 private function showInvalidPortalMessage()
215 292 {
@@ -224,20 +301,18 @@
224 301 }
225 302
226 303 private function redirectToExternalAttachment($redirectUrl)
227 304 {
228 - // This redirect is required to serve attachments stored on third-party services (Google Drive, Dropbox).
229 - // Safe and intentional: not a malicious or undesired redirect.
305 + // Not wp_safe_redirect: remote attachments (Drive, Dropbox) live on other hosts.
230 306 wp_redirect($redirectUrl, 307);
231 307 exit();
232 308 }
233 309
234 - // Helper method to serve an attachment
235 310 private function serveLocalAttachment($attachment)
236 311 {
237 312 $file_path = realpath($attachment->file_path);
238 313 $uploads = wp_upload_dir();
239 - $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
314 + $uploads_dir = realpath($uploads['basedir']);
240 315
241 316 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
242 317 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
243 318 return;
@@ -258,50 +333,39 @@
258 333 ini_set('user_agent', $original_user_agent);
259 334 die();
260 335 }
261 336
262 - /**
263 - * Verify security for public endpoints
264 - * This implements a custom security mechanism appropriate for public endpoints
265 - * that need to work without user authentication while maintaining security
266 - */
267 337 private function verifyPublicEndpointSecurity($route)
268 338 {
269 339 switch ($route) {
270 340 case 'ticket':
271 - // For ticket viewing, we need at least ticket_id
272 - // support_hash is required only when public signed tickets are enabled
341 + // support_hash is only required when signed ticket links are enabled.
273 342 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
274 343 if (!isset($_REQUEST['ticket_id'])) {
275 - 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]);
276 345 }
277 -
278 - // Additional validation will be done in handleValidTicket/handleInvalidTicket
279 346 break;
280 347
281 348 default:
282 - // For any other routes, ensure basic security
283 349 break;
284 350 }
285 351 }
286 352
287 353 /**
288 - * Basic rate limiting for public endpoints
289 - * Prevents abuse of public ticket/attachment viewing
354 + * 30 requests per minute per IP on the public endpoints.
290 355 */
291 356 private function checkRateLimit()
292 357 {
293 358 $ip = Helper::getIp();
294 - $transient_key = 'fs_rate_limit_' . md5($ip);
359 + $transient_key = 'fs_rate_limit_' . wp_hash($ip);
295 360 $requests = get_transient($transient_key);
296 361
297 362 if ($requests === false) {
298 - // First request in this minute
299 - set_transient($transient_key, 1, 60); // 60 seconds
363 + set_transient($transient_key, 1, 60);
300 364 } else {
301 365 $requests++;
302 - if ($requests > 30) { // Max 30 requests per minute per IP
303 - 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]);
304 368 }
305 369 set_transient($transient_key, $requests, 60);
306 370 }
307 371 }