PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 2.2.0
Fluent Support – Helpdesk & Customer Support Ticket System v2.2.0
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 2.2.0, at app/Hooks/Handlers/ExternalPages.php

311 lines 12.6 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 // 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)) {
108 $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]);
110 }
111
112 //If external file, redirect to the secure download URL
113 if ('local' !== $attachment->driver) {
114 $fileUrl = apply_filters('fluent_support/external_attachment_url', $attachment->full_url, $attachment);
115 if (!empty($fileUrl)) {
116 $this->redirectToExternalAttachment($fileUrl);
117 } else {
118 die('File could not be found');
119 }
120 }
121
122 //Handle Local file
123 if (!file_exists($attachment->file_path)) {
124 die('File could not be found');
125 }
126 $this->serveLocalAttachment($attachment);
127 }
128
129 private function getAttachmentByHash($attachmentHash)
130 {
131 return Attachment::where('file_hash', $attachmentHash)->first();
132 }
133
134 private function validateAttachmentSignature($attachment)
135 {
136 // Sanitize and validate secure_sign input - don't trust any input
137 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
138 if (!isset($_REQUEST['secure_sign'])) {
139 return false;
140 }
141
142 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
143 $secureSign = isset($_REQUEST['secure_sign']) ? sanitize_text_field(wp_unslash($_REQUEST['secure_sign'])) : '';
144
145 if (empty($secureSign)) {
146 return false;
147 }
148
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);
152 }
153
154 private function handleInvalidTicket()
155 {
156 // Validate required parameter exists and sanitize
157 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
158 if (!isset($_REQUEST['ticket_id'])) {
159 wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
160 }
161
162 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
163 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
164
165 // Validate ticket ID is positive integer
166 if ($ticketId <= 0) {
167 wp_die('Invalid ticket ID', 'Bad Request', ['response' => 400]);
168 }
169
170 $ticket = Ticket::wherePublicIdentifier($ticketId)->first();
171
172 if (!$ticket) {
173 $this->showInvalidPortalMessage();
174 } else {
175 $this->redirectToTicketView($ticket);
176 }
177 }
178
179 private function handleValidTicket()
180 {
181 // Validate required parameters exist and sanitize
182 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
183 if (!isset($_REQUEST['support_hash']) || !isset($_REQUEST['ticket_id'])) {
184 wp_die('Missing required parameters', 'Bad Request', ['response' => 400]);
185 }
186
187 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
188 $ticketHash = isset($_REQUEST['support_hash']) ? sanitize_text_field(wp_unslash($_REQUEST['support_hash'])) : '';
189 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
190 $ticketId = isset($_REQUEST['ticket_id']) ? absint($_REQUEST['ticket_id']) : 0;
191
192 // Validate hash format (should be alphanumeric)
193 if (empty($ticketHash) || !preg_match('/^[a-zA-Z0-9]+$/', $ticketHash)) {
194 wp_die('Invalid ticket hash format', 'Bad Request', ['response' => 400]);
195 }
196
197 // Validate ticket ID is positive integer
198 if ($ticketId <= 0) {
199 wp_die('Invalid ticket ID', 'Bad Request', ['response' => 400]);
200 }
201
202 $ticket = Ticket::where('hash', $ticketHash)
203 ->wherePublicIdentifier($ticketId)
204 ->first();
205
206 if (!$ticket) {
207 $this->showInvalidPortalMessage();
208 } elseif (get_current_user_id()) {
209 // Only redirect if user is logged in (to clean up URL)
210 $this->redirectToTicketView($ticket);
211 }
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
214 }
215
216 private function showInvalidPortalMessage()
217 {
218 echo '<h3 style="text-align: center; margin: 50px 0;">' . esc_html__('Invalid Support Portal URL', 'fluent-support') . '</h3>';
219 die();
220 }
221
222 private function redirectToTicketView($ticket)
223 {
224 $redirectUrl = Helper::getTicketViewUrl($ticket);
225 $this->redirectToExternalAttachment($redirectUrl);
226 }
227
228 private function redirectToExternalAttachment($redirectUrl)
229 {
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.
232 wp_redirect($redirectUrl, 307);
233 exit();
234 }
235
236 // Helper method to serve an attachment
237 private function serveLocalAttachment($attachment)
238 {
239 $file_path = realpath($attachment->file_path);
240 $uploads = wp_upload_dir();
241 $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
242
243 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
244 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
245 return;
246 }
247
248 ob_get_clean();
249 $original_user_agent = ini_get('user_agent');
250 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Temporary change for file serving, restored immediately after
251 ini_set('user_agent', 'Fluent Support/' . FLUENT_SUPPORT_VERSION . '; ' . esc_url(get_bloginfo('url')));
252
253 header("Content-Type: " . esc_attr($attachment->file_type));
254 header("Content-Disposition: inline; filename=\"" . esc_attr($attachment->title) . "\"");
255
256 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Direct file serving required for attachment download
257 readfile($file_path);
258
259 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Restoring original value
260 ini_set('user_agent', $original_user_agent);
261 die();
262 }
263
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 private function verifyPublicEndpointSecurity($route)
270 {
271 switch ($route) {
272 case 'ticket':
273 // For ticket viewing, we need at least ticket_id
274 // support_hash is required only when public signed tickets are enabled
275 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
276 if (!isset($_REQUEST['ticket_id'])) {
277 wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
278 }
279
280 // Additional validation will be done in handleValidTicket/handleInvalidTicket
281 break;
282
283 default:
284 // For any other routes, ensure basic security
285 break;
286 }
287 }
288
289 /**
290 * Basic rate limiting for public endpoints
291 * Prevents abuse of public ticket/attachment viewing
292 */
293 private function checkRateLimit()
294 {
295 $ip = Helper::getIp();
296 $transient_key = 'fs_rate_limit_' . wp_hash($ip);
297 $requests = get_transient($transient_key);
298
299 if ($requests === false) {
300 // First request in this minute
301 set_transient($transient_key, 1, 60); // 60 seconds
302 } else {
303 $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]);
306 }
307 set_transient($transient_key, $requests, 60);
308 }
309 }
310 }
311