|
24 | 24 | import org.junit.Test; |
25 | 25 | import org.junit.experimental.categories.Category; |
26 | 26 | import org.labkey.remoteapi.CommandException; |
| 27 | +import org.labkey.remoteapi.Connection; |
27 | 28 | import org.labkey.remoteapi.domain.Domain; |
28 | 29 | import org.labkey.remoteapi.domain.DomainResponse; |
29 | 30 | import org.labkey.remoteapi.domain.PropertyDescriptor; |
30 | 31 | import org.labkey.remoteapi.domain.SaveDomainCommand; |
31 | 32 | import org.labkey.remoteapi.query.ContainerFilter; |
32 | 33 | 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; |
33 | 37 | import org.labkey.serverapi.reader.TabLoader; |
34 | 38 | import org.labkey.test.BaseWebDriverTest; |
35 | 39 | import org.labkey.test.Locator; |
|
55 | 59 | import org.labkey.test.params.FieldDefinition.StringLookup; |
56 | 60 | import org.labkey.test.params.FieldInfo; |
57 | 61 | import org.labkey.test.params.FieldKey; |
| 62 | +import org.labkey.test.params.list.IntListDefinition; |
58 | 63 | import org.labkey.test.params.list.VarListDefinition; |
59 | 64 | import org.labkey.test.tests.AuditLogTest; |
60 | 65 | import org.labkey.test.util.AbstractDataRegionExportOrSignHelper.ColumnHeaderType; |
@@ -867,6 +872,95 @@ public void testCustomViews() |
867 | 872 | new PortalHelper(this).removeAllWebParts(); |
868 | 873 | } |
869 | 874 |
|
| 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 | + |
870 | 964 | /* Issue 23487: add regression coverage for batch insert into list with multiple errors |
871 | 965 | */ |
872 | 966 | @Test |
|
0 commit comments