PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.0
2.1.0 2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 All 42 releases
← All changes | app/Services/PublicAccessService.php +100 -8 2.0.42.1.0 View file →
@@ -1,8 +1,11 @@
1 1 <?php
2 2
3 3 namespace FluentBoards\App\Services;
4 4
5 +use FluentBoards\App\Models\Board;
6 +use FluentBoards\App\Models\User;
7 +
5 8 class PublicAccessService
6 9 {
7 10 const TOKEN_DELIMITER = '|';
8 11
@@ -41,20 +44,37 @@
41 44
42 45 return hash_equals(self::signature($boardId), $tokenSignature);
43 46 }
44 47
48 + /**
49 + * Fields a logged-out visitor may see for a board member or task assignee.
50 + * Everything else on the WordPress user row (user_login, user_email, ...) is dropped.
51 + */
52 + const PUBLIC_USER_FIELDS = ['ID', 'display_name', 'photo', 'role'];
53 +
54 + /**
55 + * Reduce user records to the public-safe shape defined by PUBLIC_USER_FIELDS.
56 + *
57 + * Accepts an ORM collection, a plain array, or any iterable of user models/objects/arrays
58 + * and always returns a list of plain arrays, so no User model can reach a public response.
59 + */
45 60 public static function sanitizeUsers($users)
46 61 {
47 62 $sanitizedUsers = [];
48 63
49 - foreach ((array)$users as $user) {
64 + foreach (self::toIterable($users) as $user) {
65 + if (is_array($user)) {
66 + $user = (object)$user;
67 + }
68 +
50 69 if (!is_object($user) || empty($user->ID)) {
51 70 continue;
52 71 }
53 72
54 73 $role = 'Member';
55 - if (isset($user->pivot) && isset($user->pivot->settings)) {
56 - $settings = maybe_unserialize($user->pivot->settings);
74 + $pivot = isset($user->pivot) ? $user->pivot : null;
75 + if ($pivot && isset($pivot->settings)) {
76 + $settings = maybe_unserialize($pivot->settings);
57 77 if (is_array($settings)) {
58 78 if (!empty($settings['is_admin'])) {
59 79 $role = 'Admin';
60 80 } elseif (!empty($settings['is_viewer_only'])) {
@@ -62,21 +82,93 @@
62 82 }
63 83 }
64 84 }
65 85
66 - $displayName = isset($user->display_name) ? $user->display_name : '';
86 + $displayName = isset($user->display_name) ? (string)$user->display_name : '';
87 + $email = isset($user->user_email) ? (string)$user->user_email : '';
67 88
68 - $sanitizedUsers[] = [
89 + $record = [
69 90 'ID' => (int)$user->ID,
70 91 'display_name' => $displayName,
71 - 'photo' => fluent_boards_user_avatar($user->user_email ?? '', $displayName),
92 + 'photo' => fluent_boards_user_avatar($email, $displayName),
72 93 'role' => $role
73 94 ];
95 +
96 + // Explicit allow-list: anything not in PUBLIC_USER_FIELDS can never reach the response.
97 + $sanitizedUsers[] = array_intersect_key($record, array_flip(self::PUBLIC_USER_FIELDS));
74 98 }
75 99
76 100 return $sanitizedUsers;
77 101 }
78 102
103 + /**
104 + * Swap a user relation on a model for its public-safe list before the model is serialized.
105 + *
106 + * Loaded relations override attributes of the same name during toArray()/JSON encoding,
107 + * so assigning the sanitized list as an attribute alone is not enough: the relation must be
108 + * unloaded first. Only the sanitized plain array is left on the model.
109 + */
110 + public static function replaceUserRelation($model, $relation)
111 + {
112 + if ($model->relationLoaded($relation)) {
113 + $users = $model->getRelation($relation);
114 + } else {
115 + $users = $model->$relation()->get();
116 + }
117 +
118 + $model->unsetRelation($relation);
119 + $model->setAttribute($relation, self::sanitizeUsers($users));
120 +
121 + return $model;
122 + }
123 +
124 + /**
125 + * Defense in depth for public responses: drop any still-loaded relation that would
126 + * serialize a WordPress user model, whatever name it was loaded under.
127 + */
128 + public static function stripUserRelations($model)
129 + {
130 + foreach ($model->getRelations() as $name => $value) {
131 + if (self::containsUserModel($value)) {
132 + $model->unsetRelation($name);
133 + }
134 + }
135 +
136 + return $model;
137 + }
138 +
139 + private static function containsUserModel($value)
140 + {
141 + if ($value instanceof User) {
142 + return true;
143 + }
144 +
145 + foreach (self::toIterable($value) as $item) {
146 + if ($item instanceof User) {
147 + return true;
148 + }
149 + }
150 +
151 + return false;
152 + }
153 +
154 + private static function toIterable($value)
155 + {
156 + if (is_array($value)) {
157 + return $value;
158 + }
159 +
160 + if (is_object($value) && method_exists($value, 'all')) {
161 + return (array)$value->all();
162 + }
163 +
164 + if ($value instanceof \Traversable) {
165 + return iterator_to_array($value, false);
166 + }
167 +
168 + return [];
169 + }
170 +
79 171 private static function signature($boardId)
80 172 {
81 173 $secret = self::getSecretForBoard($boardId);
82 174
@@ -84,9 +176,9 @@
84 176 }
85 177
86 178 private static function getSecretForBoard($boardId)
87 179 {
88 - $board = \FluentBoards\App\Models\Board::find($boardId);
180 + $board = Board::find($boardId);
89 181 $perBoardSalt = $board ? $board->getMetaByKey('public_token_salt') : '';
90 182
91 183 return wp_salt('auth') . $perBoardSalt;
92 184 }
@@ -92,9 +184,9 @@
92 184 }
93 185
94 186 public static function revokeAccessToken($boardId)
95 187 {
96 - $board = \FluentBoards\App\Models\Board::find($boardId);
188 + $board = Board::find($boardId);
97 189 if ($board) {
98 190 $board->updateMeta('public_token_salt', wp_generate_password(32, true, true));
99 191 }
100 192 }