|
| 1 | +/* |
| 2 | + * Copyright (c) 2026 LabKey Corporation |
| 3 | + * |
| 4 | + * Licensed under the Apache License, Version 2.0 (the "License"); |
| 5 | + * you may not use this file except in compliance with the License. |
| 6 | + * You may obtain a copy of the License at |
| 7 | + * |
| 8 | + * http://www.apache.org/licenses/LICENSE-2.0 |
| 9 | + * |
| 10 | + * Unless required by applicable law or agreed to in writing, software |
| 11 | + * distributed under the License is distributed on an "AS IS" BASIS, |
| 12 | + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 13 | + * See the License for the specific language governing permissions and |
| 14 | + * limitations under the License. |
| 15 | + */ |
| 16 | +package org.labkey.test.tests.signup; |
| 17 | + |
| 18 | +import org.apache.hc.client5.http.classic.methods.HttpPost; |
| 19 | +import org.apache.hc.client5.http.entity.UrlEncodedFormEntity; |
| 20 | +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; |
| 21 | +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; |
| 22 | +import org.apache.hc.core5.http.HttpStatus; |
| 23 | +import org.apache.hc.core5.http.NameValuePair; |
| 24 | +import org.apache.hc.core5.http.io.entity.EntityUtils; |
| 25 | +import org.apache.hc.core5.http.message.BasicNameValuePair; |
| 26 | +import org.apache.hc.core5.http.protocol.HttpContext; |
| 27 | +import org.junit.After; |
| 28 | +import org.junit.BeforeClass; |
| 29 | +import org.junit.Test; |
| 30 | +import org.junit.experimental.categories.Category; |
| 31 | +import org.labkey.remoteapi.CommandResponse; |
| 32 | +import org.labkey.remoteapi.Connection; |
| 33 | +import org.labkey.remoteapi.SimplePostCommand; |
| 34 | +import org.labkey.test.BaseWebDriverTest; |
| 35 | +import org.labkey.test.WebTestHelper; |
| 36 | +import org.labkey.test.categories.External; |
| 37 | +import org.labkey.test.categories.MacCossLabModules; |
| 38 | +import org.labkey.test.util.APITestHelper; |
| 39 | +import org.labkey.test.util.ApiPermissionsHelper; |
| 40 | +import org.labkey.test.util.LogMethod; |
| 41 | +import org.labkey.test.util.PermissionsHelper.MemberType; |
| 42 | +import org.labkey.test.util.PermissionsHelper.PrincipalType; |
| 43 | + |
| 44 | +import java.io.IOException; |
| 45 | +import java.util.ArrayList; |
| 46 | +import java.util.List; |
| 47 | +import java.util.Map; |
| 48 | + |
| 49 | +import static org.junit.Assert.assertEquals; |
| 50 | +import static org.junit.Assert.assertFalse; |
| 51 | +import static org.junit.Assert.assertTrue; |
| 52 | +import static org.labkey.test.util.PermissionsHelper.FOLDER_ADMIN_ROLE; |
| 53 | + |
| 54 | +/** |
| 55 | + * The self-service group change (ChangeGroupsApiAction) must not let a logged-in user escalate their own privileges. |
| 56 | + * |
| 57 | + * The transition rule map (the site-global "group A may move to group B" list) is admin-configured |
| 58 | + * and could be pointed at a privileged target by mistake. ChangeGroupsApiAction therefore re-validates |
| 59 | + * the resolved groups with validateGroupChangeTarget before it changes any membership, and rejects the |
| 60 | + * move with status TARGET_NOT_ALLOWED when the target: |
| 61 | + * - is a site group rather than a project group, |
| 62 | + * - is in a different project than the source group, or |
| 63 | + * - carries administrative permission anywhere in its project or its subfolders. |
| 64 | + * |
| 65 | + * This test plants each of those dangerous rules on purpose, then confirms that a non-admin member of |
| 66 | + * the source group is refused every escalating move (and is neither added to the target nor removed from |
| 67 | + * the source), while a legitimate move to a plain non-admin project group in the same project still works. |
| 68 | + * |
| 69 | + * Each escalating case needs its transition rule planted so the attempt gets past the "rule exists" gate and |
| 70 | + * actually reaches validateGroupChangeTarget. A target with no rule is refused before that check, with |
| 71 | + * NO_PERMISSIONS. The test drives a no-rule move on purpose and asserts NO_PERMISSIONS, so that path stays |
| 72 | + * distinguishable from the TARGET_NOT_ALLOWED path. |
| 73 | + * |
| 74 | + * Design notes: |
| 75 | + * - The live client (a skyline.ms wiki page) is not in the module, so this drives the server actions |
| 76 | + * directly: the admin AddGroupChangeProperty action to plant rules, and the ChangeGroupsApi action as |
| 77 | + * the user to attempt the moves. |
| 78 | + * - The transition rule map is site-global (no container), so the planted rules are removed in @After |
| 79 | + * (not doCleanup) so they are cleaned up even on a local clean=false run. |
| 80 | + */ |
| 81 | +@Category({External.class, MacCossLabModules.class}) |
| 82 | +@BaseWebDriverTest.ClassTimeout(minutes = 2) |
| 83 | +public class SignUpGroupChangeSecurityTest extends BaseWebDriverTest |
| 84 | +{ |
| 85 | + private static final String PROJECT_1 = "SignUpSecurityTest P1"; |
| 86 | + private static final String PROJECT_2 = "SignUpSecurityTest P2"; |
| 87 | + private static final String SUBFOLDER = "Sub"; |
| 88 | + |
| 89 | + private static final String GROUP_SOURCE = "SignupSource"; |
| 90 | + private static final String GROUP_TARGET_OK = "SignupTargetOk"; |
| 91 | + private static final String GROUP_TARGET_ADMIN = "SignupTargetAdmin"; |
| 92 | + private static final String GROUP_TARGET_SUBADMIN = "SignupTargetSubAdmin"; |
| 93 | + private static final String GROUP_TARGET_CROSS = "SignupTargetCross"; |
| 94 | + private static final String SITE_GROUP = "SignupSiteGroup"; |
| 95 | + |
| 96 | + // Group ids captured during setup, used both for the rule map and for the ChangeGroupsApi params. |
| 97 | + private static int idSource; |
| 98 | + private static int idTargetOk; |
| 99 | + private static int idTargetAdmin; |
| 100 | + private static int idTargetSubAdmin; |
| 101 | + private static int idTargetCross; |
| 102 | + private static int idSiteGroup; |
| 103 | + |
| 104 | + private static final String USER = "[email protected]"; |
| 105 | + |
| 106 | + // Password for the user account, so we can POST to ChangeGroupsApi as that user. |
| 107 | + private static String userPassword; |
| 108 | + |
| 109 | + // Transition rules this test planted, so @After can remove them from the site-global rule map. |
| 110 | + private final List<int[]> _plantedRules = new ArrayList<>(); |
| 111 | + |
| 112 | + @Override |
| 113 | + protected String getProjectName() |
| 114 | + { |
| 115 | + return PROJECT_1; |
| 116 | + } |
| 117 | + |
| 118 | + @BeforeClass |
| 119 | + public static void setupProject() |
| 120 | + { |
| 121 | + SignUpGroupChangeSecurityTest init = getCurrentTest(); |
| 122 | + init.doSetup(); |
| 123 | + } |
| 124 | + |
| 125 | + @LogMethod |
| 126 | + private void doSetup() |
| 127 | + { |
| 128 | + _containerHelper.createProject(PROJECT_1, null); |
| 129 | + _containerHelper.createSubfolder(PROJECT_1, SUBFOLDER); |
| 130 | + _containerHelper.createProject(PROJECT_2, null); |
| 131 | + |
| 132 | + ApiPermissionsHelper perms = new ApiPermissionsHelper(this); |
| 133 | + idSource = perms.createProjectGroup(GROUP_SOURCE, PROJECT_1); |
| 134 | + idTargetOk = perms.createProjectGroup(GROUP_TARGET_OK, PROJECT_1); |
| 135 | + idTargetAdmin = perms.createProjectGroup(GROUP_TARGET_ADMIN, PROJECT_1); |
| 136 | + idTargetSubAdmin = perms.createProjectGroup(GROUP_TARGET_SUBADMIN, PROJECT_1); |
| 137 | + idTargetCross = perms.createProjectGroup(GROUP_TARGET_CROSS, PROJECT_2); |
| 138 | + idSiteGroup = perms.createGlobalPermissionsGroup(SITE_GROUP); |
| 139 | + |
| 140 | + // TargetAdmin carries admin in the P1 project; TargetSubAdmin carries admin only in the P1/Sub |
| 141 | + // subfolder. Assigning a role in the subfolder gives it its own permission policy (breaks |
| 142 | + // inheritance), which is what the subfolder branch of validateGroupChangeTarget looks for. |
| 143 | + perms.addMemberToRole(idTargetAdmin, FOLDER_ADMIN_ROLE, "/" + PROJECT_1); |
| 144 | + perms.addMemberToRole(idTargetSubAdmin, FOLDER_ADMIN_ROLE, "/" + PROJECT_1 + "/" + SUBFOLDER); |
| 145 | + |
| 146 | + // A non-admin user who is a member of the source group. All rejection cases depend on the user |
| 147 | + // being in Source, because the action checks source-group membership before it validates the target. |
| 148 | + _userHelper.createUser(USER); |
| 149 | + userPassword = setInitialPassword(USER); |
| 150 | + perms.addUserToProjGroup(USER, PROJECT_1, GROUP_SOURCE); |
| 151 | + } |
| 152 | + |
| 153 | + @Test |
| 154 | + public void testSelfServiceGroupChangeRejectsPrivilegeEscalation() throws Exception |
| 155 | + { |
| 156 | + // Plant the four dangerous rules so each escalating attempt gets past the "rule exists" check and |
| 157 | + // actually reaches validateGroupChangeTarget. TargetOk's rule is planted later, just before the |
| 158 | + // happy path, so it can first stand in for the "no rule configured" case below. |
| 159 | + addTransitionRule(idSource, idTargetAdmin); |
| 160 | + addTransitionRule(idSource, idTargetSubAdmin); |
| 161 | + addTransitionRule(idSource, idTargetCross); |
| 162 | + addTransitionRule(idSource, idSiteGroup); |
| 163 | + |
| 164 | + Connection userConnection = new Connection(WebTestHelper.getBaseURL(), USER, userPassword); |
| 165 | + |
| 166 | + // Contrast case: a valid same-project non-admin target with no rule configured is rejected before |
| 167 | + // validateGroupChangeTarget, with NO_PERMISSIONS. Asserting this keeps the two rejection reasons distinguishable. |
| 168 | + assertMoveNotEligible(userConnection, idTargetOk, GROUP_TARGET_OK, |
| 169 | + "no transition rule is configured for the target"); |
| 170 | + |
| 171 | + // Group transition validation rejections. Run these while the user is still in Source; each must |
| 172 | + // leave membership untouched and report TARGET_NOT_ALLOWED. |
| 173 | + assertMoveRejected(userConnection, idTargetAdmin, GROUP_TARGET_ADMIN, PROJECT_1, |
| 174 | + "target group carries admin permission in its project"); |
| 175 | + assertMoveRejected(userConnection, idTargetSubAdmin, GROUP_TARGET_SUBADMIN, PROJECT_1, |
| 176 | + "target group carries admin permission in a subfolder"); |
| 177 | + assertMoveRejected(userConnection, idTargetCross, GROUP_TARGET_CROSS, PROJECT_2, |
| 178 | + "target group is in a different project"); |
| 179 | + assertMoveRejected(userConnection, idSiteGroup, SITE_GROUP, "/", |
| 180 | + "target is a site group, not a project group"); |
| 181 | + |
| 182 | + // Legitimate move, run last because it actually moves the user out of the source group. |
| 183 | + addTransitionRule(idSource, idTargetOk); |
| 184 | + assertMoveSucceeded(userConnection, idTargetOk, GROUP_TARGET_OK); |
| 185 | + } |
| 186 | + |
| 187 | + private void assertMoveRejected(Connection userConnection, int targetId, String targetGroup, |
| 188 | + String targetContainer, String because) throws Exception |
| 189 | + { |
| 190 | + String status = postGroupChange(userConnection, idSource, targetId); |
| 191 | + assertEquals("Escalating move should be refused with TARGET_NOT_ALLOWED because " + because, |
| 192 | + "TARGET_NOT_ALLOWED", status); |
| 193 | + |
| 194 | + ApiPermissionsHelper perms = new ApiPermissionsHelper(this); |
| 195 | + assertFalse("User must not have been added to " + targetGroup + " (" + because + ")", |
| 196 | + perms.isUserInGroup(USER, targetGroup, targetContainer, PrincipalType.USER)); |
| 197 | + assertTrue("User must remain in the source group after a refused move (" + because + ")", |
| 198 | + perms.isUserInGroup(USER, GROUP_SOURCE, PROJECT_1, PrincipalType.USER)); |
| 199 | + } |
| 200 | + |
| 201 | + // A move rejected before the group transition validation runs (e.g. no rule configured, or caller not in |
| 202 | + // the source group) reports NO_PERMISSIONS, distinct from the validation's TARGET_NOT_ALLOWED. |
| 203 | + private void assertMoveNotEligible(Connection userConnection, int targetId, String targetGroup, |
| 204 | + String because) throws Exception |
| 205 | + { |
| 206 | + String status = postGroupChange(userConnection, idSource, targetId); |
| 207 | + assertEquals("Move should be refused with NO_PERMISSIONS because " + because, "NO_PERMISSIONS", status); |
| 208 | + |
| 209 | + ApiPermissionsHelper perms = new ApiPermissionsHelper(this); |
| 210 | + assertFalse("User must not have been added to " + targetGroup + " (" + because + ")", |
| 211 | + perms.isUserInGroup(USER, targetGroup, PROJECT_1, PrincipalType.USER)); |
| 212 | + assertTrue("User must remain in the source group after a refused move (" + because + ")", |
| 213 | + perms.isUserInGroup(USER, GROUP_SOURCE, PROJECT_1, PrincipalType.USER)); |
| 214 | + } |
| 215 | + |
| 216 | + private void assertMoveSucceeded(Connection userConnection, int targetId, String targetGroup) throws Exception |
| 217 | + { |
| 218 | + String status = postGroupChange(userConnection, idSource, targetId); |
| 219 | + assertEquals("A legitimate move to a non-admin project group should succeed", "USER_MOVED_SUCCESS", status); |
| 220 | + |
| 221 | + ApiPermissionsHelper perms = new ApiPermissionsHelper(this); |
| 222 | + assertTrue("User should now be a member of " + targetGroup, |
| 223 | + perms.isUserInGroup(USER, targetGroup, PROJECT_1, PrincipalType.USER)); |
| 224 | + assertFalse("User should have been removed from the source group", |
| 225 | + perms.isUserInGroup(USER, GROUP_SOURCE, PROJECT_1, PrincipalType.USER)); |
| 226 | + } |
| 227 | + |
| 228 | + // Posts to ChangeGroupsApi as the user and returns the response status string. |
| 229 | + private String postGroupChange(Connection userConnection, int oldGroup, int newGroup) throws Exception |
| 230 | + { |
| 231 | + SimplePostCommand command = new SimplePostCommand("signup", "changeGroupsApi"); |
| 232 | + command.setParameters(Map.of("oldgroup", oldGroup, "newgroup", newGroup)); |
| 233 | + CommandResponse response = command.execute(userConnection, "/"); |
| 234 | + return (String) response.getProperty("status"); |
| 235 | + } |
| 236 | + |
| 237 | + private void addTransitionRule(int oldGroup, int newGroup) throws IOException |
| 238 | + { |
| 239 | + postAdminGroupChangeForm("addGroupChangeProperty", oldGroup, newGroup); |
| 240 | + _plantedRules.add(new int[]{oldGroup, newGroup}); |
| 241 | + } |
| 242 | + |
| 243 | + // Drives the site-admin AddGroupChangeProperty / RemoveGroupChangeProperty form actions. These are |
| 244 | + // FormHandlerActions (they redirect rather than return JSON), so this posts form-encoded fields as the |
| 245 | + // logged-in admin: injectCookies carries the admin session and CSRF token, and getBasicHttpContext adds |
| 246 | + // preemptive basic auth. |
| 247 | + private void postAdminGroupChangeForm(String action, int oldGroup, int newGroup) throws IOException |
| 248 | + { |
| 249 | + HttpPost post = new HttpPost(WebTestHelper.buildURL("signup", "/", action)); |
| 250 | + List<NameValuePair> form = List.of( |
| 251 | + new BasicNameValuePair("oldgroup", String.valueOf(oldGroup)), |
| 252 | + new BasicNameValuePair("newgroup", String.valueOf(newGroup))); |
| 253 | + post.setEntity(new UrlEncodedFormEntity(form)); |
| 254 | + APITestHelper.injectCookies(post); |
| 255 | + |
| 256 | + HttpContext context = WebTestHelper.getBasicHttpContext(); |
| 257 | + try (CloseableHttpClient client = WebTestHelper.getHttpClient(); |
| 258 | + CloseableHttpResponse response = client.execute(post, context)) |
| 259 | + { |
| 260 | + int code = response.getCode(); |
| 261 | + assertTrue(action + " returned unexpected HTTP status " + code, |
| 262 | + code == HttpStatus.SC_OK || code == HttpStatus.SC_MOVED_TEMPORARILY); |
| 263 | + EntityUtils.consumeQuietly(response.getEntity()); |
| 264 | + } |
| 265 | + } |
| 266 | + |
| 267 | + @After |
| 268 | + public void removePlantedRules() |
| 269 | + { |
| 270 | + // The transition rule map is site-global, so remove the rules this test planted even when project |
| 271 | + // cleanup is skipped (clean=false). Runs after the test method. |
| 272 | + if (_plantedRules.isEmpty()) |
| 273 | + return; |
| 274 | + |
| 275 | + ensureSignedInAsPrimaryTestUser(); |
| 276 | + for (int[] rule : _plantedRules) |
| 277 | + { |
| 278 | + try |
| 279 | + { |
| 280 | + postAdminGroupChangeForm("removeGroupChangeProperty", rule[0], rule[1]); |
| 281 | + } |
| 282 | + catch (IOException e) |
| 283 | + { |
| 284 | + log("Failed to remove planted transition rule " + rule[0] + " -> " + rule[1] + ": " + e.getMessage()); |
| 285 | + } |
| 286 | + } |
| 287 | + _plantedRules.clear(); |
| 288 | + } |
| 289 | + |
| 290 | + @Override |
| 291 | + protected void doCleanup(boolean afterTest) |
| 292 | + { |
| 293 | + // The site group is not scoped to a project, so it survives project deletion and must be removed |
| 294 | + // explicitly. |
| 295 | + new ApiPermissionsHelper(this).deleteGroup(SITE_GROUP, false); |
| 296 | + _userHelper.deleteUsers(false, USER); |
| 297 | + _containerHelper.deleteProject(PROJECT_1, afterTest); |
| 298 | + _containerHelper.deleteProject(PROJECT_2, afterTest); |
| 299 | + } |
| 300 | + |
| 301 | + @Override |
| 302 | + public List<String> getAssociatedModules() |
| 303 | + { |
| 304 | + return List.of("signup"); |
| 305 | + } |
| 306 | + |
| 307 | + @Override |
| 308 | + protected BrowserType bestBrowser() |
| 309 | + { |
| 310 | + return BrowserType.CHROME; |
| 311 | + } |
| 312 | +} |
0 commit comments