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

308 lines 12.2 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 wp_safe_redirect($redirectUrl, 307);
229 exit();
230 }
231
232 // Helper method to serve an attachment
233 private function serveLocalAttachment($attachment)
234 {
235 $file_path = realpath($attachment->file_path);
236 $uploads = wp_upload_dir();
237 $uploads_dir = realpath($uploads['basedir']); // Ensures both paths are absolute
238
239 if (!$file_path || !$uploads_dir || strpos($file_path, $uploads_dir) !== 0 || !file_exists($file_path)) {
240 wp_die(esc_html__('File not found or access denied', 'fluent-support'), 403);
241 return;
242 }
243
244 ob_get_clean();
245 $original_user_agent = ini_get('user_agent');
246 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Temporary change for file serving, restored immediately after
247 ini_set('user_agent', 'Fluent Support/' . FLUENT_SUPPORT_VERSION . '; ' . esc_url(get_bloginfo('url')));
248
249 header("Content-Type: " . esc_attr($attachment->file_type));
250 header("Content-Disposition: inline; filename=\"" . esc_attr($attachment->title) . "\"");
251
252 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- Direct file serving required for attachment download
253 readfile($file_path);
254
255 // phpcs:ignore WordPress.PHP.IniSet.Risky -- Restoring original value
256 ini_set('user_agent', $original_user_agent);
257 die();
258 }
259
260 /**
261 * Verify security for public endpoints
262 * This implements a custom security mechanism appropriate for public endpoints
263 * that need to work without user authentication while maintaining security
264 */
265 private function verifyPublicEndpointSecurity($route)
266 {
267 switch ($route) {
268 case 'ticket':
269 // For ticket viewing, we need at least ticket_id
270 // support_hash is required only when public signed tickets are enabled
271 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public endpoint uses hash validation instead of nonces
272 if (!isset($_REQUEST['ticket_id'])) {
273 wp_die('Missing ticket ID parameter', 'Bad Request', ['response' => 400]);
274 }
275
276 // Additional validation will be done in handleValidTicket/handleInvalidTicket
277 break;
278
279 default:
280 // For any other routes, ensure basic security
281 break;
282 }
283 }
284
285 /**
286 * Basic rate limiting for public endpoints
287 * Prevents abuse of public ticket/attachment viewing
288 */
289 private function checkRateLimit()
290 {
291 $ip = Helper::getIp();
292 $transient_key = 'fs_rate_limit_' . md5($ip);
293 $requests = get_transient($transient_key);
294
295 if ($requests === false) {
296 // First request in this minute
297 set_transient($transient_key, 1, 60); // 60 seconds
298 } else {
299 $requests++;
300 if ($requests > 30) { // Max 30 requests per minute per IP
301 wp_die('Rate limit exceeded. Please try again later.', 'Too Many Requests', ['response' => 429]);
302 }
303 set_transient($transient_key, $requests, 60);
304 }
305 }
306
307 }
308