From 86c372cce98316576cbb254837ac917948e2e9f4 Mon Sep 17 00:00:00 2001 From: "Raphael A. Bauer" Date: Mon, 15 Jun 2026 18:00:02 +0200 Subject: [PATCH] Reject weak JWT signing secrets at startup HS256 requires a 256-bit key, but NinjaSessionConverter used the Base64-decoded secret directly with no length check, so a weak or empty secret produced working-but-forgeable session tokens. Enforce a minimum of 32 bytes and fail fast at startup otherwise. Replace the 'changeme' demo default (which decoded to ~6 bytes) with a real 256-bit secret in the demo configs to keep them starting. --- .../ninjax/core/NinjaSessionConverter.java | 11 +++ .../core/NinjaSessionConverterTest.java | 88 +++++++++++++++++++ .../src/main/java/conf/application.conf | 3 +- .../src/test/resources/conf/application.conf | 2 +- .../src/main/java/conf/application.conf | 3 +- .../src/test/resources/conf/application.conf | 2 +- 6 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 ninjax-core/src/test/java/org/r10r/ninjax/core/NinjaSessionConverterTest.java diff --git a/ninjax-core/src/main/java/org/r10r/ninjax/core/NinjaSessionConverter.java b/ninjax-core/src/main/java/org/r10r/ninjax/core/NinjaSessionConverter.java index 0d447ef..c812ffc 100644 --- a/ninjax-core/src/main/java/org/r10r/ninjax/core/NinjaSessionConverter.java +++ b/ninjax-core/src/main/java/org/r10r/ninjax/core/NinjaSessionConverter.java @@ -35,6 +35,17 @@ public NinjaSessionConverter(NinjaProperties ninjaProperties) { }); byte[] decodedKey = Base64.getDecoder().decode(encodedSecret); + + // HS256 requires a key of at least 256 bits (32 bytes). A shorter key (e.g. the + // 'changeme' demo default) would still "work" with this custom Jwts implementation but + // produces weak, forgeable tokens. Fail fast at startup instead of silently accepting it. + int MINIMUM_SECRET_LENGTH_IN_BYTES = 32; + if (decodedKey.length < MINIMUM_SECRET_LENGTH_IN_BYTES) { + throw new RuntimeException(String.format( + "The secret '%s' in 'conf/application.conf' is too weak. HS256 requires at least %d bytes (256 bits) after Base64-decoding, but the configured secret decodes to %d bytes. Please generate a strong secret using 'mvn ninja:generateSecret'.", + NinjaConstants.NINJA_APPLICATION_SECRET_KEY, MINIMUM_SECRET_LENGTH_IN_BYTES, decodedKey.length)); + } + this.secretKeyForSessionEncryption = new SecretKeySpec(decodedKey, 0, decodedKey.length, "HmacSHA256"); this.sessionExpiryTimeInSeconds = ninjaProperties.get("application.session.expire_time_in_seconds").map(v -> Long.valueOf(v)); diff --git a/ninjax-core/src/test/java/org/r10r/ninjax/core/NinjaSessionConverterTest.java b/ninjax-core/src/test/java/org/r10r/ninjax/core/NinjaSessionConverterTest.java new file mode 100644 index 0000000..5b73199 --- /dev/null +++ b/ninjax-core/src/test/java/org/r10r/ninjax/core/NinjaSessionConverterTest.java @@ -0,0 +1,88 @@ +package org.r10r.ninjax.core; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.r10r.ninjax.core.properties.NinjaProperties; + +public class NinjaSessionConverterTest { + + /** + * Test double for NinjaProperties that serves a fixed map of properties instead of + * loading from conf/application.conf. + */ + private static class FixedNinjaProperties extends NinjaProperties { + private final Map values; + + FixedNinjaProperties(Map values) { + this.values = values; + } + + @Override + public Optional get(String propertyName) { + return Optional.ofNullable(values.get(propertyName)); + } + } + + private static String base64SecretOfLength(int numberOfBytes) { + return Base64.getEncoder().encodeToString(new byte[numberOfBytes]); + } + + @Test + public void shouldRejectSecretShorterThan32Bytes() { + // given + NinjaProperties properties = new FixedNinjaProperties( + Map.of(NinjaConstants.NINJA_APPLICATION_SECRET_KEY, base64SecretOfLength(31))); + + // when + RuntimeException exception = assertThrows(RuntimeException.class, + () -> new NinjaSessionConverter(properties)); + + // then + assertThat(exception.getMessage()).contains("too weak"); + } + + @Test + public void shouldRejectWeakChangemeDemoDefault() { + // given + NinjaProperties properties = new FixedNinjaProperties( + Map.of(NinjaConstants.NINJA_APPLICATION_SECRET_KEY, "changeme")); + + // when + RuntimeException exception = assertThrows(RuntimeException.class, + () -> new NinjaSessionConverter(properties)); + + // then + assertThat(exception.getMessage()).contains("too weak"); + } + + @Test + public void shouldAcceptSecretOfAtLeast32Bytes() { + // given + NinjaProperties properties = new FixedNinjaProperties( + Map.of(NinjaConstants.NINJA_APPLICATION_SECRET_KEY, base64SecretOfLength(32))); + + // when + NinjaSessionConverter converter = new NinjaSessionConverter(properties); + + // then + assertThat(converter).isNotNull(); + } + + @Test + public void shouldFailWhenSecretIsMissing() { + // given + NinjaProperties properties = new FixedNinjaProperties(Map.of()); + + // when + RuntimeException exception = assertThrows(RuntimeException.class, + () -> new NinjaSessionConverter(properties)); + + // then + assertThat(exception.getMessage()).contains("Missing key"); + } +} diff --git a/ninjax-demo-todo/src/main/java/conf/application.conf b/ninjax-demo-todo/src/main/java/conf/application.conf index 6e7daa6..9a77022 100644 --- a/ninjax-demo-todo/src/main/java/conf/application.conf +++ b/ninjax-demo-todo/src/main/java/conf/application.conf @@ -1,5 +1,6 @@ ninja.port=8081 -application.secret=changeme +# Demo secret only. Generate your own for production with 'mvn ninja:generateSecret'. +application.secret=RHwx0fWz73AlJx1fulfkrYKL5Yo7t8F1H8xUajByE28= application.session.expire_time_in_seconds=3600 application.session.cookie.secure=false diff --git a/ninjax-demo-todo/src/test/resources/conf/application.conf b/ninjax-demo-todo/src/test/resources/conf/application.conf index 1f03122..cc263a1 100644 --- a/ninjax-demo-todo/src/test/resources/conf/application.conf +++ b/ninjax-demo-todo/src/test/resources/conf/application.conf @@ -1,6 +1,6 @@ # Set in test: ninja.port= -application.secret=changeme +application.secret=RHwx0fWz73AlJx1fulfkrYKL5Yo7t8F1H8xUajByE28= application.session.expire_time_in_seconds=3600 application.session.cookie.secure=false diff --git a/ninjax-jetty-demo-todo/src/main/java/conf/application.conf b/ninjax-jetty-demo-todo/src/main/java/conf/application.conf index 6e7daa6..9a77022 100644 --- a/ninjax-jetty-demo-todo/src/main/java/conf/application.conf +++ b/ninjax-jetty-demo-todo/src/main/java/conf/application.conf @@ -1,5 +1,6 @@ ninja.port=8081 -application.secret=changeme +# Demo secret only. Generate your own for production with 'mvn ninja:generateSecret'. +application.secret=RHwx0fWz73AlJx1fulfkrYKL5Yo7t8F1H8xUajByE28= application.session.expire_time_in_seconds=3600 application.session.cookie.secure=false diff --git a/ninjax-jetty-demo-todo/src/test/resources/conf/application.conf b/ninjax-jetty-demo-todo/src/test/resources/conf/application.conf index 1f03122..cc263a1 100644 --- a/ninjax-jetty-demo-todo/src/test/resources/conf/application.conf +++ b/ninjax-jetty-demo-todo/src/test/resources/conf/application.conf @@ -1,6 +1,6 @@ # Set in test: ninja.port= -application.secret=changeme +application.secret=RHwx0fWz73AlJx1fulfkrYKL5Yo7t8F1H8xUajByE28= application.session.expire_time_in_seconds=3600 application.session.cookie.secure=false