This is an automated email from the ASF dual-hosted git repository. benweidig pushed a commit to branch TAP5-2834 in repository https://gitbox.apache.org/repos/asf/tapestry-5.git
commit 25388ea4bcdd90c354fb6e643cab68c484120f07 Author: Ben Weidig <[email protected]> AuthorDate: Sat Jun 27 10:18:57 2026 +0200 TAP5-2834: Hmac Passphrase rules improved --- .../java/org/apache/tapestry5/SymbolConstants.java | 10 +- .../internal/services/ClientDataEncoderImpl.java | 46 +++-- .../services/ClientDataEncoderImplTest.groovy | 136 -------------- .../services/ClientDataEncoderImplTest.java | 201 +++++++++++++++++++++ 4 files changed, 236 insertions(+), 157 deletions(-) diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java b/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java index 9a5c845b0..810f5b018 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/SymbolConstants.java @@ -397,9 +397,13 @@ public class SymbolConstants /** * A passphrase used as the basis of hash-based message authentication (HMAC) for any object stream data stored on - * the client. The default phrase is the empty string, which will result in a logged runtime <em>error</em>. - * You should configure this to a reasonable value (longer is better) and ensure that all servers in your cluster - * share the same value (configuring this in code, rather than the command line, is preferred). + * the client. The passphrase must be at least 20 characters long (aligned with the RFC 2104 SHA-1 digest length). + * You should configure this to a strong, random value and ensure that all servers in your cluster share the same + * value (configuring this in code, rather than on the command line, is preferred). + * + * <p>In <strong>production mode</strong>, a missing or too-short passphrase causes a startup exception. + * In <strong>development mode</strong>, a warning is logged and a random per-startup fallback key is used instead, + * meaning client data will not survive server restarts.</p> * * @see org.apache.tapestry5.services.ClientDataEncoder * @since 5.3.6 diff --git a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ClientDataEncoderImpl.java b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ClientDataEncoderImpl.java index ec6063acd..e168e38d9 100644 --- a/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ClientDataEncoderImpl.java +++ b/tapestry-core/src/main/java/org/apache/tapestry5/internal/services/ClientDataEncoderImpl.java @@ -1,4 +1,4 @@ -// Copyright 2009, 2012 The Apache Software Foundation +// Copyright 2009, 2012, 2026 The Apache Software Foundation // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,9 +14,9 @@ package org.apache.tapestry5.internal.services; +import org.apache.commons.lang3.StringUtils; import org.apache.tapestry5.SymbolConstants; import org.apache.tapestry5.alerts.AlertManager; -import org.apache.tapestry5.http.internal.TapestryHttpInternalConstants; import org.apache.tapestry5.internal.TapestryInternalUtils; import org.apache.tapestry5.internal.util.Base64InputStream; import org.apache.tapestry5.internal.util.MacOutputStream; @@ -30,43 +30,53 @@ import javax.crypto.spec.SecretKeySpec; import java.io.BufferedInputStream; import java.io.IOException; import java.io.ObjectInputStream; -import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; import java.security.Key; import java.security.MessageDigest; +import java.util.UUID; import java.util.zip.GZIPInputStream; public class ClientDataEncoderImpl implements ClientDataEncoder { + private static final int MIN_PASSPHRASE_LENGTH = 20; + private final URLEncoder urlEncoder; private final Key hmacKey; - public ClientDataEncoderImpl(URLEncoder urlEncoder, @Symbol(SymbolConstants.HMAC_PASSPHRASE) String passphrase, + public ClientDataEncoderImpl(URLEncoder urlEncoder, + @Symbol(SymbolConstants.HMAC_PASSPHRASE) String passphrase, Logger logger, - @Symbol(TapestryHttpInternalConstants.TAPESTRY_APP_PACKAGE_PARAM) - String applicationPackageName, AlertManager alertManager) throws UnsupportedEncodingException + AlertManager alertManager, + @Symbol(SymbolConstants.PRODUCTION_MODE) boolean productionMode) { this.urlEncoder = urlEncoder; - if (passphrase.equals("")) + // TAP5-2834: Also check for minimum length + if (StringUtils.isBlank(passphrase) || passphrase.length() < MIN_PASSPHRASE_LENGTH) { - String message = String.format("The symbol '%s' has not been configured. " + - "This is used to configure hash-based message authentication of Tapestry data stored in forms, or in the URL. " + - "You application is less secure, and more vulnerable to denial-of-service attacks, when this symbol is not configured.", - SymbolConstants.HMAC_PASSPHRASE); + String message = String.format( + "SymbolConstants.HMAC_PASSPHRASE '%s' has not been configured or is too short (minimum %d characters). " + + "This is used to configure hash-based message authentication of Tapestry data stored in forms or in the URL. " + + "Your application is less secure and more vulnerable to denial-of-service attacks when this symbol is not properly configured.", + SymbolConstants.HMAC_PASSPHRASE, MIN_PASSPHRASE_LENGTH); - // Now to really get the attention of the developer! + if (productionMode) + { + throw new RuntimeException(message); + } alertManager.error(message); - logger.error(message); - // Override the blank parameter to set a default value. Use the application package name, - // which is justly slightly more secure than having a fixed default. - passphrase = applicationPackageName; + // TAP5-2834: No longer fall back to application package, as it's easier to guess/deduct. + // As we disallow an empty passphrase in production, this should only become an issue + // if run in non-production in a clustered environment. and even then, the easy fix + // will be configuring a custom passphrase. + passphrase = UUID.randomUUID().toString(); } - hmacKey = new SecretKeySpec(passphrase.getBytes("UTF8"), "HmacSHA1"); + hmacKey = new SecretKeySpec(passphrase.getBytes(StandardCharsets.UTF_8), "HmacSHA1"); } public ClientDataSink createSink() @@ -127,7 +137,7 @@ public class ClientDataEncoderImpl implements ClientDataEncoder String actual = macOs.getResult(); - if (!MessageDigest.isEqual(storedHmacResult.getBytes(), actual.getBytes())) + if (!MessageDigest.isEqual(storedHmacResult.getBytes(StandardCharsets.UTF_8), actual.getBytes(StandardCharsets.UTF_8))) { throw new IOException("Client data associated with the current request appears to have been tampered with " + "(the HMAC signature does not match)."); diff --git a/tapestry-core/src/test/groovy/org/apache/tapestry5/internal/services/ClientDataEncoderImplTest.groovy b/tapestry-core/src/test/groovy/org/apache/tapestry5/internal/services/ClientDataEncoderImplTest.groovy deleted file mode 100644 index 71cc54587..000000000 --- a/tapestry-core/src/test/groovy/org/apache/tapestry5/internal/services/ClientDataEncoderImplTest.groovy +++ /dev/null @@ -1,136 +0,0 @@ -package org.apache.tapestry5.internal.services - -import org.apache.tapestry5.alerts.AlertManager -import org.apache.tapestry5.services.ClientDataEncoder -import org.apache.tapestry5.test.ioc.TestBase -import org.easymock.EasyMock -import org.slf4j.Logger -import org.testng.annotations.Test - -class ClientDataEncoderImplTest extends TestBase { - - def tryEncodeAndDecode(ClientDataEncoder cde) { - def now = new Date() - def input = "The current time is $now" - - - String clientData = convertToClientData cde, input - - def stream = cde.decodeClientData clientData - - def output = stream.readObject() - - assert !input.is(output) - assert input == output - } - - def String convertToClientData(ClientDataEncoder cde, input) { - def sink = cde.createSink() - - sink.getObjectOutputStream().with { stream -> - stream << input - stream.close() - } - - sink.clientData - } - - def extractData(String encoded) { - def colonx = encoded.indexOf(':') - - encoded.substring(colonx + 1) - } - - @Test - void blank_passphrase_works_but_logs_error() { - Logger logger = newMock Logger - AlertManager alertManager = newMock AlertManager - - logger.error(EasyMock.isA(String)) - alertManager.error(EasyMock.isA(String)) - - replay() - - ClientDataEncoder cde = new ClientDataEncoderImpl(null, "", logger, "foo.bar", alertManager) - - tryEncodeAndDecode cde - - verify() - } - - @Test - void no_logged_error_with_non_blank_passphrase() { - ClientDataEncoder cde = new ClientDataEncoderImpl(null, "Testing, Testing, 1.., 2.., 3...", null, "foo.bar", null) - - tryEncodeAndDecode cde - } - - @Test - void passphrase_affects_encoded_output() { - ClientDataEncoder first = new ClientDataEncoderImpl(null, "first passphrase", null, "foo.bar", null) - ClientDataEncoder second = new ClientDataEncoderImpl(null, " different passphrase ", null, "foo.bar", null) - - def input = "current time millis is ${System.currentTimeMillis()} ms" - - def output1 = convertToClientData first, input - def output2 = convertToClientData second, input - - assert output1 != output2 - - assert extractData(output1) == extractData(output2) - } - - @Test(expectedExceptions = IllegalArgumentException) - void decode_with_missing_hmac_prefix_is_a_failure() { - ClientDataEncoder cde = new ClientDataEncoderImpl(null, "a passphrase", null, "foo.bar", null) - - cde.decodeClientData("so completely invalid") - } - - @Test - void incorrect_hmac_is_detected() { - - // Simulate tampering by encoding with one passphrase and attempting to decode with a different - // passphrase. - ClientDataEncoder first = new ClientDataEncoderImpl(null, "first passphrase", null, "foo.bar", null) - ClientDataEncoder second = new ClientDataEncoderImpl(null, " different passphrase ", null, "foo.bar", null) - - def input = "current time millis is ${System.currentTimeMillis()} ms" - - def output = convertToClientData first, input - - try { - second.decodeClientData(output) - unreachable() - } - catch (Exception e) { - assert e.message.contains("HMAC signature does not match") - } - } - - @Test(expectedExceptions = EOFException) - void check_for_eof() { - ClientDataEncoder cde = new ClientDataEncoderImpl(null, "hmac passphrase", null, "foo.bar", null) - - def sink = cde.createSink() - - def os = sink.objectOutputStream - - def names = ["fred", "barney", "wilma"] - - names.each { os.writeObject it } - - os.close() - - def ois = cde.decodeClientData(sink.clientData) - - names.each { assert (ois.readObject() == it )} - - // This should fail: - - ois.readObject() - - unreachable() - } - -} diff --git a/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ClientDataEncoderImplTest.java b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ClientDataEncoderImplTest.java new file mode 100644 index 000000000..91c4ed593 --- /dev/null +++ b/tapestry-core/src/test/java/org/apache/tapestry5/internal/services/ClientDataEncoderImplTest.java @@ -0,0 +1,201 @@ +// Copyright 2012, 2020, 2026 The Apache Software Foundation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package org.apache.tapestry5.internal.services; + +import org.apache.tapestry5.alerts.AlertManager; +import org.apache.tapestry5.services.ClientDataEncoder; +import org.apache.tapestry5.services.ClientDataSink; +import org.easymock.EasyMock; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; + +import java.io.EOFException; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ClientDataEncoderImplTest +{ + private static final String VALID_PASSPHRASE = "a sufficiently long passphrase"; + + private String convertToClientData(ClientDataEncoder cde, Object input) throws IOException + { + ClientDataSink sink = cde.createSink(); + ObjectOutputStream stream = sink.getObjectOutputStream(); + stream.writeObject(input); + stream.close(); + return sink.getClientData(); + } + + private void tryEncodeAndDecode(ClientDataEncoder cde) throws Exception + { + String input = "The current time is " + new java.util.Date(); + String clientData = convertToClientData(cde, input); + ObjectInputStream stream = cde.decodeClientData(clientData); + Object output = stream.readObject(); + assertNotSame(input, output); + assertEquals(input, output); + } + + private String extractData(String encoded) + { + int colonx = encoded.indexOf(':'); + return encoded.substring(colonx + 1); + } + + @Test + void blank_passphrase_warns_in_development() throws Exception + { + Logger logger = EasyMock.mock(Logger.class); + AlertManager alertManager = EasyMock.mock(AlertManager.class); + + logger.error(EasyMock.isA(String.class)); + alertManager.error(EasyMock.isA(String.class)); + + EasyMock.replay(logger, alertManager); + + ClientDataEncoder cde = new ClientDataEncoderImpl(null, "", logger, alertManager, false); + tryEncodeAndDecode(cde); + + EasyMock.verify(logger, alertManager); + } + + @Test + void whitespace_passphrase_warns_in_development() throws Exception + { + Logger logger = EasyMock.mock(Logger.class); + AlertManager alertManager = EasyMock.mock(AlertManager.class); + + logger.error(EasyMock.isA(String.class)); + alertManager.error(EasyMock.isA(String.class)); + + EasyMock.replay(logger, alertManager); + + ClientDataEncoder cde = new ClientDataEncoderImpl(null, " ", logger, alertManager, false); + tryEncodeAndDecode(cde); + + EasyMock.verify(logger, alertManager); + } + + @Test + void short_passphrase_warns_in_development() throws Exception + { + Logger logger = EasyMock.mock(Logger.class); + AlertManager alertManager = EasyMock.mock(AlertManager.class); + + logger.error(EasyMock.isA(String.class)); + alertManager.error(EasyMock.isA(String.class)); + + EasyMock.replay(logger, alertManager); + + ClientDataEncoder cde = new ClientDataEncoderImpl(null, "short", logger, alertManager, false); + tryEncodeAndDecode(cde); + + EasyMock.verify(logger, alertManager); + } + + @Test + void blank_passphrase_fails_in_production() + { + assertThrows(RuntimeException.class, + () -> new ClientDataEncoderImpl(null, "", null, null, true)); + } + + @Test + void short_passphrase_fails_in_production() + { + assertThrows(RuntimeException.class, + () -> new ClientDataEncoderImpl(null, "too short", null, null, true)); + } + + @Test + void valid_passphrase_works_in_production() throws Exception + { + ClientDataEncoder cde = new ClientDataEncoderImpl(null, VALID_PASSPHRASE, null, null, true); + tryEncodeAndDecode(cde); + } + + @Test + void no_logged_error_with_sufficient_passphrase() throws Exception + { + ClientDataEncoder cde = new ClientDataEncoderImpl(null, "Testing, Testing, 1.., 2.., 3...", null, null, false); + tryEncodeAndDecode(cde); + } + + @Test + void passphrase_affects_encoded_output() throws Exception + { + ClientDataEncoder first = new ClientDataEncoderImpl(null, "first-passphrase-long-enough", null, null, false); + ClientDataEncoder second = new ClientDataEncoderImpl(null, "different-passphrase-long-enough", null, null, false); + + String input = "current time millis is " + System.currentTimeMillis() + " ms"; + + String output1 = convertToClientData(first, input); + String output2 = convertToClientData(second, input); + + assertNotEquals(output1, output2); + assertEquals(extractData(output1), extractData(output2)); + } + + @Test + void decode_with_missing_hmac_prefix_is_a_failure() throws Exception + { + ClientDataEncoder cde = new ClientDataEncoderImpl(null, VALID_PASSPHRASE, null, null, false); + + assertThrows(IllegalArgumentException.class, + () -> cde.decodeClientData("so completely invalid")); + } + + @Test + void incorrect_hmac_is_detected() throws Exception + { + ClientDataEncoder first = new ClientDataEncoderImpl(null, "first-passphrase-long-enough", null, null, false); + ClientDataEncoder second = new ClientDataEncoderImpl(null, "different-passphrase-long-enough", null, null, false); + + String input = "current time millis is " + System.currentTimeMillis() + " ms"; + String encoded = convertToClientData(first, input); + + RuntimeException ex = assertThrows(RuntimeException.class, + () -> second.decodeClientData(encoded)); + assertTrue(ex.getMessage().contains("HMAC signature does not match")); + } + + @Test + void check_for_eof() throws Exception + { + ClientDataEncoder cde = new ClientDataEncoderImpl(null, "hmac passphrase is valid here", null, null, false); + + ClientDataSink sink = cde.createSink(); + ObjectOutputStream os = sink.getObjectOutputStream(); + + List<String> names = List.of("fred", "barney", "wilma"); + for (String name : names) + { + os.writeObject(name); + } + os.close(); + + ObjectInputStream ois = cde.decodeClientData(sink.getClientData()); + for (String name : names) + { + assertEquals(name, ois.readObject()); + } + + assertThrows(EOFException.class, ois::readObject); + } +}
