PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.3.2
Fluent Support – Helpdesk & Customer Support Ticket System v2.3.2
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
fluent-support / app / Hooks / Handlers / ExternalPages.php

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

312 lines 12.7 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\Helper;
8 use FluentSupport\Framework\Support\Arr;
9
10 /**
11 * ExternalPages - Handles public-facing ticket and attachment viewing
12 *
13 */
14 class ExternalPages
15 {
16 public function route()
17 {
18 // Verify this is a GET request for security
19 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
20 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
21 if ($requestMethod !== 'GET') {
22 wp_die('Invalid request method', 'Method Not Allowed', ['response' => 405]);
23 }
24
25 // Rate limiting check
26 $this->checkRateLimit();
27 // Validate required parameter exists and sanitize
28 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
29 if (!isset($_REQUEST['fs_view'])) {
30 wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
31 }
32
33 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
34 $route = isset($_REQUEST['fs_view']) ? sanitize_text_field(wp_unslash($_REQUEST['fs_view'])) : '';
35
36 if (empty($route)) {
37 wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
38 }
39
40 // Validate route value
41 $methodMaps = [
42 'ticket' => 'handleTicketView'
43 ];
44
45 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 $this->verifyPublicEndpointSecurity($route);
49 $this->{$methodMaps[$route]}();
50 } else {
51 wp_die('Invalid route', 'Not Found', ['response' => 404]);
52 }
53 }
54
55 public function handleTicketView()
56 {
57 if (!Helper::isPublicSignedTicketEnabled()) {
58 $this->handleInvalidTicket();
59 } else {
60 $this->handleValidTicket();
61 }
62 }
63
64 /**
65 * Display the attachment.
66 *
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 * @return void
72 * @since 3.2.0
73 */
74 public function view_attachment()
75 {
76 // Verify this is a GET request for security
77 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
78 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
79 if ($requestMethod !== 'GET') {
80 wp_die('Invalid request method', 'Method Not Allowed', ['response' => 405]);
81 }
82
83 // Rate limiting check
84 $this->checkRateLimit();
85
86 // Validate required parameter exists and sanitize
87 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
88 if (!isset($_REQUEST['fst_file'])) {
89 wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
90 }
91
92 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
93 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
94
95 if (empty($attachmentHash)) {
96 wp_die('Invalid Attachment Hash', 'Bad Request', ['response' => 400]);
97 }
98
99 $attachment = $this->getAttachmentByHash($attachmentHash);
100
101 if (!$attachment) {
102 wp_die('Invalid Attachment Hash', 'Not Found', ['response' => 404]);
103 }
104
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.
108 if ($attachment->status !== 'inline' && !$this->validateAttachmentSignature($attachment)) {
109 $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]);
111 }
112
113 //If external file, redirect to the secure download URL
114 if ('local' !== $attachment->driver) {
115 $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
116 if (!empty($fileUrl)) {
117 $this->redirectToExternalAttachment($fileUrl);
118 } else {
119 die('File could not be found');
120 }
121 }
122
123 //Handle Local file
124 if (!file_exists($attachment->file_path)) {
125 die('File could not be found');
126 }
127 $this->serveLocalAttachment($attachment);
128 }
129
130 private function getAttachmentByHash($attachmentHash)
131 {
132 return Attachment::where('file_hash', $attachmentHash)->first();
133 }
134
135 private function validateAttachmentSignature($attachment)
136 {
137 // Sanitize and validate secure_sign input - don't trust any input
138 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
139 if (!isset($_REQUEST['secure_sign'])) {
140 return false;
141 }
142
143 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
144 $secureSign = isset($_REQUEST['secure_sign']) ? sanitize_text_field(wp_unslash($_REQUEST['secure_sign'])) : '';
145
146 if (empty($secureSign)) {
147 return false;
148 }
149
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);
153 }
154
155 private function handleInvalidTicket()
156 {
157 // Validate required parameter exists and sanitize
158 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
159 if (!isset($_REQUEST['ticket_id'])) {
160 wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
161 }
162
163 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
164 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
165
166 // Validate ticket ID is positive integer
167 if ($ticketId <= 0) {
168 wp_die('Invalid ticket ID', 'Bad Request', ['response' => 400]);
169 }
170
171 $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
172
173 if (!$ticket) {
174 $this->showInvalidPortalMessage();
175 } else {
176 $this->redirectToTicketView($ticket);
177 }
178 }
179
180 private function handleValidTicket()
181 {
182 // Validate required parameters exist and sanitize
183 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
184 if (!isset($_REQUEST['support_hash']) || !isset($_REQUEST['ticket_id'])) {
185 wp_die('Missing required parameters', 'Bad Request', ['response' => 400]);
186 }
187
188 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
189 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
190 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
191 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
192
193 // Validate hash format (should be alphanumeric)
194 if (empty($ticketHash) || !preg_match('/^[a-zA-Z0-9]+$/', $ticketHash)) {
195 wp_die('Invalid ticket hash format', 'Bad Request', ['response' => 400]);
196 }
197
198 // Validate ticket ID is positive integer
199 if ($ticketId <= 0) {
200 wp_die('Invalid ticket ID', 'Bad Request', ['response' => 400]);
201 }
202
203 $ticket = Ticket::where('hash', $ticketHash)
204 ->wherePublicIdentifier($ticketId)
205 ->first();
206
207 if (!$ticket) {
208 $this->showInvalidPortalMessage();
209 } elseif (get_current_user_id()) {
210 // Only redirect if user is logged in (to clean up URL)
211 $this->redirectToTicketView($ticket);
212 }
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
215 }
216
217 private function showInvalidPortalMessage()
218 {
219 echo '<h3 style="text-align: center; margin: 50px 0;">' . esc_html__('Invalid Support Portal URL', 'fluent-support') . '</h3>';
220 die();
221 }
222
223 private function redirectToTicketView($ticket)
224 {
225 $redirectUrl = Helper::getTicketViewUrl($ticket);
226 $this->redirectToExternalAttachment($redirectUrl);
227 }
228
229 private function redirectToExternalAttachment($redirectUrl)
230 {
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.
233 wp_redirect($redirectUrl, 307);
234 exit();
235 }
236
237 // Helper method to serve an attachment
238 private function serveLocalAttachment($attachment)
239 {
240 $file_path = realpath($attachment->file_path);
241 $uploads = wp_upload_dir();
242 $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
243
244 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
245 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
246 return;
247 }
248
249 ob_get_clean();
250 $original_user_agent = ini_get('user_agent');
251 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Temporary change for file serving, restored immediately after
252 ini_set('user_agent', 'Fluent Support/' . FLUENT_SUPPORT_VERSION . '; ' . esc_url(get_bloginfo('url')));
253
254 header("Content-Type: " . esc_attr($attachment->file_type));
255 header("Content-Disposition: inline; filename=\"" . esc_attr($attachment->title) . "\"");
256
257 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Direct file serving required for attachment download
258 readfile($file_path);
259
260 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Restoring original value
261 ini_set('user_agent', $original_user_agent);
262 die();
263 }
264
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 private function verifyPublicEndpointSecurity($route)
271 {
272 switch ($route) {
273 case 'ticket':
274 // For ticket viewing, we need at least ticket_id
275 // support_hash is required only when public signed tickets are enabled
276 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
277 if (!isset($_REQUEST['ticket_id'])) {
278 wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
279 }
280
281 // Additional validation will be done in handleValidTicket/handleInvalidTicket
282 break;
283
284 default:
285 // For any other routes, ensure basic security
286 break;
287 }
288 }
289
290 /**
291 * Basic rate limiting for public endpoints
292 * Prevents abuse of public ticket/attachment viewing
293 */
294 private function checkRateLimit()
295 {
296 $ip = Helper::getIp();
297 $transient_key = 'fs_rate_limit_' . wp_hash($ip);
298 $requests = get_transient($transient_key);
299
300 if ($requests === false) {
301 // First request in this minute
302 set_transient($transient_key, 1, 60); // 60 seconds
303 } else {
304 $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]);
307 }
308 set_transient($transient_key, $requests, 60);
309 }
310 }
311 }
312