Skip to content

Commit 1b452cf

Browse files
Merge branch 'develop' into fb_fixImportPerfWaitCheck
2 parents 57b07ac + 5925401 commit 1b452cf

5 files changed

Lines changed: 174 additions & 31 deletions

File tree

src/org/labkey/test/components/core/ApiKeyDialog.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ protected class ElementCache extends ModalDialog.ElementCache
182182
OptionSelect<OptionSelect.SelectOption> restrictionRoleSelect = new OptionSelect<>(Locator.tagWithId("select", "keyRole")
183183
.findWhenNeeded(this).withTimeout(2000));
184184
WebElement generateApiKeyButton = Locator.tagWithText("button", "Generate API Key").findWhenNeeded(this);
185-
Input inputField = Input.Input(Locator.tagWithClass("input", "api-key__input"), getDriver()).findWhenNeeded(this);
185+
Input inputField = Input.Input(Locator.tagWithClass("input", "api-key__display"), getDriver()).findWhenNeeded(this);
186186
WebElement copyKeyButton = Locator.tagWithName("button", "copy_apikey_token").findWhenNeeded(this);
187187
WebElement doneButton = Locator.tagWithText("button", "Done").findWhenNeeded(this);
188188
}

src/org/labkey/test/pages/core/admin/CustomizeSitePage.java

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -188,18 +188,6 @@ public CustomizeSitePage setAdminOnlyMessage(String value)
188188
return this;
189189
}
190190

191-
public CustomizeSitePage setCSRFCheck(CSRFCheck value)
192-
{
193-
elementCache().CSRFCheck.selectByValue(value.name());
194-
return this;
195-
}
196-
197-
public CustomizeSitePage setXFrameOption(XFrameOption value)
198-
{
199-
elementCache().XFrameOption.selectByValue(value.name());
200-
return this;
201-
}
202-
203191
public CustomizeSitePage setEnableServerHttpHeader(boolean enable)
204192
{
205193
elementCache().enableServerHttpHeader.set(enable);
@@ -271,8 +259,6 @@ protected RadioButton exceptionReportingLevel(ReportingLevel level)
271259
protected final Input adminOnlyMessage = Input(Locator.id("adminOnlyMessage"), getDriver()).findWhenNeeded(this);
272260

273261
// HTTP Security Settings
274-
protected final Select CSRFCheck = Select(Locator.id("CSRFCheck")).findWhenNeeded(this);
275-
protected final Select XFrameOption = Select(Locator.id("XFrameOption")).findWhenNeeded(this);
276262
protected final Checkbox enableServerHttpHeader = Checkbox(Locator.id("includeServerHttpHeader")).findWhenNeeded(this);
277263
}
278264

@@ -281,17 +267,8 @@ public enum ReportingLevel
281267
NONE, LOW, MEDIUM, HIGH
282268
}
283269

284-
public enum CSRFCheck
285-
{
286-
POST, ADMINONLY
287-
}
288-
289-
public enum XFrameOption
290-
{
291-
SAMEORIGIN, ALLOW
292-
}
293-
294270
private static final int SECONDS_PER_DAY = 60*60*24;
271+
295272
public enum KeyExpirationOptions implements OptionSelect.SelectOption
296273
{
297274
UNLIMITED(-1),

src/org/labkey/test/tests/list/ListTest.java

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,16 @@
2424
import org.junit.Test;
2525
import org.junit.experimental.categories.Category;
2626
import org.labkey.remoteapi.CommandException;
27+
import org.labkey.remoteapi.Connection;
2728
import org.labkey.remoteapi.domain.Domain;
2829
import org.labkey.remoteapi.domain.DomainResponse;
2930
import org.labkey.remoteapi.domain.PropertyDescriptor;
3031
import org.labkey.remoteapi.domain.SaveDomainCommand;
3132
import org.labkey.remoteapi.query.ContainerFilter;
3233
import org.labkey.remoteapi.query.Filter;
34+
import org.labkey.remoteapi.query.SelectRowsCommand;
35+
import org.labkey.remoteapi.query.SelectRowsResponse;
36+
import org.labkey.remoteapi.query.Sort;
3337
import org.labkey.serverapi.reader.TabLoader;
3438
import org.labkey.test.BaseWebDriverTest;
3539
import org.labkey.test.Locator;
@@ -55,6 +59,7 @@
5559
import org.labkey.test.params.FieldDefinition.StringLookup;
5660
import org.labkey.test.params.FieldInfo;
5761
import org.labkey.test.params.FieldKey;
62+
import org.labkey.test.params.list.IntListDefinition;
5863
import org.labkey.test.params.list.VarListDefinition;
5964
import org.labkey.test.tests.AuditLogTest;
6065
import org.labkey.test.util.AbstractDataRegionExportOrSignHelper.ColumnHeaderType;
@@ -867,6 +872,95 @@ public void testCustomViews()
867872
new PortalHelper(this).removeAllWebParts();
868873
}
869874

875+
/**
876+
* CWE-639 (IDOR): a list audit event loaded by user-controlled rowId in
877+
* ListItemDetailsAction must be tied back to the URL-requested listId before its
878+
* old/new record maps are rendered. Without this, listId=X&rowId=N-belonging-to-Y
879+
* would render List Y's audit payload inside List X's details page.
880+
*
881+
* Builds two lists in the same container, generates a modify-audit-event on the
882+
* second one, then verifies the action refuses to render it when the URL names the
883+
* first list. A positive control confirms the matched-listId path still works, so
884+
* the test fails if the predicate is ever inverted/over-rejects.
885+
*/
886+
@Test
887+
public void testAuditDetailRejectsRowIdFromOtherList() throws Exception
888+
{
889+
final String LIST_X = "IDOR_VICTIM_LIST"; // attacker claims to be viewing details for this list
890+
final String LIST_Y = "IDOR_SOURCE_LIST"; // audit event actually belongs to this list
891+
final String NAME_FIELD = "Name";
892+
final String LIST_Y_ROW_VALUE = "y-original";
893+
final String LIST_Y_ROW_EDITED = "y-modified-secret";
894+
895+
log("Set up two lists in the same container, each with a Name field");
896+
Connection connection = createDefaultConnection();
897+
new IntListDefinition(LIST_X, "Key")
898+
.addField(new FieldDefinition(NAME_FIELD, ColumnType.String))
899+
.create(connection, getProjectName());
900+
new IntListDefinition(LIST_Y, "Key")
901+
.addField(new FieldDefinition(NAME_FIELD, ColumnType.String))
902+
.create(connection, getProjectName());
903+
904+
log("Insert and then modify a row in List Y so it generates an audit event with old/new record maps");
905+
_listHelper.goToList(LIST_Y);
906+
_listHelper.clickImportData()
907+
.setText(NAME_FIELD + "\n" + LIST_Y_ROW_VALUE)
908+
.submit();
909+
DataRegionTable yTable = new DataRegionTable("query", getDriver());
910+
yTable.clickEditRow(yTable.getRowIndex(LIST_Y_ROW_VALUE));
911+
setFormElement(Locator.name("quf_" + NAME_FIELD), LIST_Y_ROW_EDITED);
912+
clickButton("Submit");
913+
914+
log("Discover List X's listId and the audit rowId for List Y's modification event");
915+
Connection cn = createDefaultConnection();
916+
int listXId = lookupListId(cn, LIST_X);
917+
int listYId = lookupListId(cn, LIST_Y);
918+
int listYAuditRowId = lookupListAuditRowId(cn, LIST_Y);
919+
920+
log("Attack: URL says listId=" + LIST_X + " but rowId points at the audit event for " + LIST_Y);
921+
String attackUrl = WebTestHelper.buildURL("list", getProjectName(), "listItemDetails",
922+
Map.of("listId", String.valueOf(listXId), "rowId", String.valueOf(listYAuditRowId)));
923+
beginAt(attackUrl);
924+
assertEquals("Action should render normally (200), not throw", 200, getResponseCode());
925+
assertTextPresent("No details available for this event");
926+
assertTextNotPresent(LIST_Y_ROW_VALUE); // proof of non-disclosure: pre-edit value
927+
assertTextNotPresent(LIST_Y_ROW_EDITED); // proof of non-disclosure: post-edit value
928+
929+
log("Positive control: same rowId but with the matching listId must still render the audit changes");
930+
String matchedUrl = WebTestHelper.buildURL("list", getProjectName(), "listItemDetails",
931+
Map.of("listId", String.valueOf(listYId), "rowId", String.valueOf(listYAuditRowId)));
932+
beginAt(matchedUrl);
933+
assertEquals(200, getResponseCode());
934+
assertTextPresent(LIST_Y_ROW_VALUE);
935+
assertTextPresent(LIST_Y_ROW_EDITED);
936+
assertTextNotPresent("No details available for this event");
937+
}
938+
939+
private int lookupListId(Connection cn, String listName) throws Exception
940+
{
941+
SelectRowsCommand cmd = new SelectRowsCommand("exp", "Lists");
942+
cmd.setColumns(List.of("RowId", "Name"));
943+
cmd.addFilter(new Filter("Name", listName, Filter.Operator.EQUAL));
944+
SelectRowsResponse rs = cmd.execute(cn, getProjectName());
945+
if (rs.getRows().isEmpty())
946+
throw new AssertionError("No exp.Lists row for " + listName);
947+
return ((Number) rs.getRows().get(0).get("RowId")).intValue();
948+
}
949+
950+
private int lookupListAuditRowId(Connection cn, String listName) throws Exception
951+
{
952+
// Most-recent ListAuditEvent for this list; the row-modify above will be it.
953+
SelectRowsCommand cmd = new SelectRowsCommand("auditLog", "ListAuditEvent");
954+
cmd.setColumns(List.of("RowId", "ListName", "Comment"));
955+
cmd.addFilter(new Filter("ListName", listName, Filter.Operator.EQUAL));
956+
cmd.setSorts(List.of(new Sort("RowId", Sort.Direction.DESCENDING)));
957+
cmd.setMaxRows(1);
958+
SelectRowsResponse rs = cmd.execute(cn, getProjectName());
959+
if (rs.getRows().isEmpty())
960+
throw new AssertionError("No ListAuditEvent for " + listName);
961+
return ((Number) rs.getRows().get(0).get("RowId")).intValue();
962+
}
963+
870964
/* Issue 23487: add regression coverage for batch insert into list with multiple errors
871965
*/
872966
@Test

src/org/labkey/test/tests/remoteapi/BulkUpdateGroupApiTest.java

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import org.labkey.test.util.APIUserHelper;
3131
import org.labkey.test.util.ApiPermissionsHelper;
3232
import org.labkey.test.util.DataRegionTable;
33+
import org.labkey.test.util.PermissionsHelper;
3334
import org.labkey.test.util.PermissionsHelper.PrincipalType;
3435

3536
import java.util.ArrayList;
@@ -62,11 +63,15 @@ public class BulkUpdateGroupApiTest extends BaseWebDriverTest
6263
private static Integer group1Id;
6364
private static Integer group2Id;
6465
private static final String siteGroup = "createdSiteGroup";
66+
private static final String OTHER_PROJECT = "BulkUpdateGroupApiTest Other Project";
67+
private static final String OTHER_GROUP = "otherProjectGroup";
68+
private static Integer otherProjectGroupId;
6569

6670
@Override
6771
protected void doCleanup(boolean afterTest) throws TestTimeoutException
6872
{
6973
_containerHelper.deleteProject(getProjectName(), afterTest);
74+
_containerHelper.deleteProject(OTHER_PROJECT, afterTest);
7075
deleteTestUsers(EMAIL_SUFFIX);
7176
_permissionsHelper.deleteGroup(siteGroup);
7277
}
@@ -105,6 +110,9 @@ private void doSetup()
105110
user2Id = _userHelper.createUser(USER2).getUserId();
106111
group1Id = _permissionsHelper.createProjectGroup(GROUP1, getProjectName());
107112
group2Id = _permissionsHelper.createProjectGroup(GROUP2, getProjectName());
113+
114+
_containerHelper.createProject(OTHER_PROJECT, null);
115+
otherProjectGroupId = _permissionsHelper.createProjectGroup(OTHER_GROUP, OTHER_PROJECT);
108116
}
109117

110118
@Test
@@ -503,6 +511,60 @@ public void testBulkUpdateAuditing() throws Exception
503511
AuditLogTest.verifyAuditEvent(this, AuditLogTest.GROUP_AUDIT_EVENT, "Comment", GROUP1, 2);
504512
}
505513

514+
// A project group may only be modified through the container it belongs to; targeting another project's group must be rejected.
515+
@Test
516+
public void testGroupFromDifferentContainerError() throws Exception
517+
{
518+
// otherProjectGroupId belongs to OTHER_PROJECT, but the command targets this test's project
519+
BulkUpdateGroupCommand command = new BulkUpdateGroupCommand(otherProjectGroupId);
520+
command.addMemberUser(USER1);
521+
Connection connection = createDefaultConnection();
522+
523+
try
524+
{
525+
String message = command.execute(connection, getProjectName()).getText();
526+
fail("Expected CommandException modifying a group from another container\nResponse:\n" + message);
527+
}
528+
catch (CommandException e)
529+
{
530+
assertTrue("Expected cross-container error. Actual error: " + e.getMessage(), e.getMessage().contains("does not belong to this project"));
531+
}
532+
533+
_permissionsHelper.assertUserNotInGroup(USER1, OTHER_GROUP, OTHER_PROJECT, PrincipalType.USER);
534+
}
535+
536+
// Adding an existing user to a group requires only AdminPermission, but creating a brand-new user additionally requires AddUserPermission.
537+
@Test
538+
public void testCreateUserWithoutAddUserPermission() throws Exception
539+
{
540+
// A Folder Administrator has AdminPermission (so the action's @RequiresPermission passes) but lacks AddUserPermission
541+
String folderAdmin = genTestEmail("folderadminnoadduser");
542+
String newUser = genTestEmail("shouldnotbecreated");
543+
_userHelper.createUser(folderAdmin);
544+
_permissionsHelper.addMemberToRole(folderAdmin, PermissionsHelper.FOLDER_ADMIN_ROLE, PermissionsHelper.MemberType.user, getProjectName());
545+
546+
Connection connection = createDefaultConnection();
547+
connection.impersonate(folderAdmin, getProjectName());
548+
try
549+
{
550+
BulkUpdateGroupCommand command = new BulkUpdateGroupCommand(group1Id);
551+
command.addMemberUser(USER1); // pre-existing user: should be added successfully
552+
command.addMemberUser(newUser); // new user: folder admin lacks AddUserPermission
553+
BulkUpdateGroupResponse response = command.execute(connection, getProjectName());
554+
555+
List<String> errors = collectErrors(response);
556+
assertEquals("Expected exactly one member error:\n" + String.join("\n", errors), 1, errors.size());
557+
assertTrue("Expected AddUserPermission error. Actual error: " + errors.get(0), errors.get(0).contains("do not have permission to create new users"));
558+
}
559+
finally
560+
{
561+
connection.stopImpersonating();
562+
}
563+
564+
assertNull("New user was created despite the caller lacking AddUserPermission", _userHelper.getUserId(newUser));
565+
_permissionsHelper.assertUserInGroup(USER1, GROUP1, getProjectName(), PrincipalType.USER);
566+
}
567+
506568
protected List<String> collectErrors(BulkUpdateGroupResponse response)
507569
{
508570
Map<String, Object> errors = response.getErrors();

src/org/labkey/test/util/AuditLogHelper.java

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -284,12 +284,22 @@ public void checkAuditEventValuesForTransactionId(String containerPath, AuditEve
284284
public void checkAuditEventValuesForTransactionId(String containerPath, AuditEvent auditEventName, List<String> columnNames, Integer transactionId, List<Map<String, Object>> expectedValues) throws IOException, CommandException
285285
{
286286
List<Map<String, Object>> events = getAuditLogsForTransactionId(containerPath, auditEventName, columnNames, transactionId, ContainerFilter.CurrentAndSubfolders);
287-
assertEquals("Unexpected number of events for transactionId " + transactionId, expectedValues.size(), events.size());
288-
for (int i = 0; i < expectedValues.size(); i++)
289-
{
290-
for (String key : expectedValues.get(i).keySet())
291-
assertEquals("Event " + i + " value for " + key + " not as expected", expectedValues.get(i).get(key), events.get(i).get(key));
292-
}
287+
288+
List<Map<String, Object>> unmatched = expectedValues.stream()
289+
.filter(expectedRow -> {
290+
Set<String> keysOfInterest = expectedRow.keySet();
291+
return events.stream().noneMatch(actualRow -> {
292+
Map<String, Object> actualFiltered = actualRow.entrySet().stream()
293+
.filter(e -> keysOfInterest.contains(e.getKey()))
294+
.collect(HashMap::new,
295+
(m, e) -> m.put(e.getKey(), e.getValue()),
296+
HashMap::putAll);
297+
return actualFiltered.equals(expectedRow);
298+
});
299+
})
300+
.toList();
301+
302+
assertTrue("Expected rows with no match in actual: " + unmatched, unmatched.isEmpty());
293303
}
294304

295305
public Map<String, Object> getTransactionAuditLogDetails(Integer transactionAuditId)

0 commit comments

Comments
 (0)