PluginProbe
Fluent Support – Helpdesk & Customer Support Ticket System / 1.10.5
Fluent Support – Helpdesk & Customer Support Ticket System v1.10.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 1.5.5 All 68 releases
fluent-support / app / Hooks / Handlers / ExternalPages.php

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

309 lines 12.4 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
28 // Validate required parameter exists and sanitize
29 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
30 if (!isset($_REQUEST['fs_view'])) {
31 wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
32 }
33
34 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
35 $route = isset($_REQUEST['fs_view']) ? sanitize_text_field(wp_unslash($_REQUEST['fs_view'])) : '';
36
37 if (empty($route)) {
38 wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
39 }
40
41 // Validate route value
42 $methodMaps = [
43 'ticket' => 'handleTicketView'
44 ];
45
46 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 $this->verifyPublicEndpointSecurity($route);
50 $this->{$methodMaps[$route]}();
51 } else {
52 wp_die('Invalid route', 'Not Found', ['response' => 404]);
53 }
54 }
55
56 public function handleTicketView()
57 {
58 if (!Helper::isPublicSignedTicketEnabled()) {
59 $this->handleInvalidTicket();
60 } else {
61 $this->handleValidTicket();
62 }
63 }
64
65 /**
66 * Display the attachment.
67 *
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 * @return void
73 * @since 3.2.0
74 */
75 public function view_attachment()
76 {
77 // Verify this is a GET request for security
78 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- REQUEST_METHOD is server-controlled, sanitized for comparison only
79 $requestMethod = isset($_SERVER['REQUEST_METHOD']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'])) : '';
80 if ($requestMethod !== 'GET') {
81 wp_die('Invalid request method', 'Method Not Allowed', ['response' => 405]);
82 }
83
84 // Rate limiting check
85 $this->checkRateLimit();
86
87 // Validate required parameter exists and sanitize
88 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
89 if (!isset($_REQUEST['fst_file'])) {
90 wp_die('Missing required parameter', 'Bad Request', ['response' => 400]);
91 }
92
93 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses signature validation instead of nonces
94 $attachmentHash = isset($_REQUEST['fst_file']) ? sanitize_text_field(wp_unslash($_REQUEST['fst_file'])) : '';
95
96 if (empty($attachmentHash)) {
97 wp_die('Invalid Attachment Hash', 'Bad Request', ['response' => 400]);
98 }
99
100 $attachment = $this->getAttachmentByHash($attachmentHash);
101
102 if (!$attachment) {
103 wp_die('Invalid Attachment Hash', 'Not Found', ['response' => 404]);
104 }
105
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)) {
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
114 if ('local' !== $attachment->driver) {
115 if(!empty($attachment->full_url)){
116 $this->redirectToExternalAttachment($attachment->full_url);
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 gmdate() instead of date() to avoid timezone issues
150 $sign = md5($attachment->id . gmdate('YmdH'));
151 return $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::where('id', $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)->where('id', $ticketId)->first();
203
204 if (!$ticket) {
205 $this->showInvalidPortalMessage();
206 } elseif (get_current_user_id()) {
207 // Only redirect if user is logged in (to clean up URL)
208 $this->redirectToTicketView($ticket);
209 }
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
212 }
213
214 private function showInvalidPortalMessage()
215 {
216 echo '<h3 style="text-align: center; margin: 50px 0;">' . esc_html__('Invalid Support Portal URL', 'fluent-support') . '</h3>';
217 die();
218 }
219
220 private function redirectToTicketView($ticket)
221 {
222 $redirectUrl = Helper::getTicketViewUrl($ticket);
223 $this->redirectToExternalAttachment($redirectUrl);
224 }
225
226 private function redirectToExternalAttachment($redirectUrl)
227 {
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.
230 wp_redirect($redirectUrl, 307);
231 exit();
232 }
233
234 // Helper method to serve an attachment
235 private function serveLocalAttachment($attachment)
236 {
237 $file_path = realpath($attachment->file_path);
238 $uploads = wp_upload_dir();
239 $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
240
241 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
242 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
243 return;
244 }
245
246 ob_get_clean();
247 $original_user_agent = ini_get('user_agent');
248 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Temporary change for file serving, restored immediately after
249 ini_set('user_agent', 'Fluent Support/' . FLUENT_SUPPORT_VERSION . '; ' . esc_url(get_bloginfo('url')));
250
251 header("Content-Type: " . esc_attr($attachment->file_type));
252 header("Content-Disposition: inline; filename=\"" . esc_attr($attachment->title) . "\"");
253
254 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Direct file serving required for attachment download
255 readfile($file_path);
256
257 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Restoring original value
258 ini_set('user_agent', $original_user_agent);
259 die();
260 }
261
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 private function verifyPublicEndpointSecurity($route)
268 {
269 switch ($route) {
270 case 'ticket':
271 // For ticket viewing, we need at least ticket_id
272 // support_hash is required only when public signed tickets are enabled
273 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
274 if (!isset($_REQUEST['ticket_id'])) {
275 wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
276 }
277
278 // Additional validation will be done in handleValidTicket/handleInvalidTicket
279 break;
280
281 default:
282 // For any other routes, ensure basic security
283 break;
284 }
285 }
286
287 /**
288 * Basic rate limiting for public endpoints
289 * Prevents abuse of public ticket/attachment viewing
290 */
291 private function checkRateLimit()
292 {
293 $ip = Helper::getIp();
294 $transient_key = 'fs_rate_limit_' . md5($ip);
295 $requests = get_transient($transient_key);
296
297 if ($requests === false) {
298 // First request in this minute
299 set_transient($transient_key, 1, 60); // 60 seconds
300 } else {
301 $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]);
304 }
305 set_transient($transient_key, $requests, 60);
306 }
307 }
308 }
309