diff --git a/openig-ai/src/main/java/org/openidentityplatform/openig/ai/filter/TypoglycemiaDetector.java b/openig-ai/src/main/java/org/openidentityplatform/openig/ai/filter/TypoglycemiaDetector.java index 8135963e8..e4aad72af 100644 --- a/openig-ai/src/main/java/org/openidentityplatform/openig/ai/filter/TypoglycemiaDetector.java +++ b/openig-ai/src/main/java/org/openidentityplatform/openig/ai/filter/TypoglycemiaDetector.java @@ -86,29 +86,29 @@ public DetectionResult scan(String prompt) { } // Tokenise: split on whitespace + common punctuation, lowercase everything - String[] tokens = tokenise(prompt); - if (tokens.length == 0) { + String[] words = tokenise(prompt); + if (words.length == 0) { return DetectionResult.clean(); } // Collect all matched keywords (position → keyword) for phrase-window check Map hits = new HashMap<>(); - for (int i = 0; i < tokens.length; i++) { - String token = tokens[i]; - if (token.length() < minWordLength) { + for (int i = 0; i < words.length; i++) { + String word = words[i]; + if (word.length() < minWordLength) { continue; } - Fingerprint fp = Fingerprint.of(token); + Fingerprint fp = Fingerprint.of(word); List candidates = index.get(fp); if (candidates == null) { continue; } for (String keyword : candidates) { - if (isTypoglycemiaMatch(token, keyword)) { + if (isTypoglycemiaMatch(word, keyword)) { hits.put(i, keyword); - logger.debug("Typoglycemia hit: token='{}' matches keyword='{}' at pos={}", token, keyword, i); + logger.debug("Typoglycemia hit: word='{}' matches keyword='{}' at pos={}", word, keyword, i); break; } } @@ -120,7 +120,7 @@ public DetectionResult scan(String prompt) { // A single-token hit on a high-value keyword is sufficient to flag String matchedKeyword = hits.values().iterator().next(); - double score = computeScore(hits, tokens.length); + double score = computeScore(hits, words.length); return DetectionResult.injection( score, diff --git a/openig-core/src/main/java/org/forgerock/openig/jwt/JwtSessionManager.java b/openig-core/src/main/java/org/forgerock/openig/jwt/JwtSessionManager.java index ad6aea3b5..5bdcd99f0 100644 --- a/openig-core/src/main/java/org/forgerock/openig/jwt/JwtSessionManager.java +++ b/openig-core/src/main/java/org/forgerock/openig/jwt/JwtSessionManager.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2014-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openig.jwt; @@ -199,8 +200,8 @@ public static class Heaplet extends GenericHeaplet { private static final Logger logger = LoggerFactory.getLogger(Heaplet.class); - /** RSA needs at least a 512 key length.*/ - private static final int KEY_SIZE = 1024; + /** RSA key size (in bits) for the temporary KeyPair generated when no KeyStore is configured. */ + private static final int KEY_SIZE = 2048; @Override public Object create() throws HeapException { diff --git a/openig-core/src/test/java/org/forgerock/openig/jwt/JwtSessionManagerTest.java b/openig-core/src/test/java/org/forgerock/openig/jwt/JwtSessionManagerTest.java index 4418b575f..389ab684b 100644 --- a/openig-core/src/test/java/org/forgerock/openig/jwt/JwtSessionManagerTest.java +++ b/openig-core/src/test/java/org/forgerock/openig/jwt/JwtSessionManagerTest.java @@ -12,21 +12,30 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openig.jwt; +import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.json.JsonValue.json; +import static org.forgerock.json.JsonValue.object; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.MockitoAnnotations.initMocks; +import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.interfaces.RSAPublicKey; import org.forgerock.http.protocol.Response; import org.forgerock.http.protocol.Status; import org.forgerock.http.session.Session; import org.forgerock.json.jose.jws.handlers.HmacSigningHandler; +import org.forgerock.openig.heap.HeapUtilsTest; +import org.forgerock.openig.heap.Name; import org.mockito.Mock; import org.testng.annotations.BeforeMethod; import org.testng.annotations.Test; @@ -61,4 +70,19 @@ public void shouldNotSaveSession() throws Exception { manager.save(session, null); verifyNoMoreInteractions(session); } + + @Test + public void shouldGenerateAtLeast2048BitKeyPairWhenNoKeystoreIsConfigured() throws Exception { + JwtSessionManager created = (JwtSessionManager) new JwtSessionManager.Heaplet() + .create(Name.of("this"), json(object()), HeapUtilsTest.buildDefaultHeap()); + + RSAPublicKey publicKey = (RSAPublicKey) keyPairOf(created).getPublic(); + assertThat(publicKey.getModulus().bitLength()).isGreaterThanOrEqualTo(2048); + } + + private static KeyPair keyPairOf(JwtSessionManager manager) throws Exception { + Field field = JwtSessionManager.class.getDeclaredField("keyPair"); + field.setAccessible(true); + return (KeyPair) field.get(manager); + } } diff --git a/openig-openam/src/main/java/org/forgerock/openig/openam/TokenTransformationFilter.java b/openig-openam/src/main/java/org/forgerock/openig/openam/TokenTransformationFilter.java index 78d4ec244..82ea78125 100644 --- a/openig-openam/src/main/java/org/forgerock/openig/openam/TokenTransformationFilter.java +++ b/openig-openam/src/main/java/org/forgerock/openig/openam/TokenTransformationFilter.java @@ -12,7 +12,7 @@ * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2015-2016 ForgeRock AS. - * Copyright 2018 3A Systems, LLC + * Copyright 2018-2026 3A Systems, LLC. */ package org.forgerock.openig.openam; @@ -152,7 +152,7 @@ public Promise filter(final Context context, final String issued_token=cache.getIfPresent(resolvedIdToken); if (issued_token!=null) { if (logger.isTraceEnabled()) { - logger.trace("get ftrom cache {}", issued_token); + logger.trace("issued token found in cache (length={})", issued_token.length()); } return next.handle(new StsContext(context, issued_token), request); } diff --git a/openig-war/src/main/java/org/forgerock/openig/web/UiAdminHttpApplication.java b/openig-war/src/main/java/org/forgerock/openig/web/UiAdminHttpApplication.java index f0932750f..7daed794a 100644 --- a/openig-war/src/main/java/org/forgerock/openig/web/UiAdminHttpApplication.java +++ b/openig-war/src/main/java/org/forgerock/openig/web/UiAdminHttpApplication.java @@ -12,6 +12,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openig.web; @@ -70,16 +71,8 @@ class UiAdminHttpApplication extends AdminHttpApplication { // Unpack it in the OpenIG temp directory (create sub-directory) File unpack = new File(environment.getTempDirectory(), "openig-ui"); - try (JarInputStream jar = new JarInputStream(new BufferedInputStream(url.openStream()))) { - JarEntry entry = jar.getNextJarEntry(); - while (entry != null) { - if (!entry.isDirectory()) { - unpackFileEntry(jar, entry, new File(unpack, entry.getName())); - } - // Close and move to the next entry - jar.closeEntry(); - entry = jar.getNextJarEntry(); - } + try (InputStream in = url.openStream()) { + unpackJar(in, unpack); } // Create a FileResourceSet around that directory @@ -94,6 +87,34 @@ class UiAdminHttpApplication extends AdminHttpApplication { getOpenIGRouter().addRoute(requestUriMatcher(STARTS_WITH, "studio"), handler); } + /** + * Unpacks all file entries of the given jar stream into the {@code unpack} directory. + * + * @param in the jar content + * @param unpack the directory to unpack file entries into + * @throws IOException when unpack fails + */ + static void unpackJar(final InputStream in, final File unpack) throws IOException { + String unpackPath = unpack.getCanonicalPath() + File.separator; + try (JarInputStream jar = new JarInputStream(new BufferedInputStream(in))) { + JarEntry entry = jar.getNextJarEntry(); + while (entry != null) { + if (!entry.isDirectory()) { + File destination = new File(unpack, entry.getName()); + // Reject entries that would escape the unpack directory (zip slip) + if (!destination.getCanonicalPath().startsWith(unpackPath)) { + throw new IOException("Jar entry '" + entry.getName() + + "' is outside of the unpack directory " + unpack); + } + unpackFileEntry(jar, entry, destination); + } + // Close and move to the next entry + jar.closeEntry(); + entry = jar.getNextJarEntry(); + } + } + } + private static void unpackFileEntry(final JarInputStream jar, final JarEntry entry, final File destination) throws IOException { // Prepare parent directories if they do not exists yet diff --git a/openig-war/src/test/java/org/forgerock/openig/web/UiAdminHttpApplicationTest.java b/openig-war/src/test/java/org/forgerock/openig/web/UiAdminHttpApplicationTest.java index ee91bf655..78cb045d0 100644 --- a/openig-war/src/test/java/org/forgerock/openig/web/UiAdminHttpApplicationTest.java +++ b/openig-war/src/test/java/org/forgerock/openig/web/UiAdminHttpApplicationTest.java @@ -12,18 +12,28 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2016 ForgeRock AS. + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openig.web; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.forgerock.json.JsonValue.json; import static org.forgerock.json.JsonValue.object; import static org.forgerock.openig.http.RunMode.EVALUATION; import static org.forgerock.openig.web.OpenIGInitializerTest.getRelative; import static org.forgerock.services.context.ClientContext.newInternalClientContext; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.util.Collections; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; import org.forgerock.http.Handler; import org.forgerock.http.header.ContentTypeHeader; @@ -52,6 +62,48 @@ public void shouldServeTheUi() throws Exception { assertThat(response.getHeaders().getFirst(ContentTypeHeader.class)).isEqualTo("text/html"); } + @Test + public void shouldUnpackRegularJarEntryIntoUnpackDirectory() throws Exception { + File root = Files.createTempDirectory("openig-ui").toFile(); + File unpack = new File(root, "openig-ui"); + + UiAdminHttpApplication.unpackJar(jarWithEntry("index.html", ""), unpack); + + assertThat(new File(unpack, "index.html")).hasContent(""); + } + + @Test + public void shouldRejectJarEntryEscapingUnpackDirectory() throws Exception { + File root = Files.createTempDirectory("openig-ui").toFile(); + File unpack = new File(root, "openig-ui"); + + // A regular entry first, so that the unpack directory exists when the escaping entry is processed + ByteArrayInputStream jar = jarWithEntries("index.html", "", + "../evil.txt", "boom"); + + assertThatThrownBy(() -> UiAdminHttpApplication.unpackJar(jar, unpack)) + .isInstanceOf(IOException.class) + .hasMessageContaining("../evil.txt"); + assertThat(new File(root, "evil.txt")).doesNotExist(); + } + + private static ByteArrayInputStream jarWithEntry(String name, String content) throws IOException { + return jarWithEntries(name, content); + } + + /** Builds an in-memory jar from {@code name, content} pairs, in the given order. */ + private static ByteArrayInputStream jarWithEntries(String... nameContentPairs) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream(bytes)) { + for (int i = 0; i < nameContentPairs.length; i += 2) { + jar.putNextEntry(new JarEntry(nameContentPairs[i])); + jar.write(nameContentPairs[i + 1].getBytes(StandardCharsets.UTF_8)); + jar.closeEntry(); + } + } + return new ByteArrayInputStream(bytes.toByteArray()); + } + private static UriRouterContext newUriRouterContext(Context parent) { return new UriRouterContext(parent, "",