diff --git a/core/src/main/java/google/registry/config/RegistryConfig.java b/core/src/main/java/google/registry/config/RegistryConfig.java index cee88303343..98014d02d40 100644 --- a/core/src/main/java/google/registry/config/RegistryConfig.java +++ b/core/src/main/java/google/registry/config/RegistryConfig.java @@ -1178,6 +1178,13 @@ public static String provideToolsClientSecret(RegistryConfigSettings config) { return config.registryTool.clientSecret; } + /** Secret key used to sign EPP session cookies (SESSION_INFO). */ + @Provides + @Config("sessionSecret") + public static String provideSessionSecret(RegistryConfigSettings config) { + return config.misc.sessionSecret; + } + @Provides @Config("rdapTos") public static ImmutableList provideRdapTos(RegistryConfigSettings config) { diff --git a/core/src/main/java/google/registry/config/RegistryConfigSettings.java b/core/src/main/java/google/registry/config/RegistryConfigSettings.java index 5b8ddedadcd..273919ed90f 100644 --- a/core/src/main/java/google/registry/config/RegistryConfigSettings.java +++ b/core/src/main/java/google/registry/config/RegistryConfigSettings.java @@ -203,6 +203,7 @@ public static class Misc { public String spec11OutgoingEmailAddress; public List spec11BccEmailAddresses; public int transientFailureRetries; + public String sessionSecret; } /** Configuration options for the registry tool. */ diff --git a/core/src/main/java/google/registry/config/files/default-config.yaml b/core/src/main/java/google/registry/config/files/default-config.yaml index 9b8719d7511..b0d0984b04b 100644 --- a/core/src/main/java/google/registry/config/files/default-config.yaml +++ b/core/src/main/java/google/registry/config/files/default-config.yaml @@ -428,6 +428,9 @@ misc: # The number of milliseconds it'll sleep before giving up is (2^n - 2) * 100. transientFailureRetries: 12 + # Secret key used to sign EPP session cookies (SESSION_INFO). + sessionSecret: "default-unsigned-session-secret-key-placeholder" + beam: # The default region to run Apache Beam (Cloud Dataflow) jobs in. defaultJobRegion: us-central1 diff --git a/core/src/main/java/google/registry/flows/CookieSessionMetadata.java b/core/src/main/java/google/registry/flows/CookieSessionMetadata.java index 1ed774ec6f4..40bc9b3c3ae 100644 --- a/core/src/main/java/google/registry/flows/CookieSessionMetadata.java +++ b/core/src/main/java/google/registry/flows/CookieSessionMetadata.java @@ -15,6 +15,7 @@ package google.registry.flows; import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.nio.charset.StandardCharsets.UTF_8; import com.google.common.base.Joiner; import com.google.common.base.Splitter; @@ -23,12 +24,15 @@ import com.google.common.io.BaseEncoding; import google.registry.request.Response; import jakarta.servlet.http.HttpServletRequest; +import java.security.MessageDigest; import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; /** * A metadata class that saves the data directly in cookies. @@ -58,34 +62,43 @@ public class CookieSessionMetadata extends SessionMetadata { private static final FluentLogger logger = FluentLogger.forEnclosingClass(); private final Map data = new HashMap<>(); + private final String sessionSecret; - public CookieSessionMetadata(HttpServletRequest request) { + public CookieSessionMetadata(HttpServletRequest request, String sessionSecret) { + this.sessionSecret = sessionSecret; Optional.ofNullable(request.getHeader("Cookie")) .ifPresent( cookie -> { Matcher matcher = COOKIE_PATTERN.matcher(cookie); if (matcher.find()) { - String sessionInfo = decode(matcher.group(1)); - logger.atInfo().log("SESSION INFO: %s", sessionInfo); - matcher = REGISTRAR_ID_PATTERN.matcher(sessionInfo); - if (matcher.find()) { - String registrarId = matcher.group(1); - if (!registrarId.equals("null")) { - data.put(REGISTRAR_ID, registrarId); + String cookieValue = matcher.group(1); + if (cookieValue != null) { + Optional sessionInfoOpt = + verifyAndDecodeCookie(cookieValue, sessionSecret); + if (sessionInfoOpt.isPresent()) { + String sessionInfo = sessionInfoOpt.get(); + logger.atInfo().log("SESSION INFO: %s", sessionInfo); + Matcher matcher2 = REGISTRAR_ID_PATTERN.matcher(sessionInfo); + if (matcher2.find()) { + String registrarId = matcher2.group(1); + if (!registrarId.equals("null")) { + data.put(REGISTRAR_ID, registrarId); + } + } + matcher2 = SERVICE_EXTENSIONS_PATTERN.matcher(sessionInfo); + if (matcher2.find()) { + String serviceExtensions = matcher2.group(1); + if (serviceExtensions != null) { + data.put(SERVICE_EXTENSIONS, serviceExtensions); + } + } + matcher2 = FAILED_LOGIN_ATTEMPTS_PATTERN.matcher(sessionInfo); + if (matcher2.find()) { + String failedLoginAttempts = matcher2.group(1); + data.put(FAILED_LOGIN_ATTEMPTS, failedLoginAttempts); + } } } - matcher = SERVICE_EXTENSIONS_PATTERN.matcher(sessionInfo); - if (matcher.find()) { - String serviceExtensions = matcher.group(1); - if (serviceExtensions != null) { - data.put(SERVICE_EXTENSIONS, serviceExtensions); - } - } - matcher = FAILED_LOGIN_ATTEMPTS_PATTERN.matcher(sessionInfo); - if (matcher.find()) { - String failedLoginAttempts = matcher.group(1); - data.put(FAILED_LOGIN_ATTEMPTS, failedLoginAttempts); - } } }); } @@ -141,8 +154,49 @@ public void resetFailedLoginAttempts() { @Override public void save(Response response) { - String value = encode(toString()); - response.setHeader("Set-Cookie", COOKIE_NAME + "=" + value); + String payload = toString(); + try { + byte[] payloadBytes = payload.getBytes(US_ASCII); + byte[] signatureBytes = calculateHmac(payloadBytes, sessionSecret); + String encodedPayload = BaseEncoding.base64Url().encode(payloadBytes); + String encodedSignature = BaseEncoding.base64Url().encode(signatureBytes); + String cookieValue = encodedPayload + "." + encodedSignature; + response.setHeader("Set-Cookie", COOKIE_NAME + "=" + cookieValue); + } catch (Exception e) { + logger.atSevere().withCause(e).log("Failed to sign and save session cookie."); + } + } + + private static Optional verifyAndDecodeCookie(String cookieValue, String secret) { + int dotIndex = cookieValue.indexOf('.'); + if (dotIndex == -1) { + logger.atWarning().log("Cookie does not contain a signature."); + return Optional.empty(); + } + String encodedPayload = cookieValue.substring(0, dotIndex); + String encodedSignature = cookieValue.substring(dotIndex + 1); + try { + byte[] payloadBytes = BaseEncoding.base64Url().decode(encodedPayload); + byte[] signatureBytes = BaseEncoding.base64Url().decode(encodedSignature); + byte[] expectedSignatureBytes = calculateHmac(payloadBytes, secret); + if (MessageDigest.isEqual(signatureBytes, expectedSignatureBytes)) { + return Optional.of(new String(payloadBytes, US_ASCII)); + } else { + logger.atWarning().log("Cookie signature verification failed."); + } + } catch (IllegalArgumentException e) { + logger.atWarning().withCause(e).log("Failed to decode cookie payload/signature."); + } catch (Exception e) { + logger.atWarning().withCause(e).log("Error verifying cookie signature."); + } + return Optional.empty(); + } + + private static byte[] calculateHmac(byte[] data, String secret) throws Exception { + SecretKeySpec signingKey = new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(signingKey); + return mac.doFinal(data); } protected static String encode(String plainText) { diff --git a/core/src/main/java/google/registry/flows/EppTlsAction.java b/core/src/main/java/google/registry/flows/EppTlsAction.java index f15495c34c7..d4d8f31e29d 100644 --- a/core/src/main/java/google/registry/flows/EppTlsAction.java +++ b/core/src/main/java/google/registry/flows/EppTlsAction.java @@ -14,6 +14,7 @@ package google.registry.flows; +import google.registry.config.RegistryConfig.Config; import google.registry.request.Action; import google.registry.request.Action.Method; import google.registry.request.Payload; @@ -36,12 +37,17 @@ public class EppTlsAction implements Runnable { @Inject TlsCredentials tlsCredentials; @Inject HttpServletRequest request; @Inject EppRequestHandler eppRequestHandler; + + @Inject + @Config("sessionSecret") + String sessionSecret; + @Inject EppTlsAction() {} @Override public void run() { eppRequestHandler.executeEpp( - new CookieSessionMetadata(request), + new CookieSessionMetadata(request, sessionSecret), tlsCredentials, EppRequestSource.TLS, false, // This endpoint is never a dry run. diff --git a/core/src/test/java/google/registry/flows/CookieSessionMetadataTest.java b/core/src/test/java/google/registry/flows/CookieSessionMetadataTest.java index f9fe7499974..598c04e7f54 100644 --- a/core/src/test/java/google/registry/flows/CookieSessionMetadataTest.java +++ b/core/src/test/java/google/registry/flows/CookieSessionMetadataTest.java @@ -16,22 +16,47 @@ import static com.google.common.truth.Truth.assertThat; import static google.registry.flows.CookieSessionMetadata.COOKIE_NAME; -import static google.registry.flows.CookieSessionMetadata.decode; -import static google.registry.flows.CookieSessionMetadata.encode; +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.nio.charset.StandardCharsets.UTF_8; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import com.google.common.collect.ImmutableSet; +import com.google.common.io.BaseEncoding; import google.registry.testing.FakeResponse; import jakarta.servlet.http.HttpServletRequest; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import org.junit.jupiter.api.Test; /** Unit tests for {@link CookieSessionMetadata}. */ public class CookieSessionMetadataTest { + private static final String TEST_SECRET = "my-test-unsigned-session-secret-key-32-chars-long"; + private HttpServletRequest request = mock(HttpServletRequest.class); private FakeResponse response = new FakeResponse(); - private CookieSessionMetadata cookieSessionMetadata = new CookieSessionMetadata(request); + private CookieSessionMetadata cookieSessionMetadata = + new CookieSessionMetadata(request, TEST_SECRET); + + private String createSignedCookie(String plainText) { + try { + byte[] payloadBytes = plainText.getBytes(US_ASCII); + byte[] signatureBytes = calculateHmac(payloadBytes, TEST_SECRET); + String encodedPayload = BaseEncoding.base64Url().encode(payloadBytes); + String encodedSignature = BaseEncoding.base64Url().encode(signatureBytes); + return encodedPayload + "." + encodedSignature; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static byte[] calculateHmac(byte[] data, String secret) throws Exception { + SecretKeySpec signingKey = new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(signingKey); + return mac.doFinal(data); + } @Test void testNoCookie() { @@ -45,11 +70,11 @@ void testCookieWithAllFields() { when(request.getHeader("Cookie")) .thenReturn( "THIS_COOKIE=foo; SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + " serviceExtensionUris=A|B|C}") + "; THAT_COOKIE=bar"); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar"); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5); assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("A", "B", "C"); @@ -60,10 +85,10 @@ void testCookieWithNullRegistrar() { when(request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=null, failedLoginAttempts=5, " + " serviceExtensionUris=A|B|C}")); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); assertThat(cookieSessionMetadata.getRegistrarId()).isNull(); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5); assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("A", "B", "C"); @@ -74,10 +99,10 @@ void testCookieWithEmptyExtension() { when(request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + " serviceExtensionUris=}")); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar"); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5); assertThat(cookieSessionMetadata.getServiceExtensionUris()).isEmpty(); @@ -88,10 +113,10 @@ void testCookieWithSingleExtension() { when(request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + " serviceExtensionUris=Foo}")); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar"); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5); assertThat(cookieSessionMetadata.getServiceExtensionUris()).containsExactly("Foo"); @@ -102,10 +127,10 @@ void testIncrementFailedLoginAttempts() { when(request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + " serviceExtensionUris=Foo}")); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); cookieSessionMetadata.incrementFailedLoginAttempts(); assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar"); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(6); @@ -117,10 +142,10 @@ void testResetFailedLoginAttempts() { when(request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + " serviceExtensionUris=Foo}")); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); cookieSessionMetadata.resetFailedLoginAttempts(); assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar"); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(0); @@ -132,10 +157,10 @@ void testSetRegistrarId() { when(request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + " serviceExtensionUris=Foo}")); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); cookieSessionMetadata.setRegistrarId("new_registrar"); assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("new_registrar"); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5); @@ -147,10 +172,10 @@ void testSetExtensions() { when(request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + " serviceExtensionUris=Foo}")); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); cookieSessionMetadata.setServiceExtensionUris(ImmutableSet.of("Bar", "Baz", "foo:bar:baz-1.3")); assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar"); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5); @@ -163,10 +188,10 @@ void testSetEmptyExtensions() { when(request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + " serviceExtensionUris=Foo}")); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); cookieSessionMetadata.setServiceExtensionUris(ImmutableSet.of()); assertThat(cookieSessionMetadata.getRegistrarId()).isEqualTo("test_registrar"); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(5); @@ -178,10 +203,10 @@ void testInvalidate() { when(request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + encode( + + createSignedCookie( "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + " serviceExtensionUris=Foo}")); - cookieSessionMetadata = new CookieSessionMetadata(request); + cookieSessionMetadata = new CookieSessionMetadata(request, TEST_SECRET); cookieSessionMetadata.invalidate(); assertThat(cookieSessionMetadata.getRegistrarId()).isNull(); assertThat(cookieSessionMetadata.getFailedLoginAttempts()).isEqualTo(0); @@ -191,22 +216,54 @@ void testInvalidate() { @Test void testSave() { cookieSessionMetadata.save(response); - String value = - decode( - response.getHeaders().get("Set-Cookie").toString().substring(COOKIE_NAME.length() + 1)); - assertThat(value) - .isEqualTo( - "CookieSessionMetadata{clientId=null, failedLoginAttempts=0, serviceExtensionUris=}"); + String cookieHeader = response.getHeaders().get("Set-Cookie").toString(); + String cookieValue = cookieHeader.substring(COOKIE_NAME.length() + 1); + + // Verify the saved cookie is signed correctly and parses successfully + CookieSessionMetadata verifyMetadata = + new CookieSessionMetadata(mockRequestWithCookie(cookieValue), TEST_SECRET); + assertThat(verifyMetadata.getRegistrarId()).isNull(); + assertThat(verifyMetadata.getFailedLoginAttempts()).isEqualTo(0); + assertThat(verifyMetadata.getServiceExtensionUris()).isEmpty(); + cookieSessionMetadata.setRegistrarId("new_registrar"); cookieSessionMetadata.setServiceExtensionUris(ImmutableSet.of("Bar", "Baz")); cookieSessionMetadata.incrementFailedLoginAttempts(); cookieSessionMetadata.save(response); - value = - decode( - response.getHeaders().get("Set-Cookie").toString().substring(COOKIE_NAME.length() + 1)); - assertThat(value) - .isEqualTo( - "CookieSessionMetadata{clientId=new_registrar, failedLoginAttempts=1," - + " serviceExtensionUris=Bar|Baz}"); + + cookieHeader = response.getHeaders().get("Set-Cookie").toString(); + cookieValue = cookieHeader.substring(COOKIE_NAME.length() + 1); + verifyMetadata = new CookieSessionMetadata(mockRequestWithCookie(cookieValue), TEST_SECRET); + assertThat(verifyMetadata.getRegistrarId()).isEqualTo("new_registrar"); + assertThat(verifyMetadata.getFailedLoginAttempts()).isEqualTo(1); + assertThat(verifyMetadata.getServiceExtensionUris()).containsExactly("Bar", "Baz"); + } + + @Test + void testSignatureMismatch_rejected() { + // Session cookie signed with a different key + String invalidSignedCookie = + "SESSION_INFO=" + + createSignedCookie( + "CookieSessionMetadata{clientId=test_registrar, failedLoginAttempts=5, " + + " serviceExtensionUris=Foo}") + + "; THAT_COOKIE=bar"; + + // Re-verify using a different secret key + HttpServletRequest badRequest = mock(HttpServletRequest.class); + when(badRequest.getHeader("Cookie")).thenReturn(invalidSignedCookie); + CookieSessionMetadata badMetadata = + new CookieSessionMetadata(badRequest, "different-secret-key-32-chars-long"); + + // The metadata should be parsed as empty/no session + assertThat(badMetadata.getRegistrarId()).isNull(); + assertThat(badMetadata.getFailedLoginAttempts()).isEqualTo(0); + assertThat(badMetadata.getServiceExtensionUris()).isEmpty(); + } + + private static HttpServletRequest mockRequestWithCookie(String cookieValue) { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Cookie")).thenReturn("SESSION_INFO=" + cookieValue); + return req; } } diff --git a/core/src/test/java/google/registry/flows/EppTlsActionTest.java b/core/src/test/java/google/registry/flows/EppTlsActionTest.java index c71f9bf9b31..608f0a29667 100644 --- a/core/src/test/java/google/registry/flows/EppTlsActionTest.java +++ b/core/src/test/java/google/registry/flows/EppTlsActionTest.java @@ -25,6 +25,8 @@ import com.google.common.io.BaseEncoding; import jakarta.servlet.http.HttpServletRequest; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -39,10 +41,12 @@ void testPassesArgumentsThrough() { action.inputXmlBytes = INPUT_XML_BYTES; action.tlsCredentials = mock(TlsCredentials.class); action.request = mock(HttpServletRequest.class); + action.sessionSecret = "test-secret-32-chars-long-epptlsaction"; when(action.request.getHeader("Cookie")) .thenReturn( "SESSION_INFO=" - + BaseEncoding.base64Url().encode("clientId=ClientIdentifier".getBytes(US_ASCII))); + + createSignedCookie( + "clientId=ClientIdentifier", "test-secret-32-chars-long-epptlsaction")); action.eppRequestHandler = mock(EppRequestHandler.class); action.run(); ArgumentCaptor captor = ArgumentCaptor.forClass(SessionMetadata.class); @@ -56,4 +60,23 @@ void testPassesArgumentsThrough() { eq(INPUT_XML_BYTES)); assertThat(captor.getValue().getRegistrarId()).isEqualTo("ClientIdentifier"); } + + private static String createSignedCookie(String plainText, String secret) { + try { + byte[] payloadBytes = plainText.getBytes(US_ASCII); + byte[] signatureBytes = calculateHmac(payloadBytes, secret); + String encodedPayload = BaseEncoding.base64Url().encode(payloadBytes); + String encodedSignature = BaseEncoding.base64Url().encode(signatureBytes); + return encodedPayload + "." + encodedSignature; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private static byte[] calculateHmac(byte[] data, String secret) throws Exception { + SecretKeySpec signingKey = new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(signingKey); + return mac.doFinal(data); + } }