diff --git a/GEMINI.md b/GEMINI.md
index b393c07733a..ab00dcdb1a2 100644
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -94,7 +94,7 @@ Do not wait for the user to tell you to improve the skills; it is your responsib
# Gemini Engineering Guide: Nomulus Codebase
-This document captures high-level architectural patterns, lessons learned from large-scale refactorings (like the Joda-Time to `java.time` migration), and specific instructions to avoid common pitfalls in this environment.
+This document captures high-level architectural patterns, lessons learned from large-scale refactorings, and specific instructions to avoid common pitfalls in this environment.
## 🏛 Architecture Overview
diff --git a/console-webapp/src/app/domains/domainList.component.ts b/console-webapp/src/app/domains/domainList.component.ts
index 0cc3e8ccf96..19bc5d7cbe2 100644
--- a/console-webapp/src/app/domains/domainList.component.ts
+++ b/console-webapp/src/app/domains/domainList.component.ts
@@ -312,11 +312,13 @@ export class DomainListComponent {
this.dialog.open(ResponseDialogComponent, {
data: {
title: 'Domain Deletion Results',
- content: `Successfully deleted - ${successCount} domain(s)
Failed to delete - ${failureCount} domain(s)
${
+ content: [
+ `Successfully deleted - ${successCount} domain(s)`,
+ `Failed to delete - ${failureCount} domain(s)`,
failureCount
- ? 'Some domains could not be deleted due to ongoing processes or server errors. '
- : ''
- }Please check the table for more information.`,
+ ? 'Some domains could not be deleted due to ongoing processes or server errors. Please check the table for more information.'
+ : 'Please check the table for more information.',
+ ],
},
});
this.selection.clear();
diff --git a/console-webapp/src/app/registrar/registrarDetails.component.html b/console-webapp/src/app/registrar/registrarDetails.component.html
index 1eda62f338f..38937c15e0c 100644
--- a/console-webapp/src/app/registrar/registrarDetails.component.html
+++ b/console-webapp/src/app/registrar/registrarDetails.component.html
@@ -97,10 +97,9 @@
Registrar details
@for (column of columns; track column.columnDef) {
{{ column.header }}
- ', ' ')"
- >
+ {{
+ column.cell(registrarInEdit)
+ }}
}
diff --git a/console-webapp/src/app/registrar/registrarsTable.component.html b/console-webapp/src/app/registrar/registrarsTable.component.html
index d2878b7f283..bd8cf598d64 100644
--- a/console-webapp/src/app/registrar/registrarsTable.component.html
+++ b/console-webapp/src/app/registrar/registrarsTable.component.html
@@ -49,10 +49,7 @@ Registrars
{{ column.header }}
-
+ {{ column.cell(row) }}
}
diff --git a/console-webapp/src/app/registrar/registrarsTable.component.ts b/console-webapp/src/app/registrar/registrarsTable.component.ts
index 702a61bcd2d..25149167f8a 100644
--- a/console-webapp/src/app/registrar/registrarsTable.component.ts
+++ b/console-webapp/src/app/registrar/registrarsTable.component.ts
@@ -54,12 +54,9 @@ export const columns = [
columnDef: 'billingAccountMap',
header: 'Billing Accounts',
cell: (record: Registrar) =>
- `${Object.entries(record.billingAccountMap || {}).reduce(
- (acc, [key, val]) => {
- return `${acc}${key}=${val}
`;
- },
- ''
- )}`,
+ `${Object.entries(record.billingAccountMap || {})
+ .map(([key, val]) => `${key}=${val}`)
+ .join(', ')}`,
},
{
columnDef: 'registryLockAllowed',
diff --git a/console-webapp/src/app/settings/contact/contact.component.html b/console-webapp/src/app/settings/contact/contact.component.html
index 362c8396c6b..4b3528ab608 100644
--- a/console-webapp/src/app/settings/contact/contact.component.html
+++ b/console-webapp/src/app/settings/contact/contact.component.html
@@ -25,7 +25,18 @@ No contacts found
@for (column of columns; track column) {
{{ column.header }}
-
+
+ @if (column.columnDef === 'name') {
+
+ } @else {
+ {{ column.cell(row) }}
+ }
+
}
diff --git a/console-webapp/src/app/settings/contact/contact.component.ts b/console-webapp/src/app/settings/contact/contact.component.ts
index cc23a7c3b89..c9dd0c2f3d9 100644
--- a/console-webapp/src/app/settings/contact/contact.component.ts
+++ b/console-webapp/src/app/settings/contact/contact.component.ts
@@ -34,14 +34,7 @@ export default class ContactComponent {
{
columnDef: 'name',
header: 'Name',
- cell: (contact: ViewReadyContact) => `
-
- `,
+ cell: (contact: ViewReadyContact) => `${contact.name}`,
},
{
columnDef: 'emailAddress',
diff --git a/console-webapp/src/app/users/usersList.component.html b/console-webapp/src/app/users/usersList.component.html
index eb88a02b3b7..8cc7b2a0c61 100644
--- a/console-webapp/src/app/users/usersList.component.html
+++ b/console-webapp/src/app/users/usersList.component.html
@@ -10,7 +10,7 @@
{{ column.header }}
-
+ {{ column.cell(row) }}
}
diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java
index a9c8943315f..a7384e361c7 100644
--- a/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java
+++ b/core/src/main/java/google/registry/ui/server/console/ConsoleEppPasswordAction.java
@@ -27,6 +27,7 @@
import com.google.gson.annotations.Expose;
import google.registry.flows.EppException.AuthenticationErrorException;
import google.registry.flows.PasswordOnlyTransportCredentials;
+import google.registry.model.console.ConsolePermission;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.User;
import google.registry.model.registrar.Registrar;
@@ -84,6 +85,8 @@ protected void postHandler(User user) {
eppRequestBody.newPassword().equals(eppRequestBody.newPasswordRepeat()),
"New password fields don't match");
+ checkPermission(user, eppRequestBody.registrarId(), ConsolePermission.CONFIGURE_EPP_CONNECTION);
+
Registrar registrar;
try {
registrar = registrarAccessor.getRegistrar(eppRequestBody.registrarId());
diff --git a/core/src/main/java/google/registry/ui/server/console/ConsoleHistoryDataAction.java b/core/src/main/java/google/registry/ui/server/console/ConsoleHistoryDataAction.java
index d967e1e3e02..255339b5f01 100644
--- a/core/src/main/java/google/registry/ui/server/console/ConsoleHistoryDataAction.java
+++ b/core/src/main/java/google/registry/ui/server/console/ConsoleHistoryDataAction.java
@@ -117,6 +117,7 @@ private void historyByRegistrarId(User user, String registrarId) {
.createNativeQuery(SQL_REGISTRAR_HISTORY, ConsoleUpdateHistory.class)
.setParameter("registrarId", registrarId)
.setHint("org.hibernate.fetchSize", 1000)
+ .setMaxResults(500)
.getResultList());
List formattedHistoryList =
diff --git a/core/src/main/java/google/registry/ui/server/console/PasswordResetRequestAction.java b/core/src/main/java/google/registry/ui/server/console/PasswordResetRequestAction.java
index 732e97f5a20..899b2aa69f8 100644
--- a/core/src/main/java/google/registry/ui/server/console/PasswordResetRequestAction.java
+++ b/core/src/main/java/google/registry/ui/server/console/PasswordResetRequestAction.java
@@ -86,7 +86,7 @@ private void performRequest(User user) {
"Must provide registry lock email to reset");
requiredPermission = ConsolePermission.MANAGE_USERS;
destinationEmail = passwordResetRequestData.registryLockEmail;
- checkUserExistsWithRegistryLockEmail(destinationEmail);
+ checkUserExistsWithRegistryLockEmail(destinationEmail, registrarId);
emailSubject = "Registry lock password reset request";
}
default -> throw new IllegalArgumentException("Unknown type " + type);
@@ -121,12 +121,24 @@ private void performRequest(User user) {
.sendEmail(EmailMessage.create(emailSubject, body, destinationAddress));
}
- static User checkUserExistsWithRegistryLockEmail(String destinationEmail) {
- return tm().createQueryComposer(User.class)
- .where("registryLockEmailAddress", QueryComposer.Comparator.EQ, destinationEmail)
- .first()
- .orElseThrow(
- () -> new IllegalArgumentException("Unknown user with lock email " + destinationEmail));
+ static User checkUserExistsWithRegistryLockEmail(String destinationEmail, String registrarId) {
+ User targetUser =
+ tm().createQueryComposer(User.class)
+ .where("registryLockEmailAddress", QueryComposer.Comparator.EQ, destinationEmail)
+ .first()
+ .orElseThrow(
+ () ->
+ new IllegalArgumentException(
+ "Unknown user with lock email " + destinationEmail));
+
+ // Prevent IDOR: Ensure the resolved user actually belongs to the registrar the requester
+ // has permissions for, or is a global admin.
+ if (!targetUser.getUserRoles().isAdmin()
+ && !targetUser.getUserRoles().getRegistrarRoles().containsKey(registrarId)) {
+ throw new IllegalArgumentException(
+ "User with lock email " + destinationEmail + " is not associated with " + registrarId);
+ }
+ return targetUser;
}
private String getAdminPocEmail(String registrarId) {
diff --git a/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java b/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java
index 796c4512fad..52c2cc889a2 100644
--- a/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java
+++ b/core/src/main/java/google/registry/ui/server/console/PasswordResetVerifyAction.java
@@ -98,7 +98,9 @@ private void handleEppPasswordReset(PasswordResetRequest request) {
}
private void handleRegistryLockPasswordReset(PasswordResetRequest request) {
- User affectedUser = checkUserExistsWithRegistryLockEmail(request.getDestinationEmail());
+ User affectedUser =
+ checkUserExistsWithRegistryLockEmail(
+ request.getDestinationEmail(), request.getRegistrarId());
tm().put(
affectedUser
.asBuilder()
diff --git a/core/src/main/java/google/registry/ui/server/console/domains/ConsoleBulkDomainAction.java b/core/src/main/java/google/registry/ui/server/console/domains/ConsoleBulkDomainAction.java
index b4ed47db12e..c4db9eb9d64 100644
--- a/core/src/main/java/google/registry/ui/server/console/domains/ConsoleBulkDomainAction.java
+++ b/core/src/main/java/google/registry/ui/server/console/domains/ConsoleBulkDomainAction.java
@@ -14,6 +14,7 @@
package google.registry.ui.server.console.domains;
+import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.collect.ImmutableMap.toImmutableMap;
import static google.registry.persistence.transaction.TransactionManagerFactory.tm;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
@@ -85,6 +86,12 @@ protected void postHandler(User user) {
optionalJsonPayload.orElseThrow(
() -> new IllegalArgumentException("Bulk action payload must be present"));
BulkDomainList domainList = consoleApiParams.gson().fromJson(jsonPayload, BulkDomainList.class);
+ checkArgument(
+ domainList.domainList != null && !domainList.domainList.isEmpty(),
+ "Domain list cannot be empty");
+ checkArgument(
+ domainList.domainList.size() <= 500,
+ "Cannot process more than 500 domains in a single bulk action");
ConsoleDomainActionType actionType =
ConsoleDomainActionType.parseActionType(bulkDomainAction, jsonPayload);
diff --git a/core/src/test/java/google/registry/ui/server/console/ConsoleHistoryDataActionTest.java b/core/src/test/java/google/registry/ui/server/console/ConsoleHistoryDataActionTest.java
index d7fee4abce5..c932f1b815b 100644
--- a/core/src/test/java/google/registry/ui/server/console/ConsoleHistoryDataActionTest.java
+++ b/core/src/test/java/google/registry/ui/server/console/ConsoleHistoryDataActionTest.java
@@ -23,6 +23,8 @@
import com.google.common.collect.ImmutableMap;
import com.google.gson.Gson;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonParser;
import google.registry.model.console.ConsoleUpdateHistory;
import google.registry.model.console.RegistrarRole;
import google.registry.model.console.User;
@@ -162,6 +164,27 @@ void testSuccess_noResults() {
assertThat(response.getPayload()).isEqualTo("[]");
}
+ @Test
+ void testSuccess_limitsResultsTo500() {
+ for (int i = 0; i < 505; i++) {
+ DatabaseHelper.persistResource(
+ new ConsoleUpdateHistory.Builder()
+ .setType(ConsoleUpdateHistory.Type.REGISTRAR_UPDATE)
+ .setDescription("TheRegistrar|some detail " + i)
+ .setActingUser(fteUser)
+ .setUrl("https://test.com")
+ .setMethod("GET")
+ .setModificationTime(clock.now())
+ .build());
+ }
+ ConsoleHistoryDataAction action =
+ createAction(AuthResult.createUser(fteUser), "TheRegistrar", Optional.empty());
+ action.run();
+ assertThat(response.getStatus()).isEqualTo(SC_OK);
+ JsonArray payload = JsonParser.parseString(response.getPayload()).getAsJsonArray();
+ assertThat(payload.size()).isEqualTo(500);
+ }
+
@Test
void testFailure_getByRegistrar_noPermission() {
ConsoleHistoryDataAction action =
diff --git a/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java b/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java
index 84afa9956da..fc3701c8949 100644
--- a/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java
+++ b/core/src/test/java/google/registry/ui/server/console/domains/ConsoleBulkDomainActionTest.java
@@ -45,6 +45,7 @@
import google.registry.testing.FakeResponse;
import google.registry.ui.server.console.ConsoleActionBaseTestCase;
import google.registry.ui.server.console.ConsoleApiParams;
+import java.util.Collections;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -173,7 +174,9 @@ void testHalfSuccess_halfNonexistent() throws Exception {
@Test
void testFailure_badActionString() {
- ConsoleBulkDomainAction action = createAction("bad", GSON.toJsonTree(ImmutableMap.of()));
+ ConsoleBulkDomainAction action =
+ createAction(
+ "bad", GSON.toJsonTree(ImmutableMap.of("domainList", ImmutableList.of("domain.tld"))));
action.run();
assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
assertThat(response.getPayload())
@@ -190,6 +193,29 @@ void testFailure_emptyBody() {
assertThat(response.getPayload()).isEqualTo("Bulk action payload must be present");
}
+ @Test
+ void testFailure_listTooLarge() {
+ JsonElement payload =
+ GSON.toJsonTree(
+ ImmutableMap.of(
+ "domainList", Collections.nCopies(501, "domain.tld"), "reason", "reason"));
+ ConsoleBulkDomainAction action = createAction("DELETE", payload);
+ action.run();
+ assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
+ assertThat(response.getPayload())
+ .isEqualTo("Cannot process more than 500 domains in a single bulk action");
+ }
+
+ @Test
+ void testFailure_emptyList() {
+ JsonElement payload =
+ GSON.toJsonTree(ImmutableMap.of("domainList", ImmutableList.of(), "reason", "reason"));
+ ConsoleBulkDomainAction action = createAction("DELETE", payload);
+ action.run();
+ assertThat(response.getStatus()).isEqualTo(SC_BAD_REQUEST);
+ assertThat(response.getPayload()).isEqualTo("Domain list cannot be empty");
+ }
+
@Test
void testFailure_noPermission() {
JsonElement payload =