diff --git a/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java b/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java
index 7ea73872328..55fb25d2b6f 100644
--- a/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java
+++ b/core/src/main/java/google/registry/batch/CannedScriptExecutionAction.java
@@ -16,24 +16,22 @@
import static google.registry.request.Action.Method.GET;
import static google.registry.request.Action.Method.POST;
+import static java.nio.charset.StandardCharsets.UTF_8;
-import com.google.api.services.gmail.Gmail;
-import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.common.flogger.FluentLogger;
-import com.google.common.net.MediaType;
-import dagger.Lazy;
-import google.registry.config.RegistryConfig.Config;
-import google.registry.groups.GmailClient;
import google.registry.request.Action;
import google.registry.request.Parameter;
import google.registry.request.Response;
+import google.registry.request.UrlConnectionService;
+import google.registry.request.UrlConnectionUtils;
import google.registry.request.auth.Auth;
-import google.registry.util.EmailMessage;
-import google.registry.util.Retrier;
import jakarta.inject.Inject;
-import jakarta.mail.internet.AddressException;
-import jakarta.mail.internet.InternetAddress;
-import java.util.Optional;
+import java.net.Inet6Address;
+import java.net.InetAddress;
+import java.net.URL;
+import java.net.UnknownHostException;
+import javax.net.ssl.HttpsURLConnection;
/**
* Action that executes a canned script specified by the caller.
@@ -42,9 +40,11 @@
* Sandbox and Production environments new features that depend on environment-specific
* configurations.
*
+ *
The URL is validated to prevent server-side request forgery (SSRF). Only HTTPS URLs pointing
+ * to public (non-private, non-loopback, non-link-local) IP addresses are allowed.
+ *
*
This action can be invoked using the Nomulus CLI command: {@code nomulus -e ${env} curl
- * --service BACKEND -X POST -d 'sender=sender@example.com' -d 'receiver=receiver@example.com' -u
- * '/_dr/task/executeCannedScript'}
+ * --service BACKEND -X POST -u '/_dr/task/executeCannedScript?url=https://example.com/path'}
*/
@Action(
service = Action.Service.BACKEND,
@@ -55,69 +55,108 @@
public class CannedScriptExecutionAction implements Runnable {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- @Inject Lazy gmail;
- @Inject Retrier retrier;
-
- @Inject
- @Config("isEmailSendingEnabled")
- boolean isEmailSendingEnabled;
+ private static final ImmutableSet BLOCKED_HOSTS =
+ ImmutableSet.of(
+ "localhost",
+ "metadata",
+ "metadata.google.internal",
+ "metadata.google.internal.",
+ "kubernetes",
+ "kubernetes.default",
+ "kubernetes.default.svc",
+ "kubernetes.default.svc.cluster.local",
+ "kubernetes.default.svc.cluster.local.");
+ @Inject UrlConnectionService urlConnectionService;
@Inject Response response;
@Inject
- @Parameter("replyTo")
- String replyTo;
-
- @Inject
- @Parameter("receiver")
- String receiver;
-
- @Config("invoiceReplyToEmailAddress")
- Optional replyToEmailAddressFromConfig;
+ @Parameter("url")
+ String url;
@Inject
CannedScriptExecutionAction() {}
@Override
public void run() {
- // For b/510340944, validating a new G Workspace user can send email. Code below can be
- // removed or changed afterward.
+ Integer responseCode = null;
+ String responseContent = null;
try {
- logger.atInfo().log("Sending email to %s with replyTo %s", receiver, replyTo);
- GmailClient gmailClient =
- new GmailClient(gmail, retrier, isEmailSendingEnabled, new InternetAddress(replyTo));
- gmailClient.sendEmail(
- EmailMessage.newBuilder()
- .addRecipient(new InternetAddress(receiver))
- .setSubject(String.format("Email with manually set replyTo header %s", replyTo))
- .setBody("See subject")
- .build());
-
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- }
-
- gmailClient.sendEmail(
- EmailMessage.newBuilder()
- .setSubject(
- String.format(
- "Email with injected replyTo header %s", replyToEmailAddressFromConfig))
- .setBody("See header")
- .setRecipients(ImmutableList.of(new InternetAddress(receiver)))
- .setReplyToEmailAddress(replyToEmailAddressFromConfig)
- .setContentType(MediaType.HTML_UTF_8)
- .build());
- response.setPayload("Emails sent successfully.");
- } catch (AddressException e) {
- logger.atWarning().withCause(e).log(
- "Invalid email address: sender=%s, receiver=%s", replyTo, receiver);
+ logger.atInfo().log("Connecting to: %s", url);
+ URL parsedUrl = new URL(url);
+ validateUrl(parsedUrl);
+ HttpsURLConnection connection =
+ (HttpsURLConnection) urlConnectionService.createConnection(parsedUrl);
+ responseCode = connection.getResponseCode();
+ logger.atInfo().log("Code: %d", responseCode);
+ logger.atInfo().log("Headers: %s", connection.getHeaderFields());
+ responseContent = new String(UrlConnectionUtils.getResponseBytes(connection), UTF_8);
+ logger.atInfo().log("Response: %s", responseContent);
+ } catch (SecurityException e) {
+ logger.atWarning().withCause(e).log("URL validation failed for: %s", url);
response.setStatus(400);
- response.setPayload("Invalid email address provided.");
+ response.setPayload("Invalid URL: " + e.getMessage());
} catch (Exception e) {
- logger.atSevere().withCause(e).log("Failed to send email");
+ logger.atWarning().withCause(e).log("Connection to %s failed", url);
throw new RuntimeException(e);
+ } finally {
+ if (responseCode != null) {
+ response.setStatus(responseCode);
+ }
+ if (responseContent != null) {
+ response.setPayload(responseContent);
+ }
+ }
+ }
+
+ private void validateUrl(URL url) {
+ if (!"https".equals(url.getProtocol())) {
+ throw new SecurityException("Only HTTPS URLs are allowed");
+ }
+
+ String host = url.getHost();
+ if (host == null || host.isEmpty()) {
+ throw new SecurityException("URL must have a host");
+ }
+
+ String lowerHost = host.toLowerCase();
+ if (BLOCKED_HOSTS.contains(lowerHost)) {
+ throw new SecurityException(
+ "Connections to internal hostnames are not allowed: " + host);
+ }
+
+ InetAddress address;
+ try {
+ address = InetAddress.getByName(host);
+ } catch (UnknownHostException e) {
+ throw new SecurityException("Could not resolve host: " + host, e);
+ }
+
+ if (address.isLoopbackAddress()) {
+ throw new SecurityException("Connections to loopback addresses are not allowed");
+ }
+ if (address.isSiteLocalAddress()) {
+ throw new SecurityException("Connections to private (site-local) addresses are not allowed");
+ }
+ if (address.isLinkLocalAddress()) {
+ throw new SecurityException("Connections to link-local addresses are not allowed");
+ }
+ if (address instanceof Inet6Address
+ && ((Inet6Address) address).isIPv4CompatibleAddress()) {
+ throw new SecurityException(
+ "Connections to IPv4-compatible IPv6 addresses are not allowed");
+ }
+ // Block the unspecified address (0.0.0.0 or ::)
+ byte[] addrBytes = address.getAddress();
+ boolean allZero = true;
+ for (byte b : addrBytes) {
+ if (b != 0) {
+ allZero = false;
+ break;
+ }
+ }
+ if (allZero) {
+ throw new SecurityException("Connections to the unspecified address are not allowed");
}
}
}